uilint 0.2.1 → 0.2.4

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 __dirname3 = dirname(fileURLToPath(import.meta.url));
361
+ const pkgPath = join2(__dirname3, "..", "..", "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,13 +1475,13 @@ 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 join16 } 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";
1363
- import { findWorkspaceRoot as findWorkspaceRoot4 } from "uilint-core/node";
1482
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
1483
+ import { join as join8 } from "path";
1484
+ import { findWorkspaceRoot as findWorkspaceRoot5 } from "uilint-core/node";
1364
1485
 
1365
1486
  // src/utils/next-detect.ts
1366
1487
  import { existsSync as existsSync4, readdirSync } from "fs";
@@ -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",
@@ -1489,16 +1714,34 @@ function isFrontendPackage(pkgJson) {
1489
1714
  };
1490
1715
  return FRONTEND_INDICATORS.some((pkg) => pkg in deps);
1491
1716
  }
1717
+ function isTypeScriptPackage(dir, pkgJson) {
1718
+ if (existsSync6(join5(dir, "tsconfig.json"))) {
1719
+ return true;
1720
+ }
1721
+ const deps = {
1722
+ ...pkgJson.dependencies,
1723
+ ...pkgJson.devDependencies
1724
+ };
1725
+ if ("typescript" in deps) {
1726
+ return true;
1727
+ }
1728
+ for (const configFile of ESLINT_CONFIG_FILES) {
1729
+ if (configFile.endsWith(".ts") && existsSync6(join5(dir, configFile))) {
1730
+ return true;
1731
+ }
1732
+ }
1733
+ return false;
1734
+ }
1492
1735
  function hasEslintConfig(dir) {
1493
1736
  for (const file of ESLINT_CONFIG_FILES) {
1494
- if (existsSync5(join4(dir, file))) {
1737
+ if (existsSync6(join5(dir, file))) {
1495
1738
  return true;
1496
1739
  }
1497
1740
  }
1498
1741
  try {
1499
- const pkgPath = join4(dir, "package.json");
1500
- if (existsSync5(pkgPath)) {
1501
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1742
+ const pkgPath = join5(dir, "package.json");
1743
+ if (existsSync6(pkgPath)) {
1744
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
1502
1745
  if (pkg.eslintConfig) return true;
1503
1746
  }
1504
1747
  } catch {
@@ -1507,14 +1750,14 @@ function hasEslintConfig(dir) {
1507
1750
  }
1508
1751
  function findPackages(rootDir, options) {
1509
1752
  const maxDepth = options?.maxDepth ?? 5;
1510
- const ignoreDirs = options?.ignoreDirs ?? DEFAULT_IGNORE_DIRS2;
1753
+ const ignoreDirs = options?.ignoreDirs ?? DEFAULT_IGNORE_DIRS3;
1511
1754
  const results = [];
1512
1755
  const visited = /* @__PURE__ */ new Set();
1513
1756
  function processPackage(dir, isRoot) {
1514
- const pkgPath = join4(dir, "package.json");
1515
- if (!existsSync5(pkgPath)) return null;
1757
+ const pkgPath = join5(dir, "package.json");
1758
+ if (!existsSync6(pkgPath)) return null;
1516
1759
  try {
1517
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1760
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
1518
1761
  const name = pkg.name || relative(rootDir, dir) || ".";
1519
1762
  return {
1520
1763
  path: dir,
@@ -1522,7 +1765,8 @@ function findPackages(rootDir, options) {
1522
1765
  name,
1523
1766
  hasEslintConfig: hasEslintConfig(dir),
1524
1767
  isFrontend: isFrontendPackage(pkg),
1525
- isRoot
1768
+ isRoot,
1769
+ isTypeScript: isTypeScriptPackage(dir, pkg)
1526
1770
  };
1527
1771
  } catch {
1528
1772
  return null;
@@ -1538,7 +1782,7 @@ function findPackages(rootDir, options) {
1538
1782
  }
1539
1783
  let entries = [];
1540
1784
  try {
1541
- entries = readdirSync2(dir, { withFileTypes: true }).map((d) => ({
1785
+ entries = readdirSync3(dir, { withFileTypes: true }).map((d) => ({
1542
1786
  name: d.name,
1543
1787
  isDirectory: d.isDirectory()
1544
1788
  }));
@@ -1549,7 +1793,7 @@ function findPackages(rootDir, options) {
1549
1793
  if (!ent.isDirectory) continue;
1550
1794
  if (ignoreDirs.has(ent.name)) continue;
1551
1795
  if (ent.name.startsWith(".")) continue;
1552
- walk(join4(dir, ent.name), depth + 1);
1796
+ walk(join5(dir, ent.name), depth + 1);
1553
1797
  }
1554
1798
  }
1555
1799
  walk(rootDir, 0);
@@ -1574,18 +1818,18 @@ function formatPackageOption(pkg) {
1574
1818
  }
1575
1819
 
1576
1820
  // src/utils/package-manager.ts
1577
- import { existsSync as existsSync6 } from "fs";
1821
+ import { existsSync as existsSync7 } from "fs";
1578
1822
  import { spawn } from "child_process";
1579
- import { dirname as dirname5, join as join5 } from "path";
1823
+ import { dirname as dirname5, join as join6 } from "path";
1580
1824
  function detectPackageManager(projectPath) {
1581
1825
  let dir = projectPath;
1582
1826
  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";
1827
+ if (existsSync7(join6(dir, "pnpm-lock.yaml"))) return "pnpm";
1828
+ if (existsSync7(join6(dir, "pnpm-workspace.yaml"))) return "pnpm";
1829
+ if (existsSync7(join6(dir, "yarn.lock"))) return "yarn";
1830
+ if (existsSync7(join6(dir, "bun.lockb"))) return "bun";
1831
+ if (existsSync7(join6(dir, "bun.lock"))) return "bun";
1832
+ if (existsSync7(join6(dir, "package-lock.json"))) return "npm";
1589
1833
  const parent = dirname5(dir);
1590
1834
  if (parent === dir) break;
1591
1835
  dir = parent;
@@ -1593,7 +1837,7 @@ function detectPackageManager(projectPath) {
1593
1837
  return "npm";
1594
1838
  }
1595
1839
  function spawnAsync(command, args, cwd) {
1596
- return new Promise((resolve7, reject) => {
1840
+ return new Promise((resolve8, reject) => {
1597
1841
  const child = spawn(command, args, {
1598
1842
  cwd,
1599
1843
  stdio: "inherit",
@@ -1601,7 +1845,7 @@ function spawnAsync(command, args, cwd) {
1601
1845
  });
1602
1846
  child.on("error", reject);
1603
1847
  child.on("close", (code) => {
1604
- if (code === 0) resolve7();
1848
+ if (code === 0) resolve8();
1605
1849
  else
1606
1850
  reject(new Error(`${command} ${args.join(" ")} exited with ${code}`));
1607
1851
  });
@@ -1627,14 +1871,15 @@ async function installDependencies(pm, projectPath, packages) {
1627
1871
  }
1628
1872
 
1629
1873
  // 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";
1874
+ import { existsSync as existsSync8, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
1875
+ import { join as join7, relative as relative2, dirname as dirname6 } from "path";
1632
1876
  import { parseExpression, parseModule, generateCode } from "magicast";
1633
- var CONFIG_EXTENSIONS = [".mjs", ".js", ".cjs"];
1877
+ import { findWorkspaceRoot as findWorkspaceRoot4 } from "uilint-core/node";
1878
+ var CONFIG_EXTENSIONS = [".ts", ".mjs", ".js", ".cjs"];
1634
1879
  function findEslintConfigFile(projectPath) {
1635
1880
  for (const ext of CONFIG_EXTENSIONS) {
1636
- const configPath = join6(projectPath, `eslint.config${ext}`);
1637
- if (existsSync7(configPath)) {
1881
+ const configPath = join7(projectPath, `eslint.config${ext}`);
1882
+ if (existsSync8(configPath)) {
1638
1883
  return configPath;
1639
1884
  }
1640
1885
  }
@@ -1644,25 +1889,6 @@ function getEslintConfigFilename(configPath) {
1644
1889
  const parts = configPath.split("/");
1645
1890
  return parts[parts.length - 1] || "eslint.config.mjs";
1646
1891
  }
1647
- function hasUilintImport(source) {
1648
- return source.includes('from "uilint-eslint"') || source.includes("from 'uilint-eslint'") || source.includes('require("uilint-eslint")') || source.includes("require('uilint-eslint')");
1649
- }
1650
- function hasUilintConfigsUsage(source) {
1651
- return /\builint\s*\.\s*configs\s*\./.test(source);
1652
- }
1653
- function walkAst(node, visit) {
1654
- if (!node || typeof node !== "object") return;
1655
- visit(node);
1656
- for (const key of Object.keys(node)) {
1657
- const v = node[key];
1658
- if (!v) continue;
1659
- if (Array.isArray(v)) {
1660
- for (const item of v) walkAst(item, visit);
1661
- } else if (typeof v === "object" && v.type) {
1662
- walkAst(v, visit);
1663
- }
1664
- }
1665
- }
1666
1892
  function isIdentifier(node, name) {
1667
1893
  return !!node && node.type === "Identifier" && (name ? node.name === name : typeof node.name === "string");
1668
1894
  }
@@ -1687,35 +1913,6 @@ function hasSpreadProperties(obj) {
1687
1913
  (p2) => p2 && (p2.type === "SpreadElement" || p2.type === "SpreadProperty")
1688
1914
  );
1689
1915
  }
1690
- var IGNORED_AST_KEYS = /* @__PURE__ */ new Set([
1691
- "loc",
1692
- "start",
1693
- "end",
1694
- "extra",
1695
- "leadingComments",
1696
- "trailingComments",
1697
- "innerComments"
1698
- ]);
1699
- function normalizeAstForCompare(node) {
1700
- if (node === null) return null;
1701
- if (node === void 0) return void 0;
1702
- if (typeof node !== "object") return node;
1703
- if (Array.isArray(node)) return node.map(normalizeAstForCompare);
1704
- const out = {};
1705
- const keys = Object.keys(node).filter((k) => !IGNORED_AST_KEYS.has(k)).sort();
1706
- for (const k of keys) {
1707
- if (k.startsWith("$")) continue;
1708
- out[k] = normalizeAstForCompare(node[k]);
1709
- }
1710
- return out;
1711
- }
1712
- function astEquivalent(a, b) {
1713
- try {
1714
- return JSON.stringify(normalizeAstForCompare(a)) === JSON.stringify(normalizeAstForCompare(b));
1715
- } catch {
1716
- return false;
1717
- }
1718
- }
1719
1916
  function collectUilintRuleIdsFromRulesObject(rulesObj) {
1720
1917
  const ids = /* @__PURE__ */ new Set();
1721
1918
  if (!rulesObj || rulesObj.type !== "ObjectExpression") return ids;
@@ -1726,13 +1923,14 @@ function collectUilintRuleIdsFromRulesObject(rulesObj) {
1726
1923
  if (!isStringLiteral(key)) continue;
1727
1924
  const val = key.value;
1728
1925
  if (typeof val !== "string") continue;
1729
- if (!val.startsWith("uilint/")) continue;
1730
- ids.add(val.slice("uilint/".length));
1926
+ if (val.startsWith("uilint/")) {
1927
+ ids.add(val.slice("uilint/".length));
1928
+ }
1731
1929
  }
1732
1930
  return ids;
1733
1931
  }
1734
1932
  function findExportedConfigArrayExpression(mod) {
1735
- function unwrapExpression(expr) {
1933
+ function unwrapExpression2(expr) {
1736
1934
  let e = expr;
1737
1935
  while (e) {
1738
1936
  if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
@@ -1758,11 +1956,11 @@ function findExportedConfigArrayExpression(mod) {
1758
1956
  for (const decl of stmt.declarations ?? []) {
1759
1957
  const id = decl?.id;
1760
1958
  if (!isIdentifier(id, name)) continue;
1761
- const init = unwrapExpression(decl?.init);
1959
+ const init = unwrapExpression2(decl?.init);
1762
1960
  if (!init) return null;
1763
1961
  if (init.type === "ArrayExpression") return init;
1764
- if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") && unwrapExpression(init.arguments?.[0])?.type === "ArrayExpression") {
1765
- return unwrapExpression(init.arguments?.[0]);
1962
+ if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") && unwrapExpression2(init.arguments?.[0])?.type === "ArrayExpression") {
1963
+ return unwrapExpression2(init.arguments?.[0]);
1766
1964
  }
1767
1965
  return null;
1768
1966
  }
@@ -1773,15 +1971,15 @@ function findExportedConfigArrayExpression(mod) {
1773
1971
  if (program2 && program2.type === "Program") {
1774
1972
  for (const stmt of program2.body ?? []) {
1775
1973
  if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
1776
- const decl = unwrapExpression(stmt.declaration);
1974
+ const decl = unwrapExpression2(stmt.declaration);
1777
1975
  if (!decl) break;
1778
1976
  if (decl.type === "ArrayExpression") {
1779
1977
  return { kind: "esm", arrayExpr: decl, program: program2 };
1780
1978
  }
1781
- if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") && unwrapExpression(decl.arguments?.[0])?.type === "ArrayExpression") {
1979
+ if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") && unwrapExpression2(decl.arguments?.[0])?.type === "ArrayExpression") {
1782
1980
  return {
1783
1981
  kind: "esm",
1784
- arrayExpr: unwrapExpression(decl.arguments?.[0]),
1982
+ arrayExpr: unwrapExpression2(decl.arguments?.[0]),
1785
1983
  program: program2
1786
1984
  };
1787
1985
  }
@@ -1820,24 +2018,6 @@ function findExportedConfigArrayExpression(mod) {
1820
2018
  }
1821
2019
  return null;
1822
2020
  }
1823
- function findUsesUilintConfigs(program2) {
1824
- let found = false;
1825
- walkAst(program2, (n) => {
1826
- if (found) return;
1827
- if (n?.type === "MemberExpression") {
1828
- const obj = n.object;
1829
- const prop = n.property;
1830
- if (isIdentifier(prop, "configs") && isIdentifier(obj, "uilint")) {
1831
- found = true;
1832
- return;
1833
- }
1834
- if (obj?.type === "MemberExpression" && isIdentifier(obj.object, "uilint") && isIdentifier(obj.property, "configs")) {
1835
- found = true;
1836
- }
1837
- }
1838
- });
1839
- return found;
1840
- }
1841
2021
  function collectConfiguredUilintRuleIdsFromConfigArray(arrayExpr) {
1842
2022
  const ids = /* @__PURE__ */ new Set();
1843
2023
  if (!arrayExpr || arrayExpr.type !== "ArrayExpression") return ids;
@@ -1890,69 +2070,68 @@ function chooseUniqueIdentifier(base, used) {
1890
2070
  while (used.has(`${base}${i}`)) i++;
1891
2071
  return `${base}${i}`;
1892
2072
  }
1893
- function getEsmUilintDefaultImportLocal(mod) {
1894
- const items = mod?.imports?.$items ?? [];
1895
- const found = items.find(
1896
- (it) => it?.from === "uilint-eslint" && it?.imported === "default"
1897
- );
1898
- return found?.local ?? null;
1899
- }
1900
- function ensureUilintImportAst(mod) {
1901
- const existing = getEsmUilintDefaultImportLocal(mod);
1902
- if (existing) return { local: existing, changed: false };
1903
- mod.imports.$prepend({
1904
- imported: "default",
1905
- local: "uilint",
1906
- from: "uilint-eslint"
1907
- });
1908
- return { local: "uilint", changed: true };
1909
- }
1910
- function findCjsUilintRequireBinding(program2) {
1911
- if (!program2 || program2.type !== "Program") return null;
1912
- for (const stmt of program2.body ?? []) {
1913
- if (stmt?.type !== "VariableDeclaration") continue;
1914
- for (const decl of stmt.declarations ?? []) {
1915
- const id = decl?.id;
1916
- const init = decl?.init;
1917
- if (!isIdentifier(id)) continue;
1918
- if (init?.type === "CallExpression" && isIdentifier(init.callee, "require") && isStringLiteral(init.arguments?.[0]) && init.arguments[0].value === "uilint-eslint") {
1919
- return id.name;
1920
- }
1921
- }
2073
+ function addLocalRuleImportsAst(mod, selectedRules, configPath, rulesRoot, fileExtension = ".js") {
2074
+ const importNames = /* @__PURE__ */ new Map();
2075
+ let changed = false;
2076
+ const configDir = dirname6(configPath);
2077
+ const rulesDir = join7(rulesRoot, ".uilint", "rules");
2078
+ const relativeRulesPath = relative2(configDir, rulesDir).replace(/\\/g, "/");
2079
+ const normalizedRulesPath = relativeRulesPath.startsWith("./") || relativeRulesPath.startsWith("../") ? relativeRulesPath : `./${relativeRulesPath}`;
2080
+ const used = collectTopLevelBindings(mod.$ast);
2081
+ for (const rule of selectedRules) {
2082
+ const importName = chooseUniqueIdentifier(
2083
+ `${rule.id.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^./, (c) => c.toUpperCase())}Rule`,
2084
+ used
2085
+ );
2086
+ importNames.set(rule.id, importName);
2087
+ used.add(importName);
2088
+ const rulePath = `${normalizedRulesPath}/${rule.id}${fileExtension}`;
2089
+ mod.imports.$add({
2090
+ imported: "default",
2091
+ local: importName,
2092
+ from: rulePath
2093
+ });
2094
+ changed = true;
1922
2095
  }
1923
- return null;
2096
+ return { importNames, changed };
1924
2097
  }
1925
- function ensureUilintRequireAst(program2) {
2098
+ function addLocalRuleRequiresAst(program2, selectedRules, configPath, rulesRoot, fileExtension = ".js") {
2099
+ const importNames = /* @__PURE__ */ new Map();
2100
+ let changed = false;
1926
2101
  if (!program2 || program2.type !== "Program") {
1927
- return { local: "uilint", changed: false };
2102
+ return { importNames, changed };
1928
2103
  }
1929
- const existing = findCjsUilintRequireBinding(program2);
1930
- if (existing) return { local: existing, changed: false };
2104
+ const configDir = dirname6(configPath);
2105
+ const rulesDir = join7(rulesRoot, ".uilint", "rules");
2106
+ const relativeRulesPath = relative2(configDir, rulesDir).replace(/\\/g, "/");
2107
+ const normalizedRulesPath = relativeRulesPath.startsWith("./") || relativeRulesPath.startsWith("../") ? relativeRulesPath : `./${relativeRulesPath}`;
1931
2108
  const used = collectTopLevelBindings(program2);
1932
- const local = chooseUniqueIdentifier("uilint", used);
1933
- const stmtMod = parseModule(`const ${local} = require("uilint-eslint");`);
1934
- const stmt = stmtMod.$ast.body?.[0];
1935
- if (!stmt) return { local, changed: false };
1936
- let insertAt = 0;
1937
- const first = program2.body?.[0];
1938
- if (first?.type === "ExpressionStatement" && first.expression?.type === "StringLiteral" && first.expression.value === "use strict") {
1939
- insertAt = 1;
1940
- }
1941
- program2.body.splice(insertAt, 0, stmt);
1942
- return { local, changed: true };
1943
- }
1944
- function buildUilintRuleProperty(rule) {
1945
- const ruleKey = `uilint/${rule.id}`;
1946
- const valueCode = rule.defaultOptions && rule.defaultOptions.length > 0 ? `["${rule.defaultSeverity}", ...${JSON.stringify(
1947
- rule.defaultOptions,
1948
- null,
1949
- 2
1950
- )}]` : `"${rule.defaultSeverity}"`;
1951
- const expr = parseExpression(`({ "${ruleKey}": ${valueCode} })`);
1952
- const obj = expr.$ast;
1953
- return obj.properties?.[0];
1954
- }
1955
- function appendUilintConfigBlockToArray(arrayExpr, selectedRules, uilintRef) {
2109
+ for (const rule of selectedRules) {
2110
+ const importName = chooseUniqueIdentifier(
2111
+ `${rule.id.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^./, (c) => c.toUpperCase())}Rule`,
2112
+ used
2113
+ );
2114
+ importNames.set(rule.id, importName);
2115
+ used.add(importName);
2116
+ const rulePath = `${normalizedRulesPath}/${rule.id}${fileExtension}`;
2117
+ const stmtMod = parseModule(
2118
+ `const ${importName} = require("${rulePath}");`
2119
+ );
2120
+ const stmt = stmtMod.$ast.body?.[0];
2121
+ if (stmt) {
2122
+ let insertAt = 0;
2123
+ const first = program2.body?.[0];
2124
+ if (first?.type === "ExpressionStatement" && first.expression?.type === "StringLiteral" && first.expression.value === "use strict") {
2125
+ insertAt = 1;
2126
+ }
2127
+ program2.body.splice(insertAt, 0, stmt);
2128
+ changed = true;
2129
+ }
2130
+ }
2131
+ return { importNames, changed };
2132
+ }
2133
+ function appendUilintConfigBlockToArray(arrayExpr, selectedRules, ruleImportNames) {
2134
+ const pluginRulesCode = Array.from(ruleImportNames.entries()).map(([ruleId, importName]) => ` "${ruleId}": ${importName},`).join("\n");
1956
2135
  const rulesPropsCode = selectedRules.map((r) => {
1957
2136
  const ruleKey = `uilint/${r.id}`;
1958
2137
  const valueCode = r.defaultOptions && r.defaultOptions.length > 0 ? `["${r.defaultSeverity}", ...${JSON.stringify(
@@ -1968,7 +2147,13 @@ function appendUilintConfigBlockToArray(arrayExpr, selectedRules, uilintRef) {
1968
2147
  "app/**/*.{js,jsx,ts,tsx}",
1969
2148
  "pages/**/*.{js,jsx,ts,tsx}",
1970
2149
  ],
1971
- plugins: { uilint: ${uilintRef} },
2150
+ plugins: {
2151
+ uilint: {
2152
+ rules: {
2153
+ ${pluginRulesCode}
2154
+ },
2155
+ },
2156
+ },
1972
2157
  rules: {
1973
2158
  ${rulesPropsCode}
1974
2159
  },
@@ -1985,14 +2170,13 @@ function getUilintEslintConfigInfoFromSourceAst(source) {
1985
2170
  error: "Could not locate an exported ESLint flat config array (expected `export default [...]`, `export default defineConfig([...])`, `module.exports = [...]`, or `module.exports = defineConfig([...])`)."
1986
2171
  };
1987
2172
  }
1988
- const usesUilintConfigs = findUsesUilintConfigs(found.program);
1989
2173
  const configuredRuleIds = collectConfiguredUilintRuleIdsFromConfigArray(
1990
2174
  found.arrayExpr
1991
2175
  );
1992
2176
  const existingUilint = findExistingUilintRulesObject(found.arrayExpr);
1993
- const configured = usesUilintConfigs || configuredRuleIds.size > 0 || existingUilint.configObj !== null || hasUilintImport(source);
2177
+ const configured = configuredRuleIds.size > 0 || existingUilint.configObj !== null;
1994
2178
  return {
1995
- info: { usesUilintConfigs, configuredRuleIds, configured },
2179
+ info: { configuredRuleIds, configured },
1996
2180
  mod,
1997
2181
  arrayExpr: found.arrayExpr,
1998
2182
  kind: found.kind
@@ -2007,11 +2191,9 @@ function getUilintEslintConfigInfoFromSource(source) {
2007
2191
  const ast = getUilintEslintConfigInfoFromSourceAst(source);
2008
2192
  if ("error" in ast) {
2009
2193
  const configuredRuleIds = extractConfiguredUilintRuleIds(source);
2010
- const usesUilintConfigs = hasUilintConfigsUsage(source);
2011
2194
  return {
2012
- usesUilintConfigs,
2013
2195
  configuredRuleIds,
2014
- configured: usesUilintConfigs || configuredRuleIds.size > 0 || hasUilintImport(source)
2196
+ configured: configuredRuleIds.size > 0
2015
2197
  };
2016
2198
  }
2017
2199
  return ast.info;
@@ -2041,7 +2223,7 @@ async function installEslintPlugin(opts) {
2041
2223
  };
2042
2224
  }
2043
2225
  const configFilename = getEslintConfigFilename(configPath);
2044
- const original = readFileSync3(configPath, "utf-8");
2226
+ const original = readFileSync4(configPath, "utf-8");
2045
2227
  const isCommonJS = configPath.endsWith(".cjs");
2046
2228
  const ast = getUilintEslintConfigInfoFromSourceAst(original);
2047
2229
  if ("error" in ast) {
@@ -2054,10 +2236,15 @@ async function installEslintPlugin(opts) {
2054
2236
  };
2055
2237
  }
2056
2238
  const { info, mod, arrayExpr, kind } = ast;
2057
- const usesUilintConfigs = info.usesUilintConfigs;
2058
2239
  const configuredIds = info.configuredRuleIds;
2059
- const missingRules = usesUilintConfigs ? [] : getMissingSelectedRules(opts.selectedRules, configuredIds);
2060
- const rulesToUpdate = usesUilintConfigs ? [] : getRulesNeedingUpdate(opts.selectedRules, configuredIds);
2240
+ const missingRules = getMissingSelectedRules(
2241
+ opts.selectedRules,
2242
+ configuredIds
2243
+ );
2244
+ const rulesToUpdate = getRulesNeedingUpdate(
2245
+ opts.selectedRules,
2246
+ configuredIds
2247
+ );
2061
2248
  let rulesToApply = [];
2062
2249
  if (!info.configured) {
2063
2250
  rulesToApply = opts.selectedRules;
@@ -2078,47 +2265,79 @@ async function installEslintPlugin(opts) {
2078
2265
  }
2079
2266
  }
2080
2267
  }
2268
+ if (rulesToApply.length === 0) {
2269
+ return {
2270
+ configFile: configFilename,
2271
+ modified: false,
2272
+ missingRuleIds: missingRules.map((r) => r.id),
2273
+ configured: info.configured
2274
+ };
2275
+ }
2081
2276
  let modifiedAst = false;
2082
- if (!usesUilintConfigs && rulesToApply.length > 0) {
2083
- const existing = findExistingUilintRulesObject(arrayExpr);
2084
- if (existing.safeToMutate && existing.rulesObj) {
2085
- let changedRules = false;
2086
- for (const rule of rulesToApply) {
2087
- const fullKey = `uilint/${rule.id}`;
2088
- const props = existing.rulesObj.properties ?? [];
2089
- const existingProp = props.find((p2) => {
2090
- if (!p2) return false;
2091
- if (p2.type !== "ObjectProperty" && p2.type !== "Property")
2092
- return false;
2093
- return isStringLiteral(p2.key) && p2.key.value === fullKey;
2094
- });
2095
- const newProp = buildUilintRuleProperty(rule);
2096
- if (!newProp) continue;
2097
- if (existingProp) {
2098
- if (!astEquivalent(existingProp.value, newProp.value)) {
2099
- existingProp.value = newProp.value;
2100
- changedRules = true;
2101
- }
2102
- } else {
2103
- props.push(newProp);
2104
- changedRules = true;
2105
- }
2106
- }
2107
- if (changedRules) modifiedAst = true;
2108
- } else {
2109
- const uilintRef = kind === "esm" ? ensureUilintImportAst(mod).local : ensureUilintRequireAst(mod.$ast).local;
2110
- appendUilintConfigBlockToArray(arrayExpr, rulesToApply, uilintRef);
2111
- modifiedAst = true;
2277
+ const localRulesDir = join7(opts.projectPath, ".uilint", "rules");
2278
+ const workspaceRoot = findWorkspaceRoot4(opts.projectPath);
2279
+ const workspaceRulesDir = join7(workspaceRoot, ".uilint", "rules");
2280
+ const rulesRoot = existsSync8(localRulesDir) ? opts.projectPath : workspaceRoot;
2281
+ let fileExtension = ".js";
2282
+ if (rulesToApply.length > 0) {
2283
+ const firstRulePath = join7(
2284
+ rulesRoot,
2285
+ ".uilint",
2286
+ "rules",
2287
+ `${rulesToApply[0].id}.ts`
2288
+ );
2289
+ if (existsSync8(firstRulePath)) {
2290
+ fileExtension = ".ts";
2112
2291
  }
2113
- } else if (!info.configured && !usesUilintConfigs) {
2114
2292
  }
2115
- if (modifiedAst || info.configured) {
2293
+ let ruleImportNames;
2294
+ if (kind === "esm") {
2295
+ const result = addLocalRuleImportsAst(
2296
+ mod,
2297
+ rulesToApply,
2298
+ configPath,
2299
+ rulesRoot,
2300
+ fileExtension
2301
+ );
2302
+ ruleImportNames = result.importNames;
2303
+ if (result.changed) modifiedAst = true;
2304
+ } else {
2305
+ const result = addLocalRuleRequiresAst(
2306
+ mod.$ast,
2307
+ rulesToApply,
2308
+ configPath,
2309
+ rulesRoot,
2310
+ fileExtension
2311
+ );
2312
+ ruleImportNames = result.importNames;
2313
+ if (result.changed) modifiedAst = true;
2314
+ }
2315
+ if (ruleImportNames && ruleImportNames.size > 0) {
2316
+ appendUilintConfigBlockToArray(arrayExpr, rulesToApply, ruleImportNames);
2317
+ modifiedAst = true;
2318
+ }
2319
+ if (!info.configured) {
2116
2320
  if (kind === "esm") {
2117
- const { changed } = ensureUilintImportAst(mod);
2118
- if (changed) modifiedAst = true;
2119
- } else if (kind === "cjs") {
2120
- const { changed } = ensureUilintRequireAst(mod.$ast);
2121
- if (changed) modifiedAst = true;
2321
+ mod.imports.$add({
2322
+ imported: "createRule",
2323
+ local: "createRule",
2324
+ from: "uilint-eslint"
2325
+ });
2326
+ modifiedAst = true;
2327
+ } else {
2328
+ const stmtMod = parseModule(
2329
+ `const { createRule } = require("uilint-eslint");`
2330
+ );
2331
+ const stmt = stmtMod.$ast.body?.[0];
2332
+ if (stmt) {
2333
+ let insertAt = 0;
2334
+ const first = mod.$ast.body?.[0];
2335
+ if (first?.type === "ExpressionStatement" && first.expression?.type === "StringLiteral" && first.expression.value === "use strict") {
2336
+ insertAt = 1;
2337
+ }
2338
+ mod.$ast.body.splice(insertAt, 0, stmt);
2339
+ modifiedAst = true;
2340
+ }
2122
2341
  }
2123
2342
  }
2124
2343
  const updated = modifiedAst ? generateCode(mod).code : original;
@@ -2143,36 +2362,36 @@ async function installEslintPlugin(opts) {
2143
2362
  var LEGACY_HOOK_FILES = ["uilint-validate.sh", "uilint-validate.js"];
2144
2363
  function safeParseJson(filePath) {
2145
2364
  try {
2146
- const content = readFileSync4(filePath, "utf-8");
2365
+ const content = readFileSync5(filePath, "utf-8");
2147
2366
  return JSON.parse(content);
2148
2367
  } catch {
2149
2368
  return void 0;
2150
2369
  }
2151
2370
  }
2152
2371
  async function analyze2(projectPath = process.cwd()) {
2153
- const workspaceRoot = findWorkspaceRoot4(projectPath);
2372
+ const workspaceRoot = findWorkspaceRoot5(projectPath);
2154
2373
  const packageManager = detectPackageManager(projectPath);
2155
- const cursorDir = join7(projectPath, ".cursor");
2156
- const cursorDirExists = existsSync8(cursorDir);
2157
- const mcpPath = join7(cursorDir, "mcp.json");
2158
- const mcpExists = existsSync8(mcpPath);
2374
+ const cursorDir = join8(projectPath, ".cursor");
2375
+ const cursorDirExists = existsSync9(cursorDir);
2376
+ const mcpPath = join8(cursorDir, "mcp.json");
2377
+ const mcpExists = existsSync9(mcpPath);
2159
2378
  const mcpConfig = mcpExists ? safeParseJson(mcpPath) : void 0;
2160
- const hooksPath = join7(cursorDir, "hooks.json");
2161
- const hooksExists = existsSync8(hooksPath);
2379
+ const hooksPath = join8(cursorDir, "hooks.json");
2380
+ const hooksExists = existsSync9(hooksPath);
2162
2381
  const hooksConfig = hooksExists ? safeParseJson(hooksPath) : void 0;
2163
- const hooksDir = join7(cursorDir, "hooks");
2382
+ const hooksDir = join8(cursorDir, "hooks");
2164
2383
  const legacyPaths = [];
2165
2384
  for (const legacyFile of LEGACY_HOOK_FILES) {
2166
- const legacyPath = join7(hooksDir, legacyFile);
2167
- if (existsSync8(legacyPath)) {
2385
+ const legacyPath = join8(hooksDir, legacyFile);
2386
+ if (existsSync9(legacyPath)) {
2168
2387
  legacyPaths.push(legacyPath);
2169
2388
  }
2170
2389
  }
2171
- const styleguidePath = join7(projectPath, ".uilint", "styleguide.md");
2172
- const styleguideExists = existsSync8(styleguidePath);
2173
- const commandsDir = join7(cursorDir, "commands");
2174
- const genstyleguideExists = existsSync8(join7(commandsDir, "genstyleguide.md"));
2175
- const genrulesExists = existsSync8(join7(commandsDir, "genrules.md"));
2390
+ const styleguidePath = join8(projectPath, ".uilint", "styleguide.md");
2391
+ const styleguideExists = existsSync9(styleguidePath);
2392
+ const commandsDir = join8(cursorDir, "commands");
2393
+ const genstyleguideExists = existsSync9(join8(commandsDir, "genstyleguide.md"));
2394
+ const genrulesExists = existsSync9(join8(commandsDir, "genrules.md"));
2176
2395
  const nextApps = [];
2177
2396
  const directDetection = detectNextAppRouter(projectPath);
2178
2397
  if (directDetection) {
@@ -2186,6 +2405,19 @@ async function analyze2(projectPath = process.cwd()) {
2186
2405
  });
2187
2406
  }
2188
2407
  }
2408
+ const viteApps = [];
2409
+ const directVite = detectViteReact(projectPath);
2410
+ if (directVite) {
2411
+ viteApps.push({ projectPath, detection: directVite });
2412
+ } else {
2413
+ const matches = findViteReactProjects(workspaceRoot, { maxDepth: 5 });
2414
+ for (const match of matches) {
2415
+ viteApps.push({
2416
+ projectPath: match.projectPath,
2417
+ detection: match.detection
2418
+ });
2419
+ }
2420
+ }
2189
2421
  const rawPackages = findPackages(workspaceRoot);
2190
2422
  const packages = rawPackages.map((pkg) => {
2191
2423
  const eslintConfigPath = findEslintConfigFile(pkg.path);
@@ -2195,9 +2427,9 @@ async function analyze2(projectPath = process.cwd()) {
2195
2427
  if (eslintConfigPath) {
2196
2428
  eslintConfigFilename = getEslintConfigFilename(eslintConfigPath);
2197
2429
  try {
2198
- const source = readFileSync4(eslintConfigPath, "utf-8");
2430
+ const source = readFileSync5(eslintConfigPath, "utf-8");
2199
2431
  const info = getUilintEslintConfigInfoFromSource(source);
2200
- hasRules = info.configuredRuleIds.size > 0 || info.usesUilintConfigs;
2432
+ hasRules = info.configuredRuleIds.size > 0;
2201
2433
  configuredRuleIds = Array.from(info.configuredRuleIds);
2202
2434
  } catch {
2203
2435
  }
@@ -2239,13 +2471,14 @@ async function analyze2(projectPath = process.cwd()) {
2239
2471
  genrules: genrulesExists
2240
2472
  },
2241
2473
  nextApps,
2474
+ viteApps,
2242
2475
  packages
2243
2476
  };
2244
2477
  }
2245
2478
 
2246
2479
  // src/commands/install/plan.ts
2247
- import { join as join8 } from "path";
2248
- import { createRequire } from "module";
2480
+ import { join as join11 } from "path";
2481
+ import { createRequire as createRequire2 } from "module";
2249
2482
 
2250
2483
  // src/commands/install/constants.ts
2251
2484
  var HOOKS_CONFIG = {
@@ -2633,11 +2866,179 @@ Generate in \`.uilint/rules/\`:
2633
2866
  - **Minimal rules** - generate 3-5 high-impact rules, not dozens
2634
2867
  `;
2635
2868
 
2636
- // src/commands/install/plan.ts
2869
+ // src/utils/skill-loader.ts
2870
+ import { readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync2, existsSync as existsSync10 } from "fs";
2871
+ import { join as join9, dirname as dirname7, relative as relative3 } from "path";
2872
+ import { fileURLToPath as fileURLToPath2 } from "url";
2873
+ var __filename = fileURLToPath2(import.meta.url);
2874
+ var __dirname = dirname7(__filename);
2875
+ function getSkillsDir() {
2876
+ const devPath = join9(__dirname, "..", "..", "skills");
2877
+ const prodPath = join9(__dirname, "..", "skills");
2878
+ if (existsSync10(devPath)) {
2879
+ return devPath;
2880
+ }
2881
+ if (existsSync10(prodPath)) {
2882
+ return prodPath;
2883
+ }
2884
+ throw new Error(
2885
+ "Could not find skills directory. This is a bug in uilint installation."
2886
+ );
2887
+ }
2888
+ function collectFiles(dir, baseDir) {
2889
+ const files = [];
2890
+ const entries = readdirSync4(dir);
2891
+ for (const entry of entries) {
2892
+ const fullPath = join9(dir, entry);
2893
+ const stat = statSync2(fullPath);
2894
+ if (stat.isDirectory()) {
2895
+ files.push(...collectFiles(fullPath, baseDir));
2896
+ } else if (stat.isFile()) {
2897
+ const relativePath = relative3(baseDir, fullPath);
2898
+ const content = readFileSync6(fullPath, "utf-8");
2899
+ files.push({ relativePath, content });
2900
+ }
2901
+ }
2902
+ return files;
2903
+ }
2904
+ function loadSkill(name) {
2905
+ const skillsDir = getSkillsDir();
2906
+ const skillDir = join9(skillsDir, name);
2907
+ if (!existsSync10(skillDir)) {
2908
+ throw new Error(`Skill "${name}" not found in ${skillsDir}`);
2909
+ }
2910
+ const skillMdPath = join9(skillDir, "SKILL.md");
2911
+ if (!existsSync10(skillMdPath)) {
2912
+ throw new Error(`Skill "${name}" is missing SKILL.md`);
2913
+ }
2914
+ const files = collectFiles(skillDir, skillDir);
2915
+ return { name, files };
2916
+ }
2917
+
2918
+ // src/utils/rule-loader.ts
2919
+ import { readFileSync as readFileSync7, existsSync as existsSync11 } from "fs";
2920
+ import { join as join10, dirname as dirname8 } from "path";
2921
+ import { fileURLToPath as fileURLToPath3 } from "url";
2922
+ import { createRequire } from "module";
2923
+ var __filename2 = fileURLToPath3(import.meta.url);
2924
+ var __dirname2 = dirname8(__filename2);
2637
2925
  var require2 = createRequire(import.meta.url);
2926
+ function getUilintEslintPackageRoot() {
2927
+ const entry = require2.resolve("uilint-eslint");
2928
+ const entryDir = dirname8(entry);
2929
+ return dirname8(entryDir);
2930
+ }
2931
+ function getUilintEslintSrcDir() {
2932
+ const devPath = join10(
2933
+ __dirname2,
2934
+ "..",
2935
+ "..",
2936
+ "..",
2937
+ "..",
2938
+ "uilint-eslint",
2939
+ "src"
2940
+ );
2941
+ if (existsSync11(devPath)) return devPath;
2942
+ const pkgRoot = getUilintEslintPackageRoot();
2943
+ const srcPath = join10(pkgRoot, "src");
2944
+ if (existsSync11(srcPath)) return srcPath;
2945
+ throw new Error(
2946
+ 'Could not find uilint-eslint "src/" directory. If you are using a published install of uilint-eslint, ensure it includes source files, or run a JS-only rules install.'
2947
+ );
2948
+ }
2949
+ function getUilintEslintDistDir() {
2950
+ const devPath = join10(
2951
+ __dirname2,
2952
+ "..",
2953
+ "..",
2954
+ "..",
2955
+ "..",
2956
+ "uilint-eslint",
2957
+ "dist"
2958
+ );
2959
+ if (existsSync11(devPath)) return devPath;
2960
+ const pkgRoot = getUilintEslintPackageRoot();
2961
+ const distPath = join10(pkgRoot, "dist");
2962
+ if (existsSync11(distPath)) return distPath;
2963
+ throw new Error(
2964
+ 'Could not find uilint-eslint "dist/" directory. This is a bug in uilint installation.'
2965
+ );
2966
+ }
2967
+ function transformRuleContent(content) {
2968
+ let transformed = content;
2969
+ transformed = transformed.replace(
2970
+ /import\s+{\s*createRule\s*}\s+from\s+["']\.\.\/utils\/create-rule\.js["'];?/g,
2971
+ 'import { createRule } from "uilint-eslint";'
2972
+ );
2973
+ transformed = transformed.replace(
2974
+ /import\s+createRule\s+from\s+["']\.\.\/utils\/create-rule\.js["'];?/g,
2975
+ 'import { createRule } from "uilint-eslint";'
2976
+ );
2977
+ transformed = transformed.replace(
2978
+ /import\s+{([^}]+)}\s+from\s+["']\.\.\/utils\/([^"']+)\.js["'];?/g,
2979
+ (match, imports, utilFile) => {
2980
+ const utilsFromPackage = ["cache", "styleguide-loader", "import-graph"];
2981
+ if (utilsFromPackage.includes(utilFile)) {
2982
+ return `import {${imports}} from "uilint-eslint";`;
2983
+ }
2984
+ return match;
2985
+ }
2986
+ );
2987
+ return transformed;
2988
+ }
2989
+ function loadRule(ruleId, options = { typescript: true }) {
2990
+ const { typescript } = options;
2991
+ const extension = typescript ? ".ts" : ".js";
2992
+ if (typescript) {
2993
+ const rulesDir = join10(getUilintEslintSrcDir(), "rules");
2994
+ const implPath = join10(rulesDir, `${ruleId}.ts`);
2995
+ const testPath = join10(rulesDir, `${ruleId}.test.ts`);
2996
+ if (!existsSync11(implPath)) {
2997
+ throw new Error(`Rule "${ruleId}" not found at ${implPath}`);
2998
+ }
2999
+ const rawContent = readFileSync7(implPath, "utf-8");
3000
+ const transformedContent = transformRuleContent(rawContent);
3001
+ const implementation = {
3002
+ relativePath: `${ruleId}.ts`,
3003
+ content: transformedContent
3004
+ };
3005
+ const test = existsSync11(testPath) ? {
3006
+ relativePath: `${ruleId}.test.ts`,
3007
+ content: transformRuleContent(readFileSync7(testPath, "utf-8"))
3008
+ } : void 0;
3009
+ return {
3010
+ ruleId,
3011
+ implementation,
3012
+ test
3013
+ };
3014
+ } else {
3015
+ const rulesDir = join10(getUilintEslintDistDir(), "rules");
3016
+ const implPath = join10(rulesDir, `${ruleId}.js`);
3017
+ if (!existsSync11(implPath)) {
3018
+ throw new Error(
3019
+ `Rule "${ruleId}" not found at ${implPath}. Make sure uilint-eslint has been built.`
3020
+ );
3021
+ }
3022
+ const content = readFileSync7(implPath, "utf-8");
3023
+ const implementation = {
3024
+ relativePath: `${ruleId}.js`,
3025
+ content
3026
+ };
3027
+ return {
3028
+ ruleId,
3029
+ implementation
3030
+ };
3031
+ }
3032
+ }
3033
+ function loadSelectedRules(ruleIds, options = { typescript: true }) {
3034
+ return ruleIds.map((id) => loadRule(id, options));
3035
+ }
3036
+
3037
+ // src/commands/install/plan.ts
3038
+ var require3 = createRequire2(import.meta.url);
2638
3039
  function getSelfDependencyVersionRange(pkgName) {
2639
3040
  try {
2640
- const pkgJson = require2("uilint/package.json");
3041
+ const pkgJson = require3("uilint/package.json");
2641
3042
  const deps = pkgJson?.dependencies;
2642
3043
  const optDeps = pkgJson?.optionalDependencies;
2643
3044
  const peerDeps = pkgJson?.peerDependencies;
@@ -2683,7 +3084,7 @@ function createPlan(state, choices, options = {}) {
2683
3084
  const dependencies = [];
2684
3085
  const { force = false } = options;
2685
3086
  const { items } = choices;
2686
- const needsCursorDir = items.includes("mcp") || items.includes("hooks") || items.includes("genstyleguide") || items.includes("genrules");
3087
+ const needsCursorDir = items.includes("mcp") || items.includes("hooks") || items.includes("genstyleguide") || items.includes("genrules") || items.includes("skill");
2687
3088
  if (needsCursorDir && !state.cursorDir.exists) {
2688
3089
  actions.push({
2689
3090
  type: "create_directory",
@@ -2712,7 +3113,7 @@ function createPlan(state, choices, options = {}) {
2712
3113
  }
2713
3114
  }
2714
3115
  if (items.includes("hooks")) {
2715
- const hooksDir = join8(state.cursorDir.path, "hooks");
3116
+ const hooksDir = join11(state.cursorDir.path, "hooks");
2716
3117
  actions.push({
2717
3118
  type: "create_directory",
2718
3119
  path: hooksDir
@@ -2738,58 +3139,92 @@ function createPlan(state, choices, options = {}) {
2738
3139
  });
2739
3140
  actions.push({
2740
3141
  type: "create_file",
2741
- path: join8(hooksDir, "uilint-session-start.sh"),
3142
+ path: join11(hooksDir, "uilint-session-start.sh"),
2742
3143
  content: SESSION_START_SCRIPT,
2743
3144
  permissions: 493
2744
3145
  });
2745
3146
  actions.push({
2746
3147
  type: "create_file",
2747
- path: join8(hooksDir, "uilint-track.sh"),
3148
+ path: join11(hooksDir, "uilint-track.sh"),
2748
3149
  content: TRACK_SCRIPT,
2749
3150
  permissions: 493
2750
3151
  });
2751
3152
  actions.push({
2752
3153
  type: "create_file",
2753
- path: join8(hooksDir, "uilint-session-end.sh"),
3154
+ path: join11(hooksDir, "uilint-session-end.sh"),
2754
3155
  content: SESSION_END_SCRIPT,
2755
3156
  permissions: 493
2756
3157
  });
2757
3158
  }
2758
3159
  if (items.includes("genstyleguide")) {
2759
- const commandsDir = join8(state.cursorDir.path, "commands");
3160
+ const commandsDir = join11(state.cursorDir.path, "commands");
2760
3161
  actions.push({
2761
3162
  type: "create_directory",
2762
3163
  path: commandsDir
2763
3164
  });
2764
3165
  actions.push({
2765
3166
  type: "create_file",
2766
- path: join8(commandsDir, "genstyleguide.md"),
3167
+ path: join11(commandsDir, "genstyleguide.md"),
2767
3168
  content: GENSTYLEGUIDE_COMMAND_MD
2768
3169
  });
2769
3170
  }
2770
3171
  if (items.includes("genrules")) {
2771
- const commandsDir = join8(state.cursorDir.path, "commands");
3172
+ const commandsDir = join11(state.cursorDir.path, "commands");
2772
3173
  actions.push({
2773
3174
  type: "create_directory",
2774
3175
  path: commandsDir
2775
3176
  });
2776
3177
  actions.push({
2777
3178
  type: "create_file",
2778
- path: join8(commandsDir, "genrules.md"),
3179
+ path: join11(commandsDir, "genrules.md"),
2779
3180
  content: GENRULES_COMMAND_MD
2780
3181
  });
2781
3182
  }
2782
- if (items.includes("next") && choices.next) {
2783
- const { projectPath, detection } = choices.next;
3183
+ if (items.includes("skill")) {
3184
+ const skillsDir = join11(state.cursorDir.path, "skills");
2784
3185
  actions.push({
2785
- type: "install_next_routes",
2786
- projectPath,
2787
- appRoot: detection.appRoot
3186
+ type: "create_directory",
3187
+ path: skillsDir
2788
3188
  });
2789
- dependencies.push({
2790
- packagePath: projectPath,
2791
- packageManager: state.packageManager,
2792
- packages: ["uilint-react", "uilint-core", "jsx-loc-plugin"]
3189
+ try {
3190
+ const skill = loadSkill("ui-consistency-enforcer");
3191
+ const skillDir = join11(skillsDir, skill.name);
3192
+ actions.push({
3193
+ type: "create_directory",
3194
+ path: skillDir
3195
+ });
3196
+ for (const file of skill.files) {
3197
+ const filePath = join11(skillDir, file.relativePath);
3198
+ const fileDir = join11(
3199
+ skillDir,
3200
+ file.relativePath.split("/").slice(0, -1).join("/")
3201
+ );
3202
+ if (fileDir !== skillDir && file.relativePath.includes("/")) {
3203
+ actions.push({
3204
+ type: "create_directory",
3205
+ path: fileDir
3206
+ });
3207
+ }
3208
+ actions.push({
3209
+ type: "create_file",
3210
+ path: filePath,
3211
+ content: file.content
3212
+ });
3213
+ }
3214
+ } catch {
3215
+ }
3216
+ }
3217
+ if (items.includes("next") && choices.next) {
3218
+ const { projectPath, detection } = choices.next;
3219
+ actions.push({
3220
+ type: "install_next_routes",
3221
+ projectPath,
3222
+ appRoot: detection.appRoot
3223
+ });
3224
+ dependencies.push({
3225
+ packagePath: projectPath,
3226
+ packageManager: state.packageManager,
3227
+ packages: ["uilint-react", "uilint-core", "jsx-loc-plugin"]
2793
3228
  });
2794
3229
  actions.push({
2795
3230
  type: "inject_react",
@@ -2801,10 +3236,57 @@ function createPlan(state, choices, options = {}) {
2801
3236
  projectPath
2802
3237
  });
2803
3238
  }
3239
+ if (items.includes("vite") && choices.vite) {
3240
+ const { projectPath, detection } = choices.vite;
3241
+ dependencies.push({
3242
+ packagePath: projectPath,
3243
+ packageManager: state.packageManager,
3244
+ packages: ["uilint-react", "uilint-core", "jsx-loc-plugin"]
3245
+ });
3246
+ actions.push({
3247
+ type: "inject_react",
3248
+ projectPath,
3249
+ appRoot: detection.entryRoot,
3250
+ mode: "vite"
3251
+ });
3252
+ actions.push({
3253
+ type: "inject_vite_config",
3254
+ projectPath
3255
+ });
3256
+ }
2804
3257
  if (items.includes("eslint") && choices.eslint) {
2805
3258
  const { packagePaths, selectedRules } = choices.eslint;
2806
3259
  for (const pkgPath of packagePaths) {
2807
3260
  const pkgInfo = state.packages.find((p2) => p2.path === pkgPath);
3261
+ const rulesDir = join11(pkgPath, ".uilint", "rules");
3262
+ actions.push({
3263
+ type: "create_directory",
3264
+ path: rulesDir
3265
+ });
3266
+ const isTypeScript = pkgInfo?.isTypeScript ?? true;
3267
+ try {
3268
+ const ruleFiles = loadSelectedRules(
3269
+ selectedRules.map((r) => r.id),
3270
+ {
3271
+ typescript: isTypeScript
3272
+ }
3273
+ );
3274
+ for (const ruleFile of ruleFiles) {
3275
+ actions.push({
3276
+ type: "create_file",
3277
+ path: join11(rulesDir, ruleFile.implementation.relativePath),
3278
+ content: ruleFile.implementation.content
3279
+ });
3280
+ if (ruleFile.test && isTypeScript) {
3281
+ actions.push({
3282
+ type: "create_file",
3283
+ path: join11(rulesDir, ruleFile.test.relativePath),
3284
+ content: ruleFile.test.content
3285
+ });
3286
+ }
3287
+ }
3288
+ } catch {
3289
+ }
2808
3290
  dependencies.push({
2809
3291
  packagePath: pkgPath,
2810
3292
  packageManager: state.packageManager,
@@ -2820,7 +3302,7 @@ function createPlan(state, choices, options = {}) {
2820
3302
  });
2821
3303
  }
2822
3304
  }
2823
- const gitignorePath = join8(state.workspaceRoot, ".gitignore");
3305
+ const gitignorePath = join11(state.workspaceRoot, ".gitignore");
2824
3306
  actions.push({
2825
3307
  type: "append_to_file",
2826
3308
  path: gitignorePath,
@@ -2833,34 +3315,49 @@ function createPlan(state, choices, options = {}) {
2833
3315
 
2834
3316
  // src/commands/install/execute.ts
2835
3317
  import {
2836
- existsSync as existsSync12,
3318
+ existsSync as existsSync16,
2837
3319
  mkdirSync as mkdirSync3,
2838
- writeFileSync as writeFileSync6,
2839
- readFileSync as readFileSync7,
3320
+ writeFileSync as writeFileSync7,
3321
+ readFileSync as readFileSync11,
2840
3322
  unlinkSync,
2841
3323
  chmodSync
2842
3324
  } from "fs";
2843
- import { dirname as dirname6 } from "path";
3325
+ import { dirname as dirname9 } from "path";
2844
3326
 
2845
3327
  // src/utils/react-inject.ts
2846
- import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2847
- import { join as join9 } from "path";
3328
+ import { existsSync as existsSync12, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
3329
+ import { join as join12 } from "path";
2848
3330
  import { parseModule as parseModule2, generateCode as generateCode2 } from "magicast";
2849
3331
  function getDefaultCandidates(projectPath, appRoot) {
3332
+ const viteMainCandidates = [
3333
+ join12(appRoot, "main.tsx"),
3334
+ join12(appRoot, "main.jsx"),
3335
+ join12(appRoot, "main.ts"),
3336
+ join12(appRoot, "main.js")
3337
+ ];
3338
+ const existingViteMain = viteMainCandidates.filter(
3339
+ (rel) => existsSync12(join12(projectPath, rel))
3340
+ );
3341
+ if (existingViteMain.length > 0) return existingViteMain;
3342
+ const viteAppCandidates = [join12(appRoot, "App.tsx"), join12(appRoot, "App.jsx")];
3343
+ const existingViteApp = viteAppCandidates.filter(
3344
+ (rel) => existsSync12(join12(projectPath, rel))
3345
+ );
3346
+ if (existingViteApp.length > 0) return existingViteApp;
2850
3347
  const layoutCandidates = [
2851
- join9(appRoot, "layout.tsx"),
2852
- join9(appRoot, "layout.jsx"),
2853
- join9(appRoot, "layout.ts"),
2854
- join9(appRoot, "layout.js")
3348
+ join12(appRoot, "layout.tsx"),
3349
+ join12(appRoot, "layout.jsx"),
3350
+ join12(appRoot, "layout.ts"),
3351
+ join12(appRoot, "layout.js")
2855
3352
  ];
2856
3353
  const existingLayouts = layoutCandidates.filter(
2857
- (rel) => existsSync9(join9(projectPath, rel))
3354
+ (rel) => existsSync12(join12(projectPath, rel))
2858
3355
  );
2859
3356
  if (existingLayouts.length > 0) {
2860
3357
  return existingLayouts;
2861
3358
  }
2862
- const pageCandidates = [join9(appRoot, "page.tsx"), join9(appRoot, "page.jsx")];
2863
- return pageCandidates.filter((rel) => existsSync9(join9(projectPath, rel)));
3359
+ const pageCandidates = [join12(appRoot, "page.tsx"), join12(appRoot, "page.jsx")];
3360
+ return pageCandidates.filter((rel) => existsSync12(join12(projectPath, rel)));
2864
3361
  }
2865
3362
  function isUseClientDirective(stmt) {
2866
3363
  return stmt?.type === "ExpressionStatement" && stmt.expression?.type === "StringLiteral" && stmt.expression.value === "use client";
@@ -2873,16 +3370,16 @@ function findImportDeclaration(program2, from) {
2873
3370
  }
2874
3371
  return null;
2875
3372
  }
2876
- function walkAst2(node, visit) {
3373
+ function walkAst(node, visit) {
2877
3374
  if (!node || typeof node !== "object") return;
2878
3375
  if (node.type) visit(node);
2879
3376
  for (const key of Object.keys(node)) {
2880
3377
  const v = node[key];
2881
3378
  if (!v) continue;
2882
3379
  if (Array.isArray(v)) {
2883
- for (const item of v) walkAst2(item, visit);
3380
+ for (const item of v) walkAst(item, visit);
2884
3381
  } else if (typeof v === "object" && v.type) {
2885
- walkAst2(v, visit);
3382
+ walkAst(v, visit);
2886
3383
  }
2887
3384
  }
2888
3385
  }
@@ -2914,7 +3411,7 @@ function ensureNamedImport(program2, from, name) {
2914
3411
  }
2915
3412
  function hasUILintProviderJsx(program2) {
2916
3413
  let found = false;
2917
- walkAst2(program2, (node) => {
3414
+ walkAst(program2, (node) => {
2918
3415
  if (found) return;
2919
3416
  if (node.type !== "JSXElement") return;
2920
3417
  const name = node.openingElement?.name;
@@ -2934,7 +3431,7 @@ function wrapFirstChildrenExpressionWithProvider(program2) {
2934
3431
  if (!providerJsx || providerJsx.type !== "JSXElement")
2935
3432
  return { changed: false };
2936
3433
  let replaced = false;
2937
- walkAst2(program2, (node) => {
3434
+ walkAst(program2, (node) => {
2938
3435
  if (replaced) return;
2939
3436
  if (node.type === "JSXExpressionContainer" && node.expression?.type === "Identifier" && node.expression.name === "children") {
2940
3437
  Object.keys(node).forEach((k) => delete node[k]);
@@ -2947,11 +3444,44 @@ function wrapFirstChildrenExpressionWithProvider(program2) {
2947
3444
  }
2948
3445
  return { changed: true };
2949
3446
  }
3447
+ function wrapFirstRenderCallArgumentWithProvider(program2) {
3448
+ if (!program2 || program2.type !== "Program") return { changed: false };
3449
+ if (hasUILintProviderJsx(program2)) return { changed: false };
3450
+ const providerMod = parseModule2(
3451
+ 'const __uilint_provider = (<UILintProvider enabled={process.env.NODE_ENV !== "production"}></UILintProvider>);'
3452
+ );
3453
+ const providerJsx = providerMod.$ast.body?.[0]?.declarations?.[0]?.init ?? null;
3454
+ if (!providerJsx || providerJsx.type !== "JSXElement")
3455
+ return { changed: false };
3456
+ providerJsx.children = providerJsx.children ?? [];
3457
+ let wrapped = false;
3458
+ walkAst(program2, (node) => {
3459
+ if (wrapped) return;
3460
+ if (node.type !== "CallExpression") return;
3461
+ const callee = node.callee;
3462
+ if (callee?.type !== "MemberExpression") return;
3463
+ const prop = callee.property;
3464
+ const isRender = prop?.type === "Identifier" && prop.name === "render" || prop?.type === "StringLiteral" && prop.value === "render" || prop?.type === "Literal" && prop.value === "render";
3465
+ if (!isRender) return;
3466
+ const arg0 = node.arguments?.[0];
3467
+ if (!arg0) return;
3468
+ if (arg0.type !== "JSXElement" && arg0.type !== "JSXFragment") return;
3469
+ providerJsx.children = [arg0];
3470
+ node.arguments[0] = providerJsx;
3471
+ wrapped = true;
3472
+ });
3473
+ if (!wrapped) {
3474
+ throw new Error(
3475
+ "Could not find a `.render(<...>)` call to wrap. Expected a React entry like `createRoot(...).render(<App />)`."
3476
+ );
3477
+ }
3478
+ return { changed: true };
3479
+ }
2950
3480
  async function installReactUILintOverlay(opts) {
2951
3481
  const candidates = getDefaultCandidates(opts.projectPath, opts.appRoot);
2952
3482
  if (!candidates.length) {
2953
3483
  throw new Error(
2954
- `No suitable Next.js entry files found under ${opts.appRoot} (expected layout.* or page.*).`
3484
+ `No suitable entry files found under ${opts.appRoot} (expected Next.js layout/page or Vite main/App).`
2955
3485
  );
2956
3486
  }
2957
3487
  let chosen;
@@ -2960,8 +3490,8 @@ async function installReactUILintOverlay(opts) {
2960
3490
  } else {
2961
3491
  chosen = candidates[0];
2962
3492
  }
2963
- const absTarget = join9(opts.projectPath, chosen);
2964
- const original = readFileSync5(absTarget, "utf-8");
3493
+ const absTarget = join12(opts.projectPath, chosen);
3494
+ const original = readFileSync8(absTarget, "utf-8");
2965
3495
  let mod;
2966
3496
  try {
2967
3497
  mod = parseModule2(original);
@@ -2979,7 +3509,8 @@ async function installReactUILintOverlay(opts) {
2979
3509
  "UILintProvider"
2980
3510
  );
2981
3511
  if (importRes.changed) changed = true;
2982
- const wrapRes = wrapFirstChildrenExpressionWithProvider(program2);
3512
+ const mode = opts.mode ?? "next";
3513
+ const wrapRes = mode === "vite" ? wrapFirstRenderCallArgumentWithProvider(program2) : wrapFirstChildrenExpressionWithProvider(program2);
2983
3514
  if (wrapRes.changed) changed = true;
2984
3515
  const updated = changed ? generateCode2(mod).code : original;
2985
3516
  const modified = updated !== original;
@@ -2994,14 +3525,14 @@ async function installReactUILintOverlay(opts) {
2994
3525
  }
2995
3526
 
2996
3527
  // src/utils/next-config-inject.ts
2997
- import { existsSync as existsSync10, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2998
- import { join as join10 } from "path";
3528
+ import { existsSync as existsSync13, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "fs";
3529
+ import { join as join13 } from "path";
2999
3530
  import { parseModule as parseModule3, generateCode as generateCode3 } from "magicast";
3000
3531
  var CONFIG_EXTENSIONS2 = [".ts", ".mjs", ".js", ".cjs"];
3001
3532
  function findNextConfigFile(projectPath) {
3002
3533
  for (const ext of CONFIG_EXTENSIONS2) {
3003
- const configPath = join10(projectPath, `next.config${ext}`);
3004
- if (existsSync10(configPath)) {
3534
+ const configPath = join13(projectPath, `next.config${ext}`);
3535
+ if (existsSync13(configPath)) {
3005
3536
  return configPath;
3006
3537
  }
3007
3538
  }
@@ -3114,7 +3645,7 @@ async function installJsxLocPlugin(opts) {
3114
3645
  return { configFile: null, modified: false };
3115
3646
  }
3116
3647
  const configFilename = getNextConfigFilename(configPath);
3117
- const original = readFileSync6(configPath, "utf-8");
3648
+ const original = readFileSync9(configPath, "utf-8");
3118
3649
  let mod;
3119
3650
  try {
3120
3651
  mod = parseModule3(original);
@@ -3143,10 +3674,221 @@ async function installJsxLocPlugin(opts) {
3143
3674
  return { configFile: configFilename, modified: false };
3144
3675
  }
3145
3676
 
3677
+ // src/utils/vite-config-inject.ts
3678
+ import { existsSync as existsSync14, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
3679
+ import { join as join14 } from "path";
3680
+ import { parseModule as parseModule4, generateCode as generateCode4 } from "magicast";
3681
+ var CONFIG_EXTENSIONS3 = [".ts", ".mjs", ".js", ".cjs"];
3682
+ function findViteConfigFile2(projectPath) {
3683
+ for (const ext of CONFIG_EXTENSIONS3) {
3684
+ const configPath = join14(projectPath, `vite.config${ext}`);
3685
+ if (existsSync14(configPath)) return configPath;
3686
+ }
3687
+ return null;
3688
+ }
3689
+ function getViteConfigFilename(configPath) {
3690
+ const parts = configPath.split("/");
3691
+ return parts[parts.length - 1] || "vite.config.ts";
3692
+ }
3693
+ function isIdentifier3(node, name) {
3694
+ return !!node && node.type === "Identifier" && (name ? node.name === name : typeof node.name === "string");
3695
+ }
3696
+ function isStringLiteral3(node) {
3697
+ return !!node && (node.type === "StringLiteral" || node.type === "Literal") && typeof node.value === "string";
3698
+ }
3699
+ function unwrapExpression(expr) {
3700
+ let e = expr;
3701
+ while (e) {
3702
+ if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
3703
+ e = e.expression;
3704
+ continue;
3705
+ }
3706
+ if (e.type === "TSSatisfiesExpression") {
3707
+ e = e.expression;
3708
+ continue;
3709
+ }
3710
+ if (e.type === "ParenthesizedExpression") {
3711
+ e = e.expression;
3712
+ continue;
3713
+ }
3714
+ break;
3715
+ }
3716
+ return e;
3717
+ }
3718
+ function findExportedConfigObjectExpression(mod) {
3719
+ const program2 = mod?.$ast;
3720
+ if (!program2 || program2.type !== "Program") return null;
3721
+ for (const stmt of program2.body ?? []) {
3722
+ if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
3723
+ const decl = unwrapExpression(stmt.declaration);
3724
+ if (!decl) break;
3725
+ if (decl.type === "ObjectExpression") {
3726
+ return { kind: "esm", objExpr: decl, program: program2 };
3727
+ }
3728
+ if (decl.type === "CallExpression" && isIdentifier3(decl.callee, "defineConfig") && unwrapExpression(decl.arguments?.[0])?.type === "ObjectExpression") {
3729
+ return {
3730
+ kind: "esm",
3731
+ objExpr: unwrapExpression(decl.arguments?.[0]),
3732
+ program: program2
3733
+ };
3734
+ }
3735
+ break;
3736
+ }
3737
+ for (const stmt of program2.body ?? []) {
3738
+ if (!stmt || stmt.type !== "ExpressionStatement") continue;
3739
+ const expr = stmt.expression;
3740
+ if (!expr || expr.type !== "AssignmentExpression") continue;
3741
+ const left = expr.left;
3742
+ const right = unwrapExpression(expr.right);
3743
+ const isModuleExports = left?.type === "MemberExpression" && isIdentifier3(left.object, "module") && isIdentifier3(left.property, "exports");
3744
+ if (!isModuleExports) continue;
3745
+ if (right?.type === "ObjectExpression") {
3746
+ return { kind: "cjs", objExpr: right, program: program2 };
3747
+ }
3748
+ if (right?.type === "CallExpression" && isIdentifier3(right.callee, "defineConfig") && unwrapExpression(right.arguments?.[0])?.type === "ObjectExpression") {
3749
+ return {
3750
+ kind: "cjs",
3751
+ objExpr: unwrapExpression(right.arguments?.[0]),
3752
+ program: program2
3753
+ };
3754
+ }
3755
+ }
3756
+ return null;
3757
+ }
3758
+ function getObjectProperty(obj, keyName) {
3759
+ if (!obj || obj.type !== "ObjectExpression") return null;
3760
+ for (const prop of obj.properties ?? []) {
3761
+ if (!prop) continue;
3762
+ if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
3763
+ const key = prop.key;
3764
+ const keyMatch = key?.type === "Identifier" && key.name === keyName || isStringLiteral3(key) && key.value === keyName;
3765
+ if (keyMatch) return prop;
3766
+ }
3767
+ return null;
3768
+ }
3769
+ function ensureEsmJsxLocImport(program2) {
3770
+ if (!program2 || program2.type !== "Program") return { changed: false };
3771
+ const existing = (program2.body ?? []).find(
3772
+ (s) => s?.type === "ImportDeclaration" && s.source?.value === "jsx-loc-plugin/vite"
3773
+ );
3774
+ if (existing) {
3775
+ const has = (existing.specifiers ?? []).some(
3776
+ (sp) => sp?.type === "ImportSpecifier" && (sp.imported?.name === "jsxLoc" || sp.imported?.value === "jsxLoc")
3777
+ );
3778
+ if (has) return { changed: false };
3779
+ const spec = parseModule4('import { jsxLoc } from "jsx-loc-plugin/vite";').$ast.body?.[0]?.specifiers?.[0];
3780
+ if (!spec) return { changed: false };
3781
+ existing.specifiers = [...existing.specifiers ?? [], spec];
3782
+ return { changed: true };
3783
+ }
3784
+ const importDecl = parseModule4('import { jsxLoc } from "jsx-loc-plugin/vite";').$ast.body?.[0];
3785
+ if (!importDecl) return { changed: false };
3786
+ const body = program2.body ?? [];
3787
+ let insertAt = 0;
3788
+ while (insertAt < body.length && body[insertAt]?.type === "ImportDeclaration") {
3789
+ insertAt++;
3790
+ }
3791
+ program2.body.splice(insertAt, 0, importDecl);
3792
+ return { changed: true };
3793
+ }
3794
+ function ensureCjsJsxLocRequire(program2) {
3795
+ if (!program2 || program2.type !== "Program") return { changed: false };
3796
+ for (const stmt of program2.body ?? []) {
3797
+ if (stmt?.type !== "VariableDeclaration") continue;
3798
+ for (const decl of stmt.declarations ?? []) {
3799
+ const init = decl?.init;
3800
+ if (init?.type === "CallExpression" && isIdentifier3(init.callee, "require") && isStringLiteral3(init.arguments?.[0]) && init.arguments[0].value === "jsx-loc-plugin/vite") {
3801
+ if (decl.id?.type === "ObjectPattern") {
3802
+ const has = (decl.id.properties ?? []).some((p2) => {
3803
+ if (p2?.type !== "ObjectProperty" && p2?.type !== "Property") return false;
3804
+ return isIdentifier3(p2.key, "jsxLoc");
3805
+ });
3806
+ if (has) return { changed: false };
3807
+ const prop = parseModule4('const { jsxLoc } = require("jsx-loc-plugin/vite");').$ast.body?.[0]?.declarations?.[0]?.id?.properties?.[0];
3808
+ if (!prop) return { changed: false };
3809
+ decl.id.properties = [...decl.id.properties ?? [], prop];
3810
+ return { changed: true };
3811
+ }
3812
+ return { changed: false };
3813
+ }
3814
+ }
3815
+ }
3816
+ const reqDecl = parseModule4('const { jsxLoc } = require("jsx-loc-plugin/vite");').$ast.body?.[0];
3817
+ if (!reqDecl) return { changed: false };
3818
+ program2.body.unshift(reqDecl);
3819
+ return { changed: true };
3820
+ }
3821
+ function pluginsHasJsxLoc(arr) {
3822
+ if (!arr || arr.type !== "ArrayExpression") return false;
3823
+ for (const el of arr.elements ?? []) {
3824
+ const e = unwrapExpression(el);
3825
+ if (!e) continue;
3826
+ if (e.type === "CallExpression" && isIdentifier3(e.callee, "jsxLoc")) return true;
3827
+ }
3828
+ return false;
3829
+ }
3830
+ function ensurePluginsContainsJsxLoc(configObj) {
3831
+ const pluginsProp = getObjectProperty(configObj, "plugins");
3832
+ if (!pluginsProp) {
3833
+ const prop = parseModule4("export default { plugins: [jsxLoc()] };").$ast.body?.find((s) => s.type === "ExportDefaultDeclaration")?.declaration?.properties?.find((p2) => {
3834
+ const k = p2?.key;
3835
+ return k?.type === "Identifier" && k.name === "plugins" || isStringLiteral3(k) && k.value === "plugins";
3836
+ });
3837
+ if (!prop) return { changed: false };
3838
+ configObj.properties = [...configObj.properties ?? [], prop];
3839
+ return { changed: true };
3840
+ }
3841
+ const value = unwrapExpression(pluginsProp.value);
3842
+ if (!value) return { changed: false };
3843
+ if (value.type === "ArrayExpression") {
3844
+ if (pluginsHasJsxLoc(value)) return { changed: false };
3845
+ const jsxLocCall2 = parseModule4("const __x = jsxLoc();").$ast.body?.[0]?.declarations?.[0]?.init;
3846
+ if (!jsxLocCall2) return { changed: false };
3847
+ value.elements.push(jsxLocCall2);
3848
+ return { changed: true };
3849
+ }
3850
+ const jsxLocCall = parseModule4("const __x = jsxLoc();").$ast.body?.[0]?.declarations?.[0]?.init;
3851
+ if (!jsxLocCall) return { changed: false };
3852
+ const spread = { type: "SpreadElement", argument: value };
3853
+ pluginsProp.value = { type: "ArrayExpression", elements: [spread, jsxLocCall] };
3854
+ return { changed: true };
3855
+ }
3856
+ async function installViteJsxLocPlugin(opts) {
3857
+ const configPath = findViteConfigFile2(opts.projectPath);
3858
+ if (!configPath) return { configFile: null, modified: false };
3859
+ const configFilename = getViteConfigFilename(configPath);
3860
+ const original = readFileSync10(configPath, "utf-8");
3861
+ const isCjs = configPath.endsWith(".cjs");
3862
+ let mod;
3863
+ try {
3864
+ mod = parseModule4(original);
3865
+ } catch {
3866
+ return { configFile: configFilename, modified: false };
3867
+ }
3868
+ const found = findExportedConfigObjectExpression(mod);
3869
+ if (!found) return { configFile: configFilename, modified: false };
3870
+ let changed = false;
3871
+ if (isCjs) {
3872
+ const reqRes = ensureCjsJsxLocRequire(found.program);
3873
+ if (reqRes.changed) changed = true;
3874
+ } else {
3875
+ const impRes = ensureEsmJsxLocImport(found.program);
3876
+ if (impRes.changed) changed = true;
3877
+ }
3878
+ const pluginsRes = ensurePluginsContainsJsxLoc(found.objExpr);
3879
+ if (pluginsRes.changed) changed = true;
3880
+ const updated = changed ? generateCode4(mod).code : original;
3881
+ if (updated !== original) {
3882
+ writeFileSync6(configPath, updated, "utf-8");
3883
+ return { configFile: configFilename, modified: true };
3884
+ }
3885
+ return { configFile: configFilename, modified: false };
3886
+ }
3887
+
3146
3888
  // src/utils/next-routes.ts
3147
- import { existsSync as existsSync11 } from "fs";
3889
+ import { existsSync as existsSync15 } from "fs";
3148
3890
  import { mkdir, writeFile } from "fs/promises";
3149
- import { join as join11 } from "path";
3891
+ import { join as join15 } from "path";
3150
3892
  var DEV_SOURCE_ROUTE_TS = `/**
3151
3893
  * Dev-only API route for fetching source files
3152
3894
  *
@@ -3161,7 +3903,8 @@ var DEV_SOURCE_ROUTE_TS = `/**
3161
3903
 
3162
3904
  import { NextRequest, NextResponse } from "next/server";
3163
3905
  import { readFileSync, existsSync } from "fs";
3164
- import { resolve, relative, dirname, extname } from "path";
3906
+ import { resolve, relative, dirname, extname, sep } from "path";
3907
+ import { fileURLToPath } from "url";
3165
3908
 
3166
3909
  export const runtime = "nodejs";
3167
3910
 
@@ -3169,23 +3912,36 @@ export const runtime = "nodejs";
3169
3912
  const ALLOWED_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
3170
3913
 
3171
3914
  /**
3172
- * Find the project root by looking for package.json or next.config
3915
+ * Best-effort: resolve the Next.js project root (the dir that owns .next/) even in monorepos.
3916
+ *
3917
+ * Why: In monorepos, process.cwd() might be the workspace root (it also has package.json),
3918
+ * which would incorrectly store/read files under the wrong directory.
3173
3919
  */
3174
- function findProjectRoot(startDir: string): string {
3175
- let dir = startDir;
3176
- for (let i = 0; i < 10; i++) {
3177
- if (
3178
- existsSync(resolve(dir, "package.json")) ||
3179
- existsSync(resolve(dir, "next.config.js")) ||
3180
- existsSync(resolve(dir, "next.config.ts"))
3181
- ) {
3182
- return dir;
3920
+ function findNextProjectRoot(): string {
3921
+ // Prefer discovering via this route module's on-disk path.
3922
+ // In Next, route code is executed from within ".next/server/...".
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);
3183
3929
  }
3930
+ } catch {
3931
+ // ignore
3932
+ }
3933
+
3934
+ // Fallback: walk up from cwd looking for .next/
3935
+ let dir = process.cwd();
3936
+ for (let i = 0; i < 20; i++) {
3937
+ if (existsSync(resolve(dir, ".next"))) return dir;
3184
3938
  const parent = dirname(dir);
3185
3939
  if (parent === dir) break;
3186
3940
  dir = parent;
3187
3941
  }
3188
- return startDir;
3942
+
3943
+ // Final fallback: cwd
3944
+ return process.cwd();
3189
3945
  }
3190
3946
 
3191
3947
  /**
@@ -3244,8 +4000,8 @@ export async function GET(request: NextRequest) {
3244
4000
  );
3245
4001
  }
3246
4002
 
3247
- // Find project root
3248
- const projectRoot = findProjectRoot(process.cwd());
4003
+ // Find project root (prefer Next project root over workspace root)
4004
+ const projectRoot = findNextProjectRoot();
3249
4005
 
3250
4006
  // Resolve the file path
3251
4007
  const resolvedPath = resolve(filePath);
@@ -3274,6 +4030,8 @@ export async function GET(request: NextRequest) {
3274
4030
  return NextResponse.json({
3275
4031
  content,
3276
4032
  relativePath,
4033
+ projectRoot,
4034
+ workspaceRoot,
3277
4035
  });
3278
4036
  } catch (error) {
3279
4037
  console.error("[Dev Source API] Error reading file:", error);
@@ -3281,20 +4039,331 @@ export async function GET(request: NextRequest) {
3281
4039
  }
3282
4040
  }
3283
4041
  `;
4042
+ var SCREENSHOT_ROUTE_TS = `/**
4043
+ * Dev-only API route for saving and retrieving vision analysis screenshots
4044
+ *
4045
+ * This route allows the UILint overlay to:
4046
+ * - POST: Save screenshots and element manifests for vision analysis
4047
+ * - GET: Retrieve screenshots or list available screenshots
4048
+ *
4049
+ * Security:
4050
+ * - Only available in development mode
4051
+ * - Saves to .uilint/screenshots/ directory within project
4052
+ */
4053
+
4054
+ import { NextRequest, NextResponse } from "next/server";
4055
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
4056
+ import { resolve, join, dirname, basename, sep } from "path";
4057
+ import { fileURLToPath } from "url";
4058
+
4059
+ export const runtime = "nodejs";
4060
+
4061
+ // Maximum screenshot size (10MB)
4062
+ const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
4063
+
4064
+ /**
4065
+ * Best-effort: resolve the Next.js project root (the dir that owns .next/) even in monorepos.
4066
+ */
4067
+ function findNextProjectRoot(): string {
4068
+ try {
4069
+ const selfPath = fileURLToPath(import.meta.url);
4070
+ const marker = sep + ".next" + sep;
4071
+ const idx = selfPath.lastIndexOf(marker);
4072
+ if (idx !== -1) {
4073
+ return selfPath.slice(0, idx);
4074
+ }
4075
+ } catch {
4076
+ // ignore
4077
+ }
4078
+
4079
+ let dir = process.cwd();
4080
+ for (let i = 0; i < 20; i++) {
4081
+ if (existsSync(resolve(dir, ".next"))) return dir;
4082
+ const parent = dirname(dir);
4083
+ if (parent === dir) break;
4084
+ dir = parent;
4085
+ }
4086
+
4087
+ return process.cwd();
4088
+ }
4089
+
4090
+ /**
4091
+ * Get the screenshots directory path, creating it if needed
4092
+ */
4093
+ function getScreenshotsDir(projectRoot: string): string {
4094
+ const screenshotsDir = join(projectRoot, ".uilint", "screenshots");
4095
+ if (!existsSync(screenshotsDir)) {
4096
+ mkdirSync(screenshotsDir, { recursive: true });
4097
+ }
4098
+ return screenshotsDir;
4099
+ }
4100
+
4101
+ /**
4102
+ * Validate filename to prevent path traversal
4103
+ */
4104
+ function isValidFilename(filename: string): boolean {
4105
+ // Only allow alphanumeric, hyphens, underscores, and dots
4106
+ // Must end with .png, .jpeg, .jpg, or .json
4107
+ const validPattern = /^[a-zA-Z0-9_-]+\\.(png|jpeg|jpg|json)$/;
4108
+ return validPattern.test(filename) && !filename.includes("..");
4109
+ }
4110
+
4111
+ /**
4112
+ * POST: Save a screenshot and optionally its manifest
4113
+ */
4114
+ export async function POST(request: NextRequest) {
4115
+ // Block in production
4116
+ if (process.env.NODE_ENV === "production") {
4117
+ return NextResponse.json(
4118
+ { error: "Not available in production" },
4119
+ { status: 404 }
4120
+ );
4121
+ }
4122
+
4123
+ try {
4124
+ const body = await request.json();
4125
+ const { filename, imageData, manifest, analysisResult } = body;
4126
+
4127
+ if (!filename) {
4128
+ return NextResponse.json({ error: "Missing 'filename'" }, { status: 400 });
4129
+ }
4130
+
4131
+ // Validate filename
4132
+ if (!isValidFilename(filename)) {
4133
+ return NextResponse.json(
4134
+ { error: "Invalid filename format" },
4135
+ { status: 400 }
4136
+ );
4137
+ }
4138
+
4139
+ // Allow "sidecar-only" updates (manifest/analysisResult) without re-sending image bytes.
4140
+ const hasImageData = typeof imageData === "string" && imageData.length > 0;
4141
+ const hasSidecar =
4142
+ typeof manifest !== "undefined" || typeof analysisResult !== "undefined";
4143
+
4144
+ if (!hasImageData && !hasSidecar) {
4145
+ return NextResponse.json(
4146
+ { error: "Nothing to save (provide imageData and/or manifest/analysisResult)" },
4147
+ { status: 400 }
4148
+ );
4149
+ }
4150
+
4151
+ // Check size (image only)
4152
+ if (hasImageData && imageData.length > MAX_SCREENSHOT_SIZE) {
4153
+ return NextResponse.json(
4154
+ { error: "Screenshot too large (max 10MB)" },
4155
+ { status: 413 }
4156
+ );
4157
+ }
4158
+
4159
+ const projectRoot = findNextProjectRoot();
4160
+ const screenshotsDir = getScreenshotsDir(projectRoot);
4161
+
4162
+ const imagePath = join(screenshotsDir, filename);
4163
+
4164
+ // Save the image (base64 data URL) if provided
4165
+ if (hasImageData) {
4166
+ const base64Data = imageData.includes(",")
4167
+ ? imageData.split(",")[1]
4168
+ : imageData;
4169
+ writeFileSync(imagePath, Buffer.from(base64Data, "base64"));
4170
+ }
4171
+
4172
+ // Save manifest and analysis result as JSON sidecar
4173
+ if (hasSidecar) {
4174
+ const jsonFilename = filename.replace(/\\.(png|jpeg|jpg)$/, ".json");
4175
+ const jsonPath = join(screenshotsDir, jsonFilename);
4176
+
4177
+ // If a sidecar already exists, merge updates (lets us POST analysisResult later without re-sending image).
4178
+ let existing: any = null;
4179
+ if (existsSync(jsonPath)) {
4180
+ try {
4181
+ existing = JSON.parse(readFileSync(jsonPath, "utf-8"));
4182
+ } catch {
4183
+ existing = null;
4184
+ }
4185
+ }
4186
+
4187
+ const routeFromAnalysis =
4188
+ analysisResult && typeof analysisResult === "object"
4189
+ ? (analysisResult as any).route
4190
+ : undefined;
4191
+ const issuesFromAnalysis =
4192
+ analysisResult && typeof analysisResult === "object"
4193
+ ? (analysisResult as any).issues
4194
+ : undefined;
4195
+
4196
+ const jsonData = {
4197
+ ...(existing && typeof existing === "object" ? existing : {}),
4198
+ timestamp: Date.now(),
4199
+ filename,
4200
+ screenshotFile: filename,
4201
+ route:
4202
+ typeof routeFromAnalysis === "string"
4203
+ ? routeFromAnalysis
4204
+ : (existing as any)?.route ?? null,
4205
+ issues:
4206
+ Array.isArray(issuesFromAnalysis)
4207
+ ? issuesFromAnalysis
4208
+ : (existing as any)?.issues ?? null,
4209
+ manifest: typeof manifest === "undefined" ? existing?.manifest ?? null : manifest,
4210
+ analysisResult:
4211
+ typeof analysisResult === "undefined"
4212
+ ? existing?.analysisResult ?? null
4213
+ : analysisResult,
4214
+ };
4215
+ writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2));
4216
+ }
4217
+
4218
+ return NextResponse.json({
4219
+ success: true,
4220
+ path: imagePath,
4221
+ projectRoot,
4222
+ screenshotsDir,
4223
+ });
4224
+ } catch (error) {
4225
+ console.error("[Screenshot API] Error saving screenshot:", error);
4226
+ return NextResponse.json(
4227
+ { error: "Failed to save screenshot" },
4228
+ { status: 500 }
4229
+ );
4230
+ }
4231
+ }
4232
+
4233
+ /**
4234
+ * GET: Retrieve a screenshot or list available screenshots
4235
+ */
4236
+ export async function GET(request: NextRequest) {
4237
+ // Block in production
4238
+ if (process.env.NODE_ENV === "production") {
4239
+ return NextResponse.json(
4240
+ { error: "Not available in production" },
4241
+ { status: 404 }
4242
+ );
4243
+ }
4244
+
4245
+ const { searchParams } = new URL(request.url);
4246
+ const filename = searchParams.get("filename");
4247
+ const list = searchParams.get("list");
4248
+
4249
+ const projectRoot = findNextProjectRoot();
4250
+ const screenshotsDir = getScreenshotsDir(projectRoot);
4251
+
4252
+ // List mode: return all screenshots
4253
+ if (list === "true") {
4254
+ try {
4255
+ const files = readdirSync(screenshotsDir);
4256
+ const screenshots = files
4257
+ .filter((f) => /\\.(png|jpeg|jpg)$/.test(f))
4258
+ .map((f) => {
4259
+ const jsonFile = f.replace(/\\.(png|jpeg|jpg)$/, ".json");
4260
+ const jsonPath = join(screenshotsDir, jsonFile);
4261
+ let metadata = null;
4262
+ if (existsSync(jsonPath)) {
4263
+ try {
4264
+ metadata = JSON.parse(readFileSync(jsonPath, "utf-8"));
4265
+ } catch {
4266
+ // Ignore parse errors
4267
+ }
4268
+ }
4269
+ return {
4270
+ filename: f,
4271
+ metadata,
4272
+ };
4273
+ })
4274
+ .sort((a, b) => {
4275
+ // Sort by timestamp descending (newest first)
4276
+ const aTime = a.metadata?.timestamp || 0;
4277
+ const bTime = b.metadata?.timestamp || 0;
4278
+ return bTime - aTime;
4279
+ });
4280
+
4281
+ return NextResponse.json({ screenshots, projectRoot, screenshotsDir });
4282
+ } catch (error) {
4283
+ console.error("[Screenshot API] Error listing screenshots:", error);
4284
+ return NextResponse.json(
4285
+ { error: "Failed to list screenshots" },
4286
+ { status: 500 }
4287
+ );
4288
+ }
4289
+ }
4290
+
4291
+ // Retrieve mode: get specific screenshot
4292
+ if (!filename) {
4293
+ return NextResponse.json(
4294
+ { error: "Missing 'filename' parameter" },
4295
+ { status: 400 }
4296
+ );
4297
+ }
4298
+
4299
+ if (!isValidFilename(filename)) {
4300
+ return NextResponse.json(
4301
+ { error: "Invalid filename format" },
4302
+ { status: 400 }
4303
+ );
4304
+ }
4305
+
4306
+ const filePath = join(screenshotsDir, filename);
4307
+
4308
+ if (!existsSync(filePath)) {
4309
+ return NextResponse.json(
4310
+ { error: "Screenshot not found" },
4311
+ { status: 404 }
4312
+ );
4313
+ }
4314
+
4315
+ try {
4316
+ const content = readFileSync(filePath);
4317
+
4318
+ // Determine content type
4319
+ const ext = filename.split(".").pop()?.toLowerCase();
4320
+ const contentType =
4321
+ ext === "json"
4322
+ ? "application/json"
4323
+ : ext === "png"
4324
+ ? "image/png"
4325
+ : "image/jpeg";
4326
+
4327
+ if (ext === "json") {
4328
+ return NextResponse.json(JSON.parse(content.toString()));
4329
+ }
4330
+
4331
+ return new NextResponse(content, {
4332
+ headers: {
4333
+ "Content-Type": contentType,
4334
+ "Cache-Control": "no-cache",
4335
+ },
4336
+ });
4337
+ } catch (error) {
4338
+ console.error("[Screenshot API] Error reading screenshot:", error);
4339
+ return NextResponse.json(
4340
+ { error: "Failed to read screenshot" },
4341
+ { status: 500 }
4342
+ );
4343
+ }
4344
+ }
4345
+ `;
3284
4346
  async function writeRouteFile(absPath, relPath, content, opts) {
3285
- if (existsSync11(absPath) && !opts.force) return;
4347
+ if (existsSync15(absPath) && !opts.force) return;
3286
4348
  await writeFile(absPath, content, "utf-8");
3287
4349
  }
3288
4350
  async function installNextUILintRoutes(opts) {
3289
- const baseRel = join11(opts.appRoot, "api", ".uilint");
3290
- const baseAbs = join11(opts.projectPath, baseRel);
3291
- await mkdir(join11(baseAbs, "source"), { recursive: true });
4351
+ const baseRel = join15(opts.appRoot, "api", ".uilint");
4352
+ const baseAbs = join15(opts.projectPath, baseRel);
4353
+ await mkdir(join15(baseAbs, "source"), { recursive: true });
3292
4354
  await writeRouteFile(
3293
- join11(baseAbs, "source", "route.ts"),
3294
- join11(baseRel, "source", "route.ts"),
4355
+ join15(baseAbs, "source", "route.ts"),
4356
+ join15(baseRel, "source", "route.ts"),
3295
4357
  DEV_SOURCE_ROUTE_TS,
3296
4358
  opts
3297
4359
  );
4360
+ await mkdir(join15(baseAbs, "screenshots"), { recursive: true });
4361
+ await writeRouteFile(
4362
+ join15(baseAbs, "screenshots", "route.ts"),
4363
+ join15(baseRel, "screenshots", "route.ts"),
4364
+ SCREENSHOT_ROUTE_TS,
4365
+ opts
4366
+ );
3298
4367
  }
3299
4368
 
3300
4369
  // src/commands/install/execute.ts
@@ -3310,7 +4379,7 @@ async function executeAction(action, options) {
3310
4379
  wouldDo: `Create directory: ${action.path}`
3311
4380
  };
3312
4381
  }
3313
- if (!existsSync12(action.path)) {
4382
+ if (!existsSync16(action.path)) {
3314
4383
  mkdirSync3(action.path, { recursive: true });
3315
4384
  }
3316
4385
  return { action, success: true };
@@ -3323,11 +4392,11 @@ async function executeAction(action, options) {
3323
4392
  wouldDo: `Create file: ${action.path}${action.permissions ? ` (mode: ${action.permissions.toString(8)})` : ""}`
3324
4393
  };
3325
4394
  }
3326
- const dir = dirname6(action.path);
3327
- if (!existsSync12(dir)) {
4395
+ const dir = dirname9(action.path);
4396
+ if (!existsSync16(dir)) {
3328
4397
  mkdirSync3(dir, { recursive: true });
3329
4398
  }
3330
- writeFileSync6(action.path, action.content, "utf-8");
4399
+ writeFileSync7(action.path, action.content, "utf-8");
3331
4400
  if (action.permissions) {
3332
4401
  chmodSync(action.path, action.permissions);
3333
4402
  }
@@ -3342,18 +4411,18 @@ async function executeAction(action, options) {
3342
4411
  };
3343
4412
  }
3344
4413
  let existing = {};
3345
- if (existsSync12(action.path)) {
4414
+ if (existsSync16(action.path)) {
3346
4415
  try {
3347
- existing = JSON.parse(readFileSync7(action.path, "utf-8"));
4416
+ existing = JSON.parse(readFileSync11(action.path, "utf-8"));
3348
4417
  } catch {
3349
4418
  }
3350
4419
  }
3351
4420
  const merged = deepMerge(existing, action.merge);
3352
- const dir = dirname6(action.path);
3353
- if (!existsSync12(dir)) {
4421
+ const dir = dirname9(action.path);
4422
+ if (!existsSync16(dir)) {
3354
4423
  mkdirSync3(dir, { recursive: true });
3355
4424
  }
3356
- writeFileSync6(action.path, JSON.stringify(merged, null, 2), "utf-8");
4425
+ writeFileSync7(action.path, JSON.stringify(merged, null, 2), "utf-8");
3357
4426
  return { action, success: true };
3358
4427
  }
3359
4428
  case "delete_file": {
@@ -3364,7 +4433,7 @@ async function executeAction(action, options) {
3364
4433
  wouldDo: `Delete file: ${action.path}`
3365
4434
  };
3366
4435
  }
3367
- if (existsSync12(action.path)) {
4436
+ if (existsSync16(action.path)) {
3368
4437
  unlinkSync(action.path);
3369
4438
  }
3370
4439
  return { action, success: true };
@@ -3377,12 +4446,12 @@ async function executeAction(action, options) {
3377
4446
  wouldDo: `Append to file: ${action.path}`
3378
4447
  };
3379
4448
  }
3380
- if (existsSync12(action.path)) {
3381
- const content = readFileSync7(action.path, "utf-8");
4449
+ if (existsSync16(action.path)) {
4450
+ const content = readFileSync11(action.path, "utf-8");
3382
4451
  if (action.ifNotContains && content.includes(action.ifNotContains)) {
3383
4452
  return { action, success: true };
3384
4453
  }
3385
- writeFileSync6(action.path, content + action.content, "utf-8");
4454
+ writeFileSync7(action.path, content + action.content, "utf-8");
3386
4455
  }
3387
4456
  return { action, success: true };
3388
4457
  }
@@ -3395,6 +4464,9 @@ async function executeAction(action, options) {
3395
4464
  case "inject_next_config": {
3396
4465
  return await executeInjectNextConfig(action, options);
3397
4466
  }
4467
+ case "inject_vite_config": {
4468
+ return await executeInjectViteConfig(action, options);
4469
+ }
3398
4470
  case "install_next_routes": {
3399
4471
  return await executeInstallNextRoutes(action, options);
3400
4472
  }
@@ -3450,6 +4522,7 @@ async function executeInjectReact(action, options) {
3450
4522
  const result = await installReactUILintOverlay({
3451
4523
  projectPath: action.projectPath,
3452
4524
  appRoot: action.appRoot,
4525
+ mode: action.mode,
3453
4526
  force: false,
3454
4527
  // Auto-select first choice for execute phase
3455
4528
  confirmFileChoice: async (choices) => choices[0]
@@ -3461,6 +4534,25 @@ async function executeInjectReact(action, options) {
3461
4534
  error: success ? void 0 : "Failed to configure React overlay"
3462
4535
  };
3463
4536
  }
4537
+ async function executeInjectViteConfig(action, options) {
4538
+ const { dryRun = false } = options;
4539
+ if (dryRun) {
4540
+ return {
4541
+ action,
4542
+ success: true,
4543
+ wouldDo: `Inject jsx-loc-plugin into vite.config: ${action.projectPath}`
4544
+ };
4545
+ }
4546
+ const result = await installViteJsxLocPlugin({
4547
+ projectPath: action.projectPath,
4548
+ force: false
4549
+ });
4550
+ return {
4551
+ action,
4552
+ success: result.modified || result.configFile !== null,
4553
+ error: result.configFile === null ? "No vite.config found" : void 0
4554
+ };
4555
+ }
3464
4556
  async function executeInjectNextConfig(action, options) {
3465
4557
  const { dryRun = false } = options;
3466
4558
  if (dryRun) {
@@ -3518,6 +4610,7 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3518
4610
  const filesDeleted = [];
3519
4611
  const eslintTargets = [];
3520
4612
  let nextApp;
4613
+ let viteApp;
3521
4614
  for (const result of actionsPerformed) {
3522
4615
  if (!result.success) continue;
3523
4616
  const { action } = result;
@@ -3540,6 +4633,12 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3540
4633
  });
3541
4634
  break;
3542
4635
  case "inject_react":
4636
+ if (action.mode === "vite") {
4637
+ viteApp = { entryRoot: action.appRoot };
4638
+ } else {
4639
+ nextApp = { appRoot: action.appRoot };
4640
+ }
4641
+ break;
3543
4642
  case "install_next_routes":
3544
4643
  nextApp = { appRoot: action.appRoot };
3545
4644
  break;
@@ -3561,7 +4660,8 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3561
4660
  filesDeleted,
3562
4661
  dependenciesInstalled,
3563
4662
  eslintTargets,
3564
- nextApp
4663
+ nextApp,
4664
+ viteApp
3565
4665
  };
3566
4666
  }
3567
4667
  async function execute(plan, options = {}) {
@@ -3611,11 +4711,14 @@ async function execute(plan, options = {}) {
3611
4711
  if (action.path.includes("hooks.json")) items.push("hooks");
3612
4712
  if (action.path.includes("genstyleguide.md")) items.push("genstyleguide");
3613
4713
  if (action.path.includes("genrules.md")) items.push("genrules");
4714
+ if (action.path.includes("/skills/") && action.path.includes("SKILL.md")) items.push("skill");
3614
4715
  }
3615
4716
  if (action.type === "inject_eslint") items.push("eslint");
3616
- if (action.type === "inject_react" || action.type === "install_next_routes") {
3617
- items.push("next");
4717
+ if (action.type === "install_next_routes") items.push("next");
4718
+ if (action.type === "inject_react") {
4719
+ items.push(action.mode === "vite" ? "vite" : "next");
3618
4720
  }
4721
+ if (action.type === "inject_vite_config") items.push("vite");
3619
4722
  }
3620
4723
  const uniqueItems = [...new Set(items)];
3621
4724
  const summary = buildSummary(
@@ -3641,13 +4744,18 @@ var cliPrompter = {
3641
4744
  {
3642
4745
  value: "eslint",
3643
4746
  label: "ESLint plugin",
3644
- hint: "Installs uilint-eslint and configures eslint.config.js"
4747
+ hint: "Installs uilint-eslint and configures eslint.config.*"
3645
4748
  },
3646
4749
  {
3647
4750
  value: "next",
3648
4751
  label: "UI overlay",
3649
4752
  hint: "Installs routes + UILintProvider (Alt+Click to inspect)"
3650
4753
  },
4754
+ {
4755
+ value: "vite",
4756
+ label: "UI overlay (Vite)",
4757
+ hint: "Installs jsx-loc-plugin + UILintProvider (Alt+Click to inspect)"
4758
+ },
3651
4759
  {
3652
4760
  value: "genstyleguide",
3653
4761
  label: "/genstyleguide command",
@@ -3667,10 +4775,15 @@ var cliPrompter = {
3667
4775
  value: "genrules",
3668
4776
  label: "/genrules command",
3669
4777
  hint: "Adds .cursor/commands/genrules.md for ESLint rule generation"
4778
+ },
4779
+ {
4780
+ value: "skill",
4781
+ label: "UI Consistency Agent Skill",
4782
+ hint: "Cursor agent skill for generating ESLint rules from UI patterns"
3670
4783
  }
3671
4784
  ],
3672
4785
  required: true,
3673
- initialValues: ["eslint", "next", "genstyleguide"]
4786
+ initialValues: ["eslint", "next", "genstyleguide", "skill"]
3674
4787
  });
3675
4788
  },
3676
4789
  async confirmMcpMerge() {
@@ -3700,6 +4813,17 @@ var cliPrompter = {
3700
4813
  });
3701
4814
  return apps.find((a) => a.projectPath === chosen) || apps[0];
3702
4815
  },
4816
+ async selectViteApp(apps) {
4817
+ const chosen = await select2({
4818
+ message: "Which Vite + React project should UILint install into?",
4819
+ options: apps.map((app) => ({
4820
+ value: app.projectPath,
4821
+ label: app.projectPath
4822
+ })),
4823
+ initialValue: apps[0].projectPath
4824
+ });
4825
+ return apps.find((a) => a.projectPath === chosen) || apps[0];
4826
+ },
3703
4827
  async selectEslintPackages(packages) {
3704
4828
  if (packages.length === 1) {
3705
4829
  const confirmed = await confirm2({
@@ -3856,13 +4980,14 @@ async function promptForField(field, ruleName) {
3856
4980
  }
3857
4981
  async function gatherChoices(state, options, prompter) {
3858
4982
  let items;
3859
- const hasExplicitFlags = options.mcp !== void 0 || options.hooks !== void 0 || options.genstyleguide !== void 0 || options.genrules !== void 0 || options.routes !== void 0 || options.react !== void 0;
4983
+ const hasExplicitFlags = options.mcp !== void 0 || options.hooks !== void 0 || options.genstyleguide !== void 0 || options.genrules !== void 0 || options.skill !== void 0 || options.routes !== void 0 || options.react !== void 0;
3860
4984
  if (hasExplicitFlags || options.eslint) {
3861
4985
  items = [];
3862
4986
  if (options.mcp) items.push("mcp");
3863
4987
  if (options.hooks) items.push("hooks");
3864
4988
  if (options.genstyleguide) items.push("genstyleguide");
3865
4989
  if (options.genrules) items.push("genrules");
4990
+ if (options.skill) items.push("skill");
3866
4991
  if (options.routes || options.react) items.push("next");
3867
4992
  if (options.eslint) items.push("eslint");
3868
4993
  } else if (options.mode) {
@@ -3901,6 +5026,25 @@ async function gatherChoices(state, options, prompter) {
3901
5026
  };
3902
5027
  }
3903
5028
  }
5029
+ let viteChoices;
5030
+ if (items.includes("vite")) {
5031
+ if (state.viteApps.length === 0) {
5032
+ throw new Error(
5033
+ "Could not find a Vite + React project (expected vite.config.* + react deps). Run this from your Vite project root."
5034
+ );
5035
+ } else if (state.viteApps.length === 1) {
5036
+ viteChoices = {
5037
+ projectPath: state.viteApps[0].projectPath,
5038
+ detection: state.viteApps[0].detection
5039
+ };
5040
+ } else {
5041
+ const selected = await prompter.selectViteApp(state.viteApps);
5042
+ viteChoices = {
5043
+ projectPath: selected.projectPath,
5044
+ detection: selected.detection
5045
+ };
5046
+ }
5047
+ }
3904
5048
  let eslintChoices;
3905
5049
  if (items.includes("eslint")) {
3906
5050
  const packagesWithEslint = state.packages.filter(
@@ -3908,7 +5052,7 @@ async function gatherChoices(state, options, prompter) {
3908
5052
  );
3909
5053
  if (packagesWithEslint.length === 0) {
3910
5054
  throw new Error(
3911
- "No packages with eslint.config.{mjs,js,cjs} found. Create an ESLint config first."
5055
+ "No packages with eslint.config.{ts,mjs,js,cjs} found. Create an ESLint config first."
3912
5056
  );
3913
5057
  }
3914
5058
  const packagePaths = await prompter.selectEslintPackages(
@@ -3940,6 +5084,7 @@ async function gatherChoices(state, options, prompter) {
3940
5084
  mcpMerge,
3941
5085
  hooksMerge,
3942
5086
  next: nextChoices,
5087
+ vite: viteChoices,
3943
5088
  eslint: eslintChoices
3944
5089
  };
3945
5090
  }
@@ -3988,7 +5133,7 @@ function displayResults(result) {
3988
5133
  if (summary.nextApp) {
3989
5134
  installedItems.push(
3990
5135
  `${pc.cyan("Next Routes")} \u2192 ${pc.dim(
3991
- join12(summary.nextApp.appRoot, "api/.uilint")
5136
+ join16(summary.nextApp.appRoot, "api/.uilint")
3992
5137
  )}`
3993
5138
  );
3994
5139
  installedItems.push(
@@ -4000,6 +5145,16 @@ function displayResults(result) {
4000
5145
  )}`
4001
5146
  );
4002
5147
  }
5148
+ if (summary.viteApp) {
5149
+ installedItems.push(
5150
+ `${pc.cyan("Vite Overlay")} \u2192 ${pc.dim("<UILintProvider> injected")}`
5151
+ );
5152
+ installedItems.push(
5153
+ `${pc.cyan("JSX Loc Plugin")} \u2192 ${pc.dim(
5154
+ "vite.config plugins patched with jsxLoc()"
5155
+ )}`
5156
+ );
5157
+ }
4003
5158
  if (summary.eslintTargets.length > 0) {
4004
5159
  installedItems.push(
4005
5160
  `${pc.cyan("ESLint Plugin")} \u2192 installed in ${summary.eslintTargets.length} package(s)`
@@ -4039,9 +5194,14 @@ function displayResults(result) {
4039
5194
  if (summary.installedItems.includes("hooks")) {
4040
5195
  steps.push("Hooks will auto-validate UI files when the agent stops");
4041
5196
  }
4042
- if (summary.nextApp) {
5197
+ if (summary.nextApp) {
5198
+ steps.push(
5199
+ "Run your Next.js dev server - use Alt+Click on any element to inspect"
5200
+ );
5201
+ }
5202
+ if (summary.viteApp) {
4043
5203
  steps.push(
4044
- "Run your Next.js dev server - use Alt+Click on any element to inspect"
5204
+ "Run your Vite dev server - use Alt+Click on any element to inspect"
4045
5205
  );
4046
5206
  }
4047
5207
  if (summary.eslintTargets.length > 0) {
@@ -4108,12 +5268,207 @@ async function install(options = {}, prompter = cliPrompter, executeOptions = {}
4108
5268
  }
4109
5269
 
4110
5270
  // src/commands/serve.ts
4111
- import { existsSync as existsSync13, statSync as statSync2, readdirSync as readdirSync3, readFileSync as readFileSync8 } from "fs";
4112
- import { createRequire as createRequire2 } from "module";
4113
- import { dirname as dirname7, resolve as resolve5, relative as relative2, join as join13 } from "path";
5271
+ import { existsSync as existsSync18, statSync as statSync4, readdirSync as readdirSync5, readFileSync as readFileSync12 } from "fs";
5272
+ import { createRequire as createRequire3 } from "module";
5273
+ import { dirname as dirname11, resolve as resolve5, relative as relative4, join as join18, parse as parse2 } from "path";
4114
5274
  import { WebSocketServer, WebSocket } from "ws";
4115
5275
  import { watch } from "chokidar";
4116
- import { findWorkspaceRoot as findWorkspaceRoot5 } from "uilint-core/node";
5276
+ import {
5277
+ findWorkspaceRoot as findWorkspaceRoot6,
5278
+ getVisionAnalyzer as getCoreVisionAnalyzer
5279
+ } from "uilint-core/node";
5280
+
5281
+ // src/utils/vision-run.ts
5282
+ import { dirname as dirname10, join as join17, parse } from "path";
5283
+ import { existsSync as existsSync17, statSync as statSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync8 } from "fs";
5284
+ import {
5285
+ ensureOllamaReady as ensureOllamaReady5,
5286
+ findStyleGuidePath as findStyleGuidePath4,
5287
+ findUILintStyleGuideUpwards as findUILintStyleGuideUpwards3,
5288
+ readStyleGuide as readStyleGuide4,
5289
+ VisionAnalyzer,
5290
+ UILINT_DEFAULT_VISION_MODEL
5291
+ } from "uilint-core/node";
5292
+ async function resolveVisionStyleGuide(args) {
5293
+ const projectPath = args.projectPath;
5294
+ const startDir = args.startDir ?? projectPath;
5295
+ if (args.styleguide) {
5296
+ const styleguideArg = resolvePathSpecifier(args.styleguide, projectPath);
5297
+ if (existsSync17(styleguideArg)) {
5298
+ const stat = statSync3(styleguideArg);
5299
+ if (stat.isFile()) {
5300
+ return {
5301
+ styleguideLocation: styleguideArg,
5302
+ styleGuide: await readStyleGuide4(styleguideArg)
5303
+ };
5304
+ }
5305
+ if (stat.isDirectory()) {
5306
+ const found = findStyleGuidePath4(styleguideArg);
5307
+ return {
5308
+ styleguideLocation: found,
5309
+ styleGuide: found ? await readStyleGuide4(found) : null
5310
+ };
5311
+ }
5312
+ }
5313
+ return { styleGuide: null, styleguideLocation: null };
5314
+ }
5315
+ const upwards = findUILintStyleGuideUpwards3(startDir);
5316
+ const fallback = upwards ?? findStyleGuidePath4(projectPath);
5317
+ return {
5318
+ styleguideLocation: fallback,
5319
+ styleGuide: fallback ? await readStyleGuide4(fallback) : null
5320
+ };
5321
+ }
5322
+ var ollamaReadyOnce = /* @__PURE__ */ new Map();
5323
+ async function ensureOllamaReadyCached(params) {
5324
+ const key = `${params.baseUrl}::${params.model}`;
5325
+ const existing = ollamaReadyOnce.get(key);
5326
+ if (existing) return existing;
5327
+ const p2 = ensureOllamaReady5({ model: params.model, baseUrl: params.baseUrl }).then(() => void 0).catch((e) => {
5328
+ ollamaReadyOnce.delete(key);
5329
+ throw e;
5330
+ });
5331
+ ollamaReadyOnce.set(key, p2);
5332
+ return p2;
5333
+ }
5334
+ function writeVisionDebugDump(params) {
5335
+ const resolvedDirOrFile = resolvePathSpecifier(
5336
+ params.dumpPath,
5337
+ process.cwd()
5338
+ );
5339
+ const safeStamp = params.now.toISOString().replace(/[:.]/g, "-");
5340
+ const dumpFile = resolvedDirOrFile.endsWith(".json") || resolvedDirOrFile.endsWith(".jsonl") ? resolvedDirOrFile : `${resolvedDirOrFile}/vision-debug-${safeStamp}.json`;
5341
+ mkdirSync4(dirname10(dumpFile), { recursive: true });
5342
+ writeFileSync8(
5343
+ dumpFile,
5344
+ JSON.stringify(
5345
+ {
5346
+ version: 1,
5347
+ timestamp: params.now.toISOString(),
5348
+ runtime: params.runtime,
5349
+ metadata: params.metadata ?? null,
5350
+ inputs: {
5351
+ imageBase64: params.includeSensitive ? params.inputs.imageBase64 : "(omitted; set debugDumpIncludeSensitive=true)",
5352
+ manifest: params.inputs.manifest,
5353
+ styleguideLocation: params.inputs.styleguideLocation,
5354
+ styleGuide: params.includeSensitive ? params.inputs.styleGuide : "(omitted; set debugDumpIncludeSensitive=true)"
5355
+ }
5356
+ },
5357
+ null,
5358
+ 2
5359
+ ),
5360
+ "utf-8"
5361
+ );
5362
+ return dumpFile;
5363
+ }
5364
+ async function runVisionAnalysis(args) {
5365
+ const visionModel = args.model || UILINT_DEFAULT_VISION_MODEL;
5366
+ const baseUrl = args.baseUrl ?? "http://localhost:11434";
5367
+ let styleGuide = null;
5368
+ let styleguideLocation = null;
5369
+ if (args.styleGuide !== void 0) {
5370
+ styleGuide = args.styleGuide;
5371
+ styleguideLocation = args.styleguideLocation ?? null;
5372
+ } else {
5373
+ args.onPhase?.("Resolving styleguide...");
5374
+ const resolved = await resolveVisionStyleGuide({
5375
+ projectPath: args.projectPath,
5376
+ styleguide: args.styleguide,
5377
+ startDir: args.styleguideStartDir
5378
+ });
5379
+ styleGuide = resolved.styleGuide;
5380
+ styleguideLocation = resolved.styleguideLocation;
5381
+ }
5382
+ if (!args.skipEnsureOllama) {
5383
+ args.onPhase?.("Preparing Ollama...");
5384
+ await ensureOllamaReadyCached({ model: visionModel, baseUrl });
5385
+ }
5386
+ if (args.debugDump) {
5387
+ writeVisionDebugDump({
5388
+ dumpPath: args.debugDump,
5389
+ now: /* @__PURE__ */ new Date(),
5390
+ runtime: { visionModel, baseUrl },
5391
+ inputs: {
5392
+ imageBase64: args.imageBase64,
5393
+ manifest: args.manifest,
5394
+ styleguideLocation,
5395
+ styleGuide
5396
+ },
5397
+ includeSensitive: Boolean(args.debugDumpIncludeSensitive),
5398
+ metadata: args.debugDumpMetadata
5399
+ });
5400
+ }
5401
+ const analyzer = args.analyzer ?? new VisionAnalyzer({
5402
+ baseUrl: args.baseUrl,
5403
+ visionModel
5404
+ });
5405
+ args.onPhase?.(`Analyzing ${args.manifest.length} elements...`);
5406
+ const result = await analyzer.analyzeScreenshot(
5407
+ args.imageBase64,
5408
+ args.manifest,
5409
+ {
5410
+ styleGuide,
5411
+ onProgress: args.onProgress
5412
+ }
5413
+ );
5414
+ args.onPhase?.(
5415
+ `Done (${result.issues.length} issues, ${result.analysisTime}ms)`
5416
+ );
5417
+ return {
5418
+ issues: result.issues,
5419
+ analysisTime: result.analysisTime,
5420
+ // Prompt is available in newer uilint-core versions; keep this resilient across versions.
5421
+ prompt: result.prompt,
5422
+ rawResponse: result.rawResponse,
5423
+ styleguideLocation,
5424
+ visionModel,
5425
+ baseUrl
5426
+ };
5427
+ }
5428
+ function writeVisionMarkdownReport(args) {
5429
+ const p2 = parse(args.imagePath);
5430
+ const outPath = args.outPath ?? join17(p2.dir, `${p2.name || p2.base}.vision.md`);
5431
+ const lines = [];
5432
+ lines.push(`# UILint Vision Report`);
5433
+ lines.push(``);
5434
+ lines.push(`- Image: \`${p2.base}\``);
5435
+ if (args.route) lines.push(`- Route: \`${args.route}\``);
5436
+ if (typeof args.timestamp === "number") {
5437
+ lines.push(`- Timestamp: \`${new Date(args.timestamp).toISOString()}\``);
5438
+ }
5439
+ if (args.visionModel) lines.push(`- Model: \`${args.visionModel}\``);
5440
+ if (args.baseUrl) lines.push(`- Ollama baseUrl: \`${args.baseUrl}\``);
5441
+ if (typeof args.analysisTimeMs === "number")
5442
+ lines.push(`- Analysis time: \`${args.analysisTimeMs}ms\``);
5443
+ lines.push(`- Generated: \`${(/* @__PURE__ */ new Date()).toISOString()}\``);
5444
+ lines.push(``);
5445
+ if (args.metadata && Object.keys(args.metadata).length > 0) {
5446
+ lines.push(`## Metadata`);
5447
+ lines.push(``);
5448
+ lines.push("```json");
5449
+ lines.push(JSON.stringify(args.metadata, null, 2));
5450
+ lines.push("```");
5451
+ lines.push(``);
5452
+ }
5453
+ lines.push(`## Prompt`);
5454
+ lines.push(``);
5455
+ lines.push("```text");
5456
+ lines.push((args.prompt ?? "").trim());
5457
+ lines.push("```");
5458
+ lines.push(``);
5459
+ lines.push(`## Raw Response`);
5460
+ lines.push(``);
5461
+ lines.push("```text");
5462
+ lines.push((args.rawResponse ?? "").trim());
5463
+ lines.push("```");
5464
+ lines.push(``);
5465
+ const content = lines.join("\n");
5466
+ mkdirSync4(dirname10(outPath), { recursive: true });
5467
+ writeFileSync8(outPath, content, "utf-8");
5468
+ return { outPath, content };
5469
+ }
5470
+
5471
+ // src/commands/serve.ts
4117
5472
  function pickAppRoot(params) {
4118
5473
  const { cwd, workspaceRoot } = params;
4119
5474
  if (detectNextAppRouter(cwd)) return cwd;
@@ -4128,11 +5483,23 @@ function pickAppRoot(params) {
4128
5483
  }
4129
5484
  var cache = /* @__PURE__ */ new Map();
4130
5485
  var eslintInstances = /* @__PURE__ */ new Map();
5486
+ var visionAnalyzer = null;
5487
+ function getVisionAnalyzerInstance() {
5488
+ if (!visionAnalyzer) {
5489
+ visionAnalyzer = getCoreVisionAnalyzer();
5490
+ }
5491
+ return visionAnalyzer;
5492
+ }
5493
+ var serverAppRootForVision = process.cwd();
5494
+ function isValidScreenshotFilename(filename) {
5495
+ const validPattern = /^[a-zA-Z0-9_-]+\.(png|jpeg|jpg)$/;
5496
+ return validPattern.test(filename) && !filename.includes("..");
5497
+ }
4131
5498
  var resolvedPathCache = /* @__PURE__ */ new Map();
4132
5499
  var subscriptions = /* @__PURE__ */ new Map();
4133
5500
  var fileWatcher = null;
4134
5501
  var connectedClients = 0;
4135
- var localRequire = createRequire2(import.meta.url);
5502
+ var localRequire = createRequire3(import.meta.url);
4136
5503
  function buildLineStarts(code) {
4137
5504
  const starts = [0];
4138
5505
  for (let i = 0; i < code.length; i++) {
@@ -4146,8 +5513,8 @@ function offsetFromLineCol(lineStarts, line1, col0, codeLength) {
4146
5513
  return Math.max(0, Math.min(codeLength, base + Math.max(0, col0)));
4147
5514
  }
4148
5515
  function buildJsxElementSpans(code, dataLocFile) {
4149
- const { parse } = localRequire("@typescript-eslint/typescript-estree");
4150
- const ast = parse(code, {
5516
+ const { parse: parse3 } = localRequire("@typescript-eslint/typescript-estree");
5517
+ const ast = parse3(code, {
4151
5518
  loc: true,
4152
5519
  range: true,
4153
5520
  jsx: true,
@@ -4210,10 +5577,10 @@ function findESLintCwd(startDir) {
4210
5577
  let dir = startDir;
4211
5578
  for (let i = 0; i < 30; i++) {
4212
5579
  for (const cfg of ESLINT_CONFIG_FILES2) {
4213
- if (existsSync13(join13(dir, cfg))) return dir;
5580
+ if (existsSync18(join18(dir, cfg))) return dir;
4214
5581
  }
4215
- if (existsSync13(join13(dir, "package.json"))) return dir;
4216
- const parent = dirname7(dir);
5582
+ if (existsSync18(join18(dir, "package.json"))) return dir;
5583
+ const parent = dirname11(dir);
4217
5584
  if (parent === dir) break;
4218
5585
  dir = parent;
4219
5586
  }
@@ -4226,7 +5593,7 @@ function normalizeDataLocFilePath(absoluteFilePath, projectCwd) {
4226
5593
  const abs = normalizePathSlashes(resolve5(absoluteFilePath));
4227
5594
  const cwd = normalizePathSlashes(resolve5(projectCwd));
4228
5595
  if (abs === cwd || abs.startsWith(cwd + "/")) {
4229
- return normalizePathSlashes(relative2(cwd, abs));
5596
+ return normalizePathSlashes(relative4(cwd, abs));
4230
5597
  }
4231
5598
  return abs;
4232
5599
  }
@@ -4238,25 +5605,25 @@ function resolveRequestedFilePath(filePath) {
4238
5605
  if (cached) return cached;
4239
5606
  const cwd = process.cwd();
4240
5607
  const fromCwd = resolve5(cwd, filePath);
4241
- if (existsSync13(fromCwd)) {
5608
+ if (existsSync18(fromCwd)) {
4242
5609
  resolvedPathCache.set(filePath, fromCwd);
4243
5610
  return fromCwd;
4244
5611
  }
4245
- const wsRoot = findWorkspaceRoot5(cwd);
5612
+ const wsRoot = findWorkspaceRoot6(cwd);
4246
5613
  const fromWs = resolve5(wsRoot, filePath);
4247
- if (existsSync13(fromWs)) {
5614
+ if (existsSync18(fromWs)) {
4248
5615
  resolvedPathCache.set(filePath, fromWs);
4249
5616
  return fromWs;
4250
5617
  }
4251
5618
  for (const top of ["apps", "packages"]) {
4252
- const base = join13(wsRoot, top);
4253
- if (!existsSync13(base)) continue;
5619
+ const base = join18(wsRoot, top);
5620
+ if (!existsSync18(base)) continue;
4254
5621
  try {
4255
- const entries = readdirSync3(base, { withFileTypes: true });
5622
+ const entries = readdirSync5(base, { withFileTypes: true });
4256
5623
  for (const ent of entries) {
4257
5624
  if (!ent.isDirectory()) continue;
4258
5625
  const p2 = resolve5(base, ent.name, filePath);
4259
- if (existsSync13(p2)) {
5626
+ if (existsSync18(p2)) {
4260
5627
  resolvedPathCache.set(filePath, p2);
4261
5628
  return p2;
4262
5629
  }
@@ -4271,7 +5638,7 @@ async function getESLintForProject(projectCwd) {
4271
5638
  const cached = eslintInstances.get(projectCwd);
4272
5639
  if (cached) return cached;
4273
5640
  try {
4274
- const req = createRequire2(join13(projectCwd, "package.json"));
5641
+ const req = createRequire3(join18(projectCwd, "package.json"));
4275
5642
  const mod = req("eslint");
4276
5643
  const ESLintCtor = mod?.ESLint ?? mod?.default?.ESLint ?? mod?.default ?? mod;
4277
5644
  if (!ESLintCtor) return null;
@@ -4284,13 +5651,13 @@ async function getESLintForProject(projectCwd) {
4284
5651
  }
4285
5652
  async function lintFile(filePath, onProgress) {
4286
5653
  const absolutePath = resolveRequestedFilePath(filePath);
4287
- if (!existsSync13(absolutePath)) {
5654
+ if (!existsSync18(absolutePath)) {
4288
5655
  onProgress(`File not found: ${pc.dim(absolutePath)}`);
4289
5656
  return [];
4290
5657
  }
4291
5658
  const mtimeMs = (() => {
4292
5659
  try {
4293
- return statSync2(absolutePath).mtimeMs;
5660
+ return statSync4(absolutePath).mtimeMs;
4294
5661
  } catch {
4295
5662
  return 0;
4296
5663
  }
@@ -4300,7 +5667,7 @@ async function lintFile(filePath, onProgress) {
4300
5667
  onProgress("Cache hit (unchanged)");
4301
5668
  return cached.issues;
4302
5669
  }
4303
- const fileDir = dirname7(absolutePath);
5670
+ const fileDir = dirname11(absolutePath);
4304
5671
  const projectCwd = findESLintCwd(fileDir);
4305
5672
  onProgress(`Resolving ESLint project... ${pc.dim(projectCwd)}`);
4306
5673
  const eslint = await getESLintForProject(projectCwd);
@@ -4323,7 +5690,7 @@ async function lintFile(filePath, onProgress) {
4323
5690
  let codeLength = 0;
4324
5691
  try {
4325
5692
  onProgress("Building JSX map...");
4326
- const code = readFileSync8(absolutePath, "utf-8");
5693
+ const code = readFileSync12(absolutePath, "utf-8");
4327
5694
  codeLength = code.length;
4328
5695
  lineStarts = buildLineStarts(code);
4329
5696
  spans = buildJsxElementSpans(code, dataLocFile);
@@ -4392,6 +5759,7 @@ async function handleMessage(ws, data) {
4392
5759
  message.filePath ?? "(all)"
4393
5760
  )}`
4394
5761
  );
5762
+ } else if (message.type === "vision:analyze") {
4395
5763
  }
4396
5764
  switch (message.type) {
4397
5765
  case "lint:file": {
@@ -4404,9 +5772,9 @@ async function handleMessage(ws, data) {
4404
5772
  });
4405
5773
  const startedAt = Date.now();
4406
5774
  const resolved = resolveRequestedFilePath(filePath);
4407
- if (!existsSync13(resolved)) {
5775
+ if (!existsSync18(resolved)) {
4408
5776
  const cwd = process.cwd();
4409
- const wsRoot = findWorkspaceRoot5(cwd);
5777
+ const wsRoot = findWorkspaceRoot6(cwd);
4410
5778
  logWarning(
4411
5779
  [
4412
5780
  `${pc.dim("[ws]")} File not found for request`,
@@ -4492,6 +5860,167 @@ async function handleMessage(ws, data) {
4492
5860
  }
4493
5861
  break;
4494
5862
  }
5863
+ case "vision:analyze": {
5864
+ const {
5865
+ route,
5866
+ timestamp,
5867
+ screenshot,
5868
+ screenshotFile,
5869
+ manifest,
5870
+ requestId
5871
+ } = message;
5872
+ logInfo(
5873
+ `${pc.dim("[ws]")} ${pc.bold("vision:analyze")} ${pc.dim(route)}${requestId ? ` ${pc.dim(`(req ${requestId})`)}` : ""}`
5874
+ );
5875
+ sendMessage(ws, {
5876
+ type: "vision:progress",
5877
+ route,
5878
+ requestId,
5879
+ phase: "Starting vision analysis..."
5880
+ });
5881
+ const startedAt = Date.now();
5882
+ const analyzer = getVisionAnalyzerInstance();
5883
+ try {
5884
+ const screenshotBytes = typeof screenshot === "string" ? Buffer.byteLength(screenshot) : 0;
5885
+ const analyzerModel = typeof analyzer.getModel === "function" ? analyzer.getModel() : void 0;
5886
+ const analyzerBaseUrl = typeof analyzer.getBaseUrl === "function" ? analyzer.getBaseUrl() : void 0;
5887
+ logInfo(
5888
+ [
5889
+ `${pc.dim("[ws]")} ${pc.dim("vision")} details`,
5890
+ ` route: ${pc.dim(route)}`,
5891
+ ` requestId: ${pc.dim(requestId ?? "(none)")}`,
5892
+ ` manifest: ${pc.dim(String(manifest.length))} element(s)`,
5893
+ ` screenshot: ${pc.dim(
5894
+ screenshot ? `${Math.round(screenshotBytes / 1024)}kb` : "none"
5895
+ )}`,
5896
+ ` screenshotFile: ${pc.dim(screenshotFile ?? "(none)")}`,
5897
+ ` ollamaUrl: ${pc.dim(analyzerBaseUrl ?? "(default)")}`,
5898
+ ` visionModel: ${pc.dim(analyzerModel ?? "(default)")}`
5899
+ ].join("\n")
5900
+ );
5901
+ if (!screenshot) {
5902
+ sendMessage(ws, {
5903
+ type: "vision:result",
5904
+ route,
5905
+ issues: [],
5906
+ analysisTime: Date.now() - startedAt,
5907
+ error: "No screenshot provided for vision analysis",
5908
+ requestId
5909
+ });
5910
+ break;
5911
+ }
5912
+ const result = await runVisionAnalysis({
5913
+ imageBase64: screenshot,
5914
+ manifest,
5915
+ projectPath: serverAppRootForVision,
5916
+ // In the overlay/server context, default to upward search from app root.
5917
+ baseUrl: analyzerBaseUrl,
5918
+ model: analyzerModel,
5919
+ analyzer,
5920
+ onPhase: (phase) => {
5921
+ sendMessage(ws, {
5922
+ type: "vision:progress",
5923
+ route,
5924
+ requestId,
5925
+ phase
5926
+ });
5927
+ }
5928
+ });
5929
+ if (typeof screenshotFile === "string" && screenshotFile.length > 0) {
5930
+ if (!isValidScreenshotFilename(screenshotFile)) {
5931
+ logWarning(
5932
+ `Skipping vision report write: invalid screenshotFile ${pc.dim(
5933
+ screenshotFile
5934
+ )}`
5935
+ );
5936
+ } else {
5937
+ const screenshotsDir = join18(
5938
+ serverAppRootForVision,
5939
+ ".uilint",
5940
+ "screenshots"
5941
+ );
5942
+ const imagePath = join18(screenshotsDir, screenshotFile);
5943
+ try {
5944
+ if (!existsSync18(imagePath)) {
5945
+ logWarning(
5946
+ `Skipping vision report write: screenshot file not found ${pc.dim(
5947
+ imagePath
5948
+ )}`
5949
+ );
5950
+ } else {
5951
+ const report = writeVisionMarkdownReport({
5952
+ imagePath,
5953
+ route,
5954
+ timestamp,
5955
+ visionModel: result.visionModel,
5956
+ baseUrl: result.baseUrl,
5957
+ analysisTimeMs: result.analysisTime,
5958
+ prompt: result.prompt ?? null,
5959
+ rawResponse: result.rawResponse ?? null,
5960
+ metadata: {
5961
+ screenshotFile: parse2(imagePath).base,
5962
+ appRoot: serverAppRootForVision,
5963
+ manifestElements: manifest.length,
5964
+ requestId: requestId ?? null
5965
+ }
5966
+ });
5967
+ logInfo(
5968
+ `${pc.dim("[ws]")} wrote vision report ${pc.dim(
5969
+ report.outPath
5970
+ )}`
5971
+ );
5972
+ }
5973
+ } catch (e) {
5974
+ logWarning(
5975
+ `Failed to write vision report for ${pc.dim(screenshotFile)}: ${e instanceof Error ? e.message : String(e)}`
5976
+ );
5977
+ }
5978
+ }
5979
+ }
5980
+ const elapsed = Date.now() - startedAt;
5981
+ logInfo(
5982
+ `${pc.dim("[ws]")} vision:analyze done ${pc.dim(route)} \u2192 ${pc.bold(
5983
+ `${result.issues.length}`
5984
+ )} issue(s) ${pc.dim(`(${elapsed}ms)`)}`
5985
+ );
5986
+ if (result.rawResponse) {
5987
+ logInfo(
5988
+ `${pc.dim("[ws]")} vision rawResponse ${pc.dim(
5989
+ `${result.rawResponse.length} chars`
5990
+ )}`
5991
+ );
5992
+ }
5993
+ sendMessage(ws, {
5994
+ type: "vision:result",
5995
+ route,
5996
+ issues: result.issues,
5997
+ analysisTime: result.analysisTime,
5998
+ requestId
5999
+ });
6000
+ } catch (error) {
6001
+ const errorMessage = error instanceof Error ? error.message : String(error);
6002
+ const stack = error instanceof Error ? error.stack : void 0;
6003
+ logError(
6004
+ [
6005
+ `Vision analysis failed`,
6006
+ ` route: ${route}`,
6007
+ ` requestId: ${requestId ?? "(none)"}`,
6008
+ ` error: ${errorMessage}`,
6009
+ stack ? ` stack:
6010
+ ${stack}` : ""
6011
+ ].filter(Boolean).join("\n")
6012
+ );
6013
+ sendMessage(ws, {
6014
+ type: "vision:result",
6015
+ route,
6016
+ issues: [],
6017
+ analysisTime: Date.now() - startedAt,
6018
+ error: errorMessage,
6019
+ requestId
6020
+ });
6021
+ }
6022
+ break;
6023
+ }
4495
6024
  }
4496
6025
  }
4497
6026
  function handleDisconnect(ws) {
@@ -4520,8 +6049,9 @@ function handleFileChange(filePath) {
4520
6049
  async function serve(options) {
4521
6050
  const port = options.port || 9234;
4522
6051
  const cwd = process.cwd();
4523
- const wsRoot = findWorkspaceRoot5(cwd);
6052
+ const wsRoot = findWorkspaceRoot6(cwd);
4524
6053
  const appRoot = pickAppRoot({ cwd, workspaceRoot: wsRoot });
6054
+ serverAppRootForVision = appRoot;
4525
6055
  logInfo(`Workspace root: ${pc.dim(wsRoot)}`);
4526
6056
  logInfo(`App root: ${pc.dim(appRoot)}`);
4527
6057
  logInfo(`Server cwd: ${pc.dim(cwd)}`);
@@ -4561,22 +6091,505 @@ async function serve(options) {
4561
6091
  `UILint WebSocket server running on ${pc.cyan(`ws://localhost:${port}`)}`
4562
6092
  );
4563
6093
  logInfo("Press Ctrl+C to stop");
4564
- await new Promise((resolve7) => {
6094
+ await new Promise((resolve8) => {
4565
6095
  process.on("SIGINT", () => {
4566
6096
  logInfo("Shutting down...");
4567
6097
  wss.close();
4568
6098
  fileWatcher?.close();
4569
- resolve7();
6099
+ resolve8();
4570
6100
  });
4571
6101
  });
4572
6102
  }
4573
6103
 
6104
+ // src/commands/vision.ts
6105
+ import { dirname as dirname12, resolve as resolve6, join as join19 } from "path";
6106
+ import {
6107
+ existsSync as existsSync19,
6108
+ readFileSync as readFileSync13,
6109
+ readdirSync as readdirSync6
6110
+ } from "fs";
6111
+ import {
6112
+ ensureOllamaReady as ensureOllamaReady6,
6113
+ STYLEGUIDE_PATHS as STYLEGUIDE_PATHS2,
6114
+ UILINT_DEFAULT_VISION_MODEL as UILINT_DEFAULT_VISION_MODEL2
6115
+ } from "uilint-core/node";
6116
+ function envTruthy3(name) {
6117
+ const v = process.env[name];
6118
+ if (!v) return false;
6119
+ return v === "1" || v.toLowerCase() === "true" || v.toLowerCase() === "yes";
6120
+ }
6121
+ function preview3(text3, maxLen) {
6122
+ if (text3.length <= maxLen) return text3;
6123
+ return text3.slice(0, maxLen) + "\n\u2026<truncated>\u2026\n" + text3.slice(-maxLen);
6124
+ }
6125
+ function debugEnabled3(options) {
6126
+ return Boolean(options.debug) || envTruthy3("UILINT_DEBUG");
6127
+ }
6128
+ function debugFullEnabled3(options) {
6129
+ return Boolean(options.debugFull) || envTruthy3("UILINT_DEBUG_FULL");
6130
+ }
6131
+ function debugDumpPath3(options) {
6132
+ const v = options.debugDump ?? process.env.UILINT_DEBUG_DUMP;
6133
+ if (!v) return null;
6134
+ if (v === "1" || v.toLowerCase() === "true" || v.toLowerCase() === "yes") {
6135
+ return resolve6(process.cwd(), ".uilint");
6136
+ }
6137
+ return v;
6138
+ }
6139
+ function debugLog3(enabled, message, obj) {
6140
+ if (!enabled) return;
6141
+ if (obj === void 0) {
6142
+ console.error(pc.dim("[uilint:debug]"), message);
6143
+ } else {
6144
+ try {
6145
+ console.error(pc.dim("[uilint:debug]"), message, obj);
6146
+ } catch {
6147
+ console.error(pc.dim("[uilint:debug]"), message);
6148
+ }
6149
+ }
6150
+ }
6151
+ function findScreenshotsDirUpwards(startDir) {
6152
+ let dir = startDir;
6153
+ for (let i = 0; i < 20; i++) {
6154
+ const candidate = join19(dir, ".uilint", "screenshots");
6155
+ if (existsSync19(candidate)) return candidate;
6156
+ const parent = dirname12(dir);
6157
+ if (parent === dir) break;
6158
+ dir = parent;
6159
+ }
6160
+ return null;
6161
+ }
6162
+ function listScreenshotSidecars(dirPath) {
6163
+ if (!existsSync19(dirPath)) return [];
6164
+ const entries = readdirSync6(dirPath).filter((f) => f.endsWith(".json")).map((f) => join19(dirPath, f));
6165
+ const out = [];
6166
+ for (const p2 of entries) {
6167
+ try {
6168
+ const json = loadJsonFile(p2);
6169
+ const issues = Array.isArray(json?.issues) ? json.issues : json?.analysisResult?.issues;
6170
+ out.push({
6171
+ path: p2,
6172
+ filename: json?.filename || json?.screenshotFile || p2.split("/").pop() || p2,
6173
+ timestamp: typeof json?.timestamp === "number" ? json.timestamp : void 0,
6174
+ route: typeof json?.route === "string" ? json.route : void 0,
6175
+ issueCount: Array.isArray(issues) ? issues.length : void 0
6176
+ });
6177
+ } catch {
6178
+ out.push({
6179
+ path: p2,
6180
+ filename: p2.split("/").pop() || p2
6181
+ });
6182
+ }
6183
+ }
6184
+ out.sort((a, b) => {
6185
+ const at = a.timestamp ?? 0;
6186
+ const bt = b.timestamp ?? 0;
6187
+ if (at !== bt) return bt - at;
6188
+ return b.path.localeCompare(a.path);
6189
+ });
6190
+ return out;
6191
+ }
6192
+ function readImageAsBase64(imagePath) {
6193
+ const bytes = readFileSync13(imagePath);
6194
+ return { base64: bytes.toString("base64"), sizeBytes: bytes.byteLength };
6195
+ }
6196
+ function loadJsonFile(filePath) {
6197
+ const raw = readFileSync13(filePath, "utf-8");
6198
+ return JSON.parse(raw);
6199
+ }
6200
+ function formatIssuesText(issues) {
6201
+ if (issues.length === 0) return "No vision issues found.\n";
6202
+ return issues.map((i) => {
6203
+ const sev = i.severity || "info";
6204
+ const cat = i.category || "other";
6205
+ const where = i.dataLoc ? ` (${i.dataLoc})` : "";
6206
+ return `- [${sev}/${cat}] ${i.message}${where}`;
6207
+ }).join("\n") + "\n";
6208
+ }
6209
+ async function vision(options) {
6210
+ const isJsonOutput = options.output === "json";
6211
+ const dbg = debugEnabled3(options);
6212
+ const dbgFull = debugFullEnabled3(options);
6213
+ const dbgDump = debugDumpPath3(options);
6214
+ if (!isJsonOutput) intro2("Vision (Screenshot) Analysis");
6215
+ try {
6216
+ const projectPath = process.cwd();
6217
+ if (options.list) {
6218
+ const base = (options.screenshotsDir ? resolvePathSpecifier(options.screenshotsDir, projectPath) : null) || findScreenshotsDirUpwards(projectPath);
6219
+ if (!base) {
6220
+ if (isJsonOutput) {
6221
+ printJSON({ screenshotsDir: null, sidecars: [] });
6222
+ } else {
6223
+ logWarning(
6224
+ "No `.uilint/screenshots` directory found (walked up from cwd)."
6225
+ );
6226
+ }
6227
+ await flushLangfuse();
6228
+ return;
6229
+ }
6230
+ const sidecars = listScreenshotSidecars(base);
6231
+ if (isJsonOutput) {
6232
+ printJSON({ screenshotsDir: base, sidecars });
6233
+ } else {
6234
+ logInfo(`Screenshots dir: ${pc.dim(base)}`);
6235
+ if (sidecars.length === 0) {
6236
+ process.stdout.write("No sidecars found.\n");
6237
+ } else {
6238
+ process.stdout.write(
6239
+ sidecars.map((s, idx) => {
6240
+ const stamp = s.timestamp ? new Date(s.timestamp).toLocaleString() : "(no timestamp)";
6241
+ const route = s.route ? ` ${pc.dim(s.route)}` : "";
6242
+ const count = typeof s.issueCount === "number" ? ` ${pc.dim(`(${s.issueCount} issues)`)}` : "";
6243
+ return `${idx === 0 ? "*" : "-"} ${s.path}${pc.dim(
6244
+ ` \u2014 ${stamp}`
6245
+ )}${route}${count}`;
6246
+ }).join("\n") + "\n"
6247
+ );
6248
+ process.stdout.write(
6249
+ pc.dim(
6250
+ `Tip: run \`uilint vision --sidecar <path>\` (the newest is marked with "*").
6251
+ `
6252
+ )
6253
+ );
6254
+ }
6255
+ }
6256
+ await flushLangfuse();
6257
+ return;
6258
+ }
6259
+ const imagePath = options.image ? resolvePathSpecifier(options.image, projectPath) : void 0;
6260
+ const sidecarPath = options.sidecar ? resolvePathSpecifier(options.sidecar, projectPath) : void 0;
6261
+ const manifestFilePath = options.manifestFile ? resolvePathSpecifier(options.manifestFile, projectPath) : void 0;
6262
+ if (!imagePath && !sidecarPath) {
6263
+ if (isJsonOutput) {
6264
+ printJSON({ error: "No input provided", issues: [] });
6265
+ } else {
6266
+ logError("No input provided. Use --image or --sidecar.");
6267
+ }
6268
+ await flushLangfuse();
6269
+ process.exit(1);
6270
+ }
6271
+ if (imagePath && !existsSync19(imagePath)) {
6272
+ throw new Error(`Image not found: ${imagePath}`);
6273
+ }
6274
+ if (sidecarPath && !existsSync19(sidecarPath)) {
6275
+ throw new Error(`Sidecar not found: ${sidecarPath}`);
6276
+ }
6277
+ if (manifestFilePath && !existsSync19(manifestFilePath)) {
6278
+ throw new Error(`Manifest file not found: ${manifestFilePath}`);
6279
+ }
6280
+ const sidecar = sidecarPath ? loadJsonFile(sidecarPath) : null;
6281
+ const routeLabel = options.route || (typeof sidecar?.route === "string" ? sidecar.route : void 0) || (sidecarPath ? `(from ${sidecarPath})` : "(unknown)");
6282
+ let manifest = null;
6283
+ if (options.manifestJson) {
6284
+ manifest = JSON.parse(options.manifestJson);
6285
+ } else if (manifestFilePath) {
6286
+ manifest = loadJsonFile(manifestFilePath);
6287
+ } else if (sidecar && Array.isArray(sidecar.manifest)) {
6288
+ manifest = sidecar.manifest;
6289
+ }
6290
+ if (!manifest || manifest.length === 0) {
6291
+ throw new Error(
6292
+ "No manifest provided. Supply --manifest-json, --manifest-file, or a sidecar JSON with a `manifest` array."
6293
+ );
6294
+ }
6295
+ let styleGuide = null;
6296
+ let styleguideLocation = null;
6297
+ const startPath = (imagePath ?? sidecarPath ?? manifestFilePath ?? void 0) || void 0;
6298
+ {
6299
+ const resolved = await resolveVisionStyleGuide({
6300
+ projectPath,
6301
+ styleguide: options.styleguide,
6302
+ startDir: startPath ? dirname12(startPath) : projectPath
6303
+ });
6304
+ styleGuide = resolved.styleGuide;
6305
+ styleguideLocation = resolved.styleguideLocation;
6306
+ }
6307
+ if (styleguideLocation && styleGuide) {
6308
+ if (!isJsonOutput)
6309
+ logSuccess(`Using styleguide: ${pc.dim(styleguideLocation)}`);
6310
+ } else if (!styleGuide && !isJsonOutput) {
6311
+ logWarning("No styleguide found");
6312
+ note2(
6313
+ [
6314
+ `Searched in: ${options.styleguide || projectPath}`,
6315
+ "",
6316
+ "Looked for:",
6317
+ ...STYLEGUIDE_PATHS2.map((p2) => ` \u2022 ${p2}`),
6318
+ "",
6319
+ `Create ${pc.cyan(
6320
+ ".uilint/styleguide.md"
6321
+ )} (recommended: run ${pc.cyan("/genstyleguide")} in Cursor).`
6322
+ ].join("\n"),
6323
+ "Missing Styleguide"
6324
+ );
6325
+ }
6326
+ debugLog3(dbg, "Vision input (high-level)", {
6327
+ imagePath: imagePath ?? null,
6328
+ sidecarPath: sidecarPath ?? null,
6329
+ manifestFile: manifestFilePath ?? null,
6330
+ manifestElements: manifest.length,
6331
+ route: routeLabel,
6332
+ styleguideLocation,
6333
+ styleGuideLength: styleGuide ? styleGuide.length : 0
6334
+ });
6335
+ const visionModel = options.model || UILINT_DEFAULT_VISION_MODEL2;
6336
+ const prepStartNs = nsNow();
6337
+ if (!isJsonOutput) {
6338
+ await withSpinner("Preparing Ollama", async () => {
6339
+ await ensureOllamaReady6({
6340
+ model: visionModel,
6341
+ baseUrl: options.baseUrl
6342
+ });
6343
+ });
6344
+ } else {
6345
+ await ensureOllamaReady6({ model: visionModel, baseUrl: options.baseUrl });
6346
+ }
6347
+ const prepEndNs = nsNow();
6348
+ const resolvedImagePath = imagePath || (() => {
6349
+ const screenshotFile = typeof sidecar?.screenshotFile === "string" ? sidecar.screenshotFile : typeof sidecar?.filename === "string" ? sidecar.filename : void 0;
6350
+ if (!screenshotFile) return null;
6351
+ const baseDir = sidecarPath ? dirname12(sidecarPath) : projectPath;
6352
+ const abs = resolve6(baseDir, screenshotFile);
6353
+ return abs;
6354
+ })();
6355
+ if (!resolvedImagePath) {
6356
+ throw new Error(
6357
+ "No image path could be resolved. Provide --image or a sidecar with `screenshotFile`/`filename`."
6358
+ );
6359
+ }
6360
+ if (!existsSync19(resolvedImagePath)) {
6361
+ throw new Error(`Image not found: ${resolvedImagePath}`);
6362
+ }
6363
+ const { base64, sizeBytes } = readImageAsBase64(resolvedImagePath);
6364
+ debugLog3(dbg, "Image loaded", {
6365
+ imagePath: resolvedImagePath,
6366
+ sizeBytes,
6367
+ base64Length: base64.length
6368
+ });
6369
+ if (dbgFull && styleGuide) {
6370
+ debugLog3(dbg, "Styleguide (full)", styleGuide);
6371
+ } else if (dbg && styleGuide) {
6372
+ debugLog3(dbg, "Styleguide (preview)", preview3(styleGuide, 800));
6373
+ }
6374
+ let result = null;
6375
+ const analysisStartNs = nsNow();
6376
+ let firstTokenNs = null;
6377
+ let firstThinkingNs = null;
6378
+ let lastThinkingNs = null;
6379
+ let firstAnswerNs = null;
6380
+ let lastAnswerNs = null;
6381
+ if (isJsonOutput) {
6382
+ result = await runVisionAnalysis({
6383
+ imageBase64: base64,
6384
+ manifest,
6385
+ projectPath,
6386
+ styleGuide,
6387
+ styleguideLocation,
6388
+ baseUrl: options.baseUrl,
6389
+ model: visionModel,
6390
+ skipEnsureOllama: true,
6391
+ debugDump: dbgDump ?? void 0,
6392
+ debugDumpIncludeSensitive: dbgFull,
6393
+ debugDumpMetadata: {
6394
+ route: routeLabel,
6395
+ imagePath: resolvedImagePath,
6396
+ imageSizeBytes: sizeBytes,
6397
+ imageBase64Length: base64.length
6398
+ }
6399
+ });
6400
+ } else {
6401
+ if (options.stream) {
6402
+ let lastStatus = "";
6403
+ let printedAnyText = false;
6404
+ let inThinking = false;
6405
+ result = await runVisionAnalysis({
6406
+ imageBase64: base64,
6407
+ manifest,
6408
+ projectPath,
6409
+ styleGuide,
6410
+ styleguideLocation,
6411
+ baseUrl: options.baseUrl,
6412
+ model: visionModel,
6413
+ skipEnsureOllama: true,
6414
+ debugDump: dbgDump ?? void 0,
6415
+ debugDumpIncludeSensitive: dbgFull,
6416
+ debugDumpMetadata: {
6417
+ route: routeLabel,
6418
+ imagePath: resolvedImagePath,
6419
+ imageSizeBytes: sizeBytes,
6420
+ imageBase64Length: base64.length
6421
+ },
6422
+ onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
6423
+ const nowNs = nsNow();
6424
+ if (!firstTokenNs && (thinkingDelta || delta)) firstTokenNs = nowNs;
6425
+ if (thinkingDelta) {
6426
+ if (!firstThinkingNs) firstThinkingNs = nowNs;
6427
+ lastThinkingNs = nowNs;
6428
+ }
6429
+ if (delta) {
6430
+ if (!firstAnswerNs) firstAnswerNs = nowNs;
6431
+ lastAnswerNs = nowNs;
6432
+ }
6433
+ if (thinkingDelta) {
6434
+ if (!printedAnyText) {
6435
+ printedAnyText = true;
6436
+ console.error(pc.dim("[vision] streaming:"));
6437
+ process.stderr.write(pc.dim("Thinking:\n"));
6438
+ inThinking = true;
6439
+ } else if (!inThinking) {
6440
+ process.stderr.write(pc.dim("\n\nThinking:\n"));
6441
+ inThinking = true;
6442
+ }
6443
+ process.stderr.write(thinkingDelta);
6444
+ return;
6445
+ }
6446
+ if (delta) {
6447
+ if (!printedAnyText) {
6448
+ printedAnyText = true;
6449
+ console.error(pc.dim("[vision] streaming:"));
6450
+ }
6451
+ if (inThinking) {
6452
+ process.stderr.write(pc.dim("\n\nAnswer:\n"));
6453
+ inThinking = false;
6454
+ }
6455
+ process.stderr.write(delta);
6456
+ return;
6457
+ }
6458
+ const line = (latestLine || "").trim();
6459
+ if (!line || line === lastStatus) return;
6460
+ lastStatus = line;
6461
+ console.error(pc.dim("[vision]"), line);
6462
+ }
6463
+ });
6464
+ } else {
6465
+ result = await withSpinner(
6466
+ "Analyzing screenshot with vision model",
6467
+ async (s) => {
6468
+ return await runVisionAnalysis({
6469
+ imageBase64: base64,
6470
+ manifest,
6471
+ projectPath,
6472
+ styleGuide,
6473
+ styleguideLocation,
6474
+ baseUrl: options.baseUrl,
6475
+ model: visionModel,
6476
+ skipEnsureOllama: true,
6477
+ debugDump: dbgDump ?? void 0,
6478
+ debugDumpIncludeSensitive: dbgFull,
6479
+ debugDumpMetadata: {
6480
+ route: routeLabel,
6481
+ imagePath: resolvedImagePath,
6482
+ imageSizeBytes: sizeBytes,
6483
+ imageBase64Length: base64.length
6484
+ },
6485
+ onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
6486
+ const nowNs = nsNow();
6487
+ if (!firstTokenNs && (thinkingDelta || delta))
6488
+ firstTokenNs = nowNs;
6489
+ if (thinkingDelta) {
6490
+ if (!firstThinkingNs) firstThinkingNs = nowNs;
6491
+ lastThinkingNs = nowNs;
6492
+ }
6493
+ if (delta) {
6494
+ if (!firstAnswerNs) firstAnswerNs = nowNs;
6495
+ lastAnswerNs = nowNs;
6496
+ }
6497
+ const maxLen = 60;
6498
+ const displayLine = latestLine.length > maxLen ? latestLine.slice(0, maxLen) + "\u2026" : latestLine;
6499
+ s.message(`Analyzing: ${pc.dim(displayLine || "...")}`);
6500
+ }
6501
+ });
6502
+ }
6503
+ );
6504
+ }
6505
+ }
6506
+ const analysisEndNs = nsNow();
6507
+ const issues = result?.issues ?? [];
6508
+ if (isJsonOutput) {
6509
+ printJSON({
6510
+ route: routeLabel,
6511
+ model: visionModel,
6512
+ issues,
6513
+ analysisTime: result?.analysisTime ?? 0,
6514
+ imagePath: resolvedImagePath,
6515
+ imageSizeBytes: sizeBytes
6516
+ });
6517
+ } else {
6518
+ logInfo(`Route: ${pc.dim(routeLabel)}`);
6519
+ logInfo(`Model: ${pc.dim(visionModel)}`);
6520
+ process.stdout.write(formatIssuesText(issues));
6521
+ if (process.stdout.isTTY) {
6522
+ const prepMs = nsToMs(prepEndNs - prepStartNs);
6523
+ const totalMs = nsToMs(analysisEndNs - analysisStartNs);
6524
+ const endToEndMs = nsToMs(analysisEndNs - prepStartNs);
6525
+ const ttftMs = firstTokenNs ? nsToMs(firstTokenNs - analysisStartNs) : null;
6526
+ const thinkingMs = firstThinkingNs && (firstAnswerNs || lastThinkingNs) ? nsToMs(
6527
+ (firstAnswerNs ?? lastThinkingNs ?? analysisEndNs) - firstThinkingNs
6528
+ ) : null;
6529
+ const outputMs = firstAnswerNs && (lastAnswerNs || analysisEndNs) ? nsToMs((lastAnswerNs ?? analysisEndNs) - firstAnswerNs) : null;
6530
+ note2(
6531
+ [
6532
+ `Prepare Ollama: ${formatMs(prepMs)}`,
6533
+ `Time to first token: ${maybeMs(ttftMs)}`,
6534
+ `Thinking: ${maybeMs(thinkingMs)}`,
6535
+ `Outputting: ${maybeMs(outputMs)}`,
6536
+ `LLM total: ${formatMs(totalMs)}`,
6537
+ `End-to-end: ${formatMs(endToEndMs)}`,
6538
+ result?.analysisTime ? pc.dim(`(core analysisTime: ${formatMs(result.analysisTime)})`) : pc.dim("(core analysisTime: n/a)")
6539
+ ].join("\n"),
6540
+ "Timings"
6541
+ );
6542
+ }
6543
+ }
6544
+ try {
6545
+ writeVisionMarkdownReport({
6546
+ imagePath: resolvedImagePath,
6547
+ route: routeLabel,
6548
+ visionModel,
6549
+ baseUrl: options.baseUrl ?? "http://localhost:11434",
6550
+ analysisTimeMs: result?.analysisTime ?? 0,
6551
+ prompt: result?.prompt ?? null,
6552
+ rawResponse: result?.rawResponse ?? null,
6553
+ metadata: {
6554
+ imageSizeBytes: sizeBytes,
6555
+ styleguideLocation
6556
+ }
6557
+ });
6558
+ debugLog3(dbg, "Wrote .vision.md report alongside image");
6559
+ } catch (e) {
6560
+ debugLog3(
6561
+ dbg,
6562
+ "Failed to write .vision.md report",
6563
+ e instanceof Error ? e.message : e
6564
+ );
6565
+ }
6566
+ if (issues.length > 0) {
6567
+ await flushLangfuse();
6568
+ process.exit(1);
6569
+ }
6570
+ } catch (error) {
6571
+ if (options.output === "json") {
6572
+ printJSON({
6573
+ error: error instanceof Error ? error.message : "Unknown error",
6574
+ issues: []
6575
+ });
6576
+ } else {
6577
+ logError(
6578
+ error instanceof Error ? error.message : "Vision analysis failed"
6579
+ );
6580
+ }
6581
+ await flushLangfuse();
6582
+ process.exit(1);
6583
+ }
6584
+ await flushLangfuse();
6585
+ }
6586
+
4574
6587
  // src/commands/session.ts
4575
- import { existsSync as existsSync14, readFileSync as readFileSync9, writeFileSync as writeFileSync7, unlinkSync as unlinkSync2 } from "fs";
4576
- import { basename, dirname as dirname8, resolve as resolve6 } from "path";
6588
+ import { existsSync as existsSync20, readFileSync as readFileSync14, writeFileSync as writeFileSync10, unlinkSync as unlinkSync2 } from "fs";
6589
+ import { basename, dirname as dirname13, resolve as resolve7 } from "path";
4577
6590
  import { createStyleSummary as createStyleSummary3 } from "uilint-core";
4578
6591
  import {
4579
- ensureOllamaReady as ensureOllamaReady5,
6592
+ ensureOllamaReady as ensureOllamaReady7,
4580
6593
  parseCLIInput as parseCLIInput2,
4581
6594
  readStyleGuideFromProject as readStyleGuideFromProject2,
4582
6595
  readTailwindThemeTokens as readTailwindThemeTokens3
@@ -4584,18 +6597,18 @@ import {
4584
6597
  var SESSION_FILE = "/tmp/uilint-session.json";
4585
6598
  var UI_FILE_EXTENSIONS = [".tsx", ".jsx", ".css", ".scss", ".module.css"];
4586
6599
  function readSession() {
4587
- if (!existsSync14(SESSION_FILE)) {
6600
+ if (!existsSync20(SESSION_FILE)) {
4588
6601
  return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
4589
6602
  }
4590
6603
  try {
4591
- const content = readFileSync9(SESSION_FILE, "utf-8");
6604
+ const content = readFileSync14(SESSION_FILE, "utf-8");
4592
6605
  return JSON.parse(content);
4593
6606
  } catch {
4594
6607
  return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
4595
6608
  }
4596
6609
  }
4597
6610
  function writeSession(state) {
4598
- writeFileSync7(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
6611
+ writeFileSync10(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
4599
6612
  }
4600
6613
  function isUIFile(filePath) {
4601
6614
  return UI_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
@@ -4606,7 +6619,7 @@ function isScannableMarkupFile(filePath) {
4606
6619
  );
4607
6620
  }
4608
6621
  async function sessionClear() {
4609
- if (existsSync14(SESSION_FILE)) {
6622
+ if (existsSync20(SESSION_FILE)) {
4610
6623
  unlinkSync2(SESSION_FILE);
4611
6624
  }
4612
6625
  console.log(JSON.stringify({ cleared: true }));
@@ -4673,17 +6686,17 @@ async function sessionScan(options = {}) {
4673
6686
  }
4674
6687
  return;
4675
6688
  }
4676
- await ensureOllamaReady5();
6689
+ await ensureOllamaReady7();
4677
6690
  const client = await createLLMClient({});
4678
6691
  const results = [];
4679
6692
  for (const filePath of session.files) {
4680
- if (!existsSync14(filePath)) continue;
6693
+ if (!existsSync20(filePath)) continue;
4681
6694
  if (!isScannableMarkupFile(filePath)) continue;
4682
6695
  try {
4683
- const absolutePath = resolve6(process.cwd(), filePath);
4684
- const htmlLike = readFileSync9(filePath, "utf-8");
6696
+ const absolutePath = resolve7(process.cwd(), filePath);
6697
+ const htmlLike = readFileSync14(filePath, "utf-8");
4685
6698
  const snapshot = parseCLIInput2(htmlLike);
4686
- const tailwindSearchDir = dirname8(absolutePath);
6699
+ const tailwindSearchDir = dirname13(absolutePath);
4687
6700
  const tailwindTheme = readTailwindThemeTokens3(tailwindSearchDir);
4688
6701
  const styleSummary = createStyleSummary3(snapshot.styles, {
4689
6702
  html: snapshot.html,
@@ -4736,7 +6749,7 @@ async function sessionScan(options = {}) {
4736
6749
  };
4737
6750
  console.log(JSON.stringify(result));
4738
6751
  }
4739
- if (existsSync14(SESSION_FILE)) {
6752
+ if (existsSync20(SESSION_FILE)) {
4740
6753
  unlinkSync2(SESSION_FILE);
4741
6754
  }
4742
6755
  await flushLangfuse();
@@ -4747,9 +6760,9 @@ async function sessionList() {
4747
6760
  }
4748
6761
 
4749
6762
  // src/index.ts
4750
- import { readFileSync as readFileSync10 } from "fs";
4751
- import { dirname as dirname9, join as join14 } from "path";
4752
- import { fileURLToPath as fileURLToPath2 } from "url";
6763
+ import { readFileSync as readFileSync15 } from "fs";
6764
+ import { dirname as dirname14, join as join20 } from "path";
6765
+ import { fileURLToPath as fileURLToPath4 } from "url";
4753
6766
  function assertNodeVersion(minMajor) {
4754
6767
  const ver = process.versions.node || "";
4755
6768
  const majorStr = ver.split(".")[0] || "";
@@ -4765,9 +6778,9 @@ assertNodeVersion(20);
4765
6778
  var program = new Command();
4766
6779
  function getCLIVersion2() {
4767
6780
  try {
4768
- const __dirname = dirname9(fileURLToPath2(import.meta.url));
4769
- const pkgPath = join14(__dirname, "..", "package.json");
4770
- const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
6781
+ const __dirname3 = dirname14(fileURLToPath4(import.meta.url));
6782
+ const pkgPath = join20(__dirname3, "..", "package.json");
6783
+ const pkg = JSON.parse(readFileSync15(pkgPath, "utf-8"));
4771
6784
  return pkg.version || "0.0.0";
4772
6785
  } catch {
4773
6786
  return "0.0.0";
@@ -4870,6 +6883,40 @@ program.command("serve").description("Start WebSocket server for real-time UI li
4870
6883
  port: parseInt(options.port, 10)
4871
6884
  });
4872
6885
  });
6886
+ program.command("vision").description("Analyze a screenshot with Ollama vision models (requires a manifest)").option("--list", "List available .uilint/screenshots sidecars and exit").option(
6887
+ "--screenshots-dir <path>",
6888
+ "Screenshots directory for --list (default: nearest .uilint/screenshots)"
6889
+ ).option("--image <path>", "Path to a screenshot image (png/jpg)").option(
6890
+ "--sidecar <path>",
6891
+ "Path to a .uilint/screenshots/*.json sidecar (contains manifest + metadata)"
6892
+ ).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(
6893
+ "-s, --styleguide <path>",
6894
+ "Path to style guide file OR project directory (falls back to upward search)"
6895
+ ).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(
6896
+ "--debug-full",
6897
+ "Print full prompt/styleguide and include base64 in dumps (can be very large)"
6898
+ ).option(
6899
+ "--debug-dump <path>",
6900
+ "Write full analysis payload dump to JSON file (or directory to auto-name)"
6901
+ ).action(async (options) => {
6902
+ await vision({
6903
+ list: options.list,
6904
+ screenshotsDir: options.screenshotsDir,
6905
+ image: options.image,
6906
+ sidecar: options.sidecar,
6907
+ manifestFile: options.manifestFile,
6908
+ manifestJson: options.manifestJson,
6909
+ route: options.route,
6910
+ styleguide: options.styleguide,
6911
+ output: options.output,
6912
+ model: options.model,
6913
+ baseUrl: options.baseUrl,
6914
+ stream: options.stream,
6915
+ debug: options.debug,
6916
+ debugFull: options.debugFull,
6917
+ debugDump: options.debugDump
6918
+ });
6919
+ });
4873
6920
  var sessionCmd = program.command("session").description(
4874
6921
  "Manage file tracking for agentic sessions (used by Cursor hooks)"
4875
6922
  );