lens-engine 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -368,21 +368,211 @@ async function runMain(cmd, opts = {}) {
368
368
 
369
369
  // packages/cli/src/commands/daemon.ts
370
370
  import { spawn } from "child_process";
371
- import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
371
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
372
+ import { createRequire as createRequire2 } from "module";
373
+ import { homedir as homedir2 } from "os";
374
+ import { dirname as dirname3, join as join3 } from "path";
375
+ import { fileURLToPath as fileURLToPath2 } from "url";
376
+
377
+ // packages/cli/src/commands/daemon-install.ts
378
+ import { existsSync as existsSync2 } from "fs";
372
379
  import { createRequire } from "module";
373
- import { homedir } from "os";
374
- import { dirname, join } from "path";
380
+ import { dirname as dirname2, join as join2 } from "path";
375
381
  import { fileURLToPath } from "url";
376
- var DATA_DIR = join(homedir(), ".lens");
377
- var PID_FILE = join(DATA_DIR, "daemon.pid");
378
- var LOG_FILE = join(DATA_DIR, "daemon.log");
382
+
383
+ // packages/cli/src/lib/launchd.ts
384
+ import { execFileSync } from "child_process";
385
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
386
+ import { homedir, platform } from "os";
387
+ import { dirname, join } from "path";
388
+ var PLIST_LABEL = "com.lens.daemon";
389
+ var PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
390
+ var LOG_PATH = join(homedir(), ".lens", "daemon.log");
391
+ function isMacOS() {
392
+ return platform() === "darwin";
393
+ }
394
+ function guiDomain() {
395
+ const uid = process.getuid?.();
396
+ if (typeof uid !== "number") {
397
+ throw new Error("process.getuid() unavailable \u2014 macOS required for launchd commands");
398
+ }
399
+ return `gui/${uid}`;
400
+ }
401
+ function renderPlist({ nodePath, daemonEntry, extraEnv = {} }) {
402
+ const env = {
403
+ PATH: `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
404
+ HOME: homedir(),
405
+ LENS_DAEMON: "1",
406
+ ...extraEnv
407
+ };
408
+ const envEntries = Object.entries(env).map(([k, v]) => ` <key>${escapeXml(k)}</key>
409
+ <string>${escapeXml(v)}</string>`).join("\n");
410
+ return `<?xml version="1.0" encoding="UTF-8"?>
411
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
412
+ <plist version="1.0">
413
+ <dict>
414
+ <key>Label</key>
415
+ <string>${PLIST_LABEL}</string>
416
+ <key>ProgramArguments</key>
417
+ <array>
418
+ <string>${escapeXml(nodePath)}</string>
419
+ <string>${escapeXml(daemonEntry)}</string>
420
+ </array>
421
+ <key>RunAtLoad</key>
422
+ <true/>
423
+ <key>KeepAlive</key>
424
+ <true/>
425
+ <key>StandardOutPath</key>
426
+ <string>${escapeXml(LOG_PATH)}</string>
427
+ <key>StandardErrorPath</key>
428
+ <string>${escapeXml(LOG_PATH)}</string>
429
+ <key>EnvironmentVariables</key>
430
+ <dict>
431
+ ${envEntries}
432
+ </dict>
433
+ </dict>
434
+ </plist>
435
+ `;
436
+ }
437
+ function escapeXml(s) {
438
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
439
+ }
440
+ function writePlist(contents) {
441
+ mkdirSync(dirname(PLIST_PATH), { recursive: true });
442
+ writeFileSync(PLIST_PATH, contents);
443
+ }
444
+ function bootstrapPlist() {
445
+ const domain = guiDomain();
446
+ try {
447
+ execFileSync("launchctl", ["bootout", domain, PLIST_PATH], { stdio: "ignore" });
448
+ } catch {
449
+ }
450
+ try {
451
+ execFileSync("launchctl", ["bootstrap", domain, PLIST_PATH], { stdio: "inherit" });
452
+ } catch (e) {
453
+ throw new Error(
454
+ `launchctl bootstrap failed. Check Console.app (filter: com.lens.daemon) for details.
455
+ ${e.message}`
456
+ );
457
+ }
458
+ }
459
+ function unloadPlist() {
460
+ const domain = guiDomain();
461
+ try {
462
+ execFileSync("launchctl", ["bootout", domain, PLIST_PATH], { stdio: "ignore" });
463
+ } catch {
464
+ }
465
+ if (existsSync(PLIST_PATH)) unlinkSync(PLIST_PATH);
466
+ }
467
+ function plistStatus() {
468
+ const installed = existsSync(PLIST_PATH);
469
+ let loaded = false;
470
+ let pid = null;
471
+ let lastExit = null;
472
+ try {
473
+ const out = execFileSync("launchctl", ["list"], { encoding: "utf-8" });
474
+ for (const line of out.split("\n")) {
475
+ const parts = line.split(/\s+/);
476
+ if (parts[2] === PLIST_LABEL) {
477
+ loaded = true;
478
+ const pidParsed = parseInt(parts[0], 10);
479
+ const exitParsed = parseInt(parts[1], 10);
480
+ pid = Number.isNaN(pidParsed) ? null : pidParsed;
481
+ lastExit = Number.isNaN(exitParsed) ? null : exitParsed;
482
+ break;
483
+ }
484
+ }
485
+ } catch {
486
+ }
487
+ return { installed, loaded, pid, lastExit };
488
+ }
489
+ function readPlist() {
490
+ try {
491
+ return readFileSync(PLIST_PATH, "utf-8");
492
+ } catch {
493
+ return null;
494
+ }
495
+ }
496
+
497
+ // packages/cli/src/commands/daemon-install.ts
379
498
  function findDaemonEntry() {
380
499
  const self = fileURLToPath(import.meta.url);
381
- const sibling = join(dirname(self), "daemon.js");
382
- if (existsSync(sibling)) return sibling;
500
+ const sibling = join2(dirname2(self), "daemon.js");
501
+ if (existsSync2(sibling)) return sibling;
383
502
  const req = createRequire(import.meta.url);
384
503
  return req.resolve("@lens/daemon");
385
504
  }
505
+ var install = defineCommand({
506
+ meta: {
507
+ description: "Install LENS daemon as a launchd service (auto-start at login, auto-restart on crash/sleep)."
508
+ },
509
+ async run() {
510
+ if (!isMacOS()) {
511
+ console.error("`lens daemon install` is macOS-only. On Linux use systemd; on Windows use Task Scheduler.");
512
+ process.exit(1);
513
+ }
514
+ const daemonEntry = findDaemonEntry();
515
+ const nodePath = process.execPath;
516
+ const plist = renderPlist({ nodePath, daemonEntry });
517
+ const existing = readPlist();
518
+ if (existing === plist) {
519
+ console.log(`Plist already up to date: ${PLIST_PATH}`);
520
+ } else {
521
+ writePlist(plist);
522
+ console.log(`Wrote plist: ${PLIST_PATH}`);
523
+ }
524
+ bootstrapPlist();
525
+ const status2 = plistStatus();
526
+ if (status2.loaded) {
527
+ console.log(`Loaded (PID ${status2.pid ?? "starting"}).`);
528
+ console.log("RunAtLoad=true, KeepAlive=true.");
529
+ console.log("Daemon will survive logout, reboot, sleep/wake, and crashes.");
530
+ } else {
531
+ console.error("launchctl bootstrap reported success but service not visible. Check Console.app for errors.");
532
+ process.exit(1);
533
+ }
534
+ }
535
+ });
536
+ var uninstall = defineCommand({
537
+ meta: { description: "Remove the LENS launchd service." },
538
+ async run() {
539
+ if (!isMacOS()) {
540
+ console.error("`lens daemon uninstall` is macOS-only.");
541
+ process.exit(1);
542
+ }
543
+ unloadPlist();
544
+ console.log("LENS daemon launchd service removed.");
545
+ }
546
+ });
547
+ var statusCmd = defineCommand({
548
+ meta: { description: "Show LENS daemon launchd status." },
549
+ async run() {
550
+ if (!isMacOS()) {
551
+ console.error("`lens daemon status` shows launchd state (macOS-only).");
552
+ process.exit(1);
553
+ }
554
+ const s = plistStatus();
555
+ console.log(`plist: ${s.installed ? PLIST_PATH : "(not installed)"}`);
556
+ console.log(`loaded: ${s.loaded}`);
557
+ console.log(`pid: ${s.pid ?? "(none)"}`);
558
+ console.log(`lastExit: ${s.lastExit ?? "(none)"}`);
559
+ if (s.installed && !s.loaded) {
560
+ console.log("\nHint: run `lens daemon install` to load it.");
561
+ }
562
+ }
563
+ });
564
+
565
+ // packages/cli/src/commands/daemon.ts
566
+ var DATA_DIR = join3(homedir2(), ".lens");
567
+ var PID_FILE = join3(DATA_DIR, "daemon.pid");
568
+ var LOG_FILE = join3(DATA_DIR, "daemon.log");
569
+ function findDaemonEntry2() {
570
+ const self = fileURLToPath2(import.meta.url);
571
+ const sibling = join3(dirname3(self), "daemon.js");
572
+ if (existsSync3(sibling)) return sibling;
573
+ const req = createRequire2(import.meta.url);
574
+ return req.resolve("@lens/daemon");
575
+ }
386
576
  function isRunning(pid) {
387
577
  try {
388
578
  process.kill(pid, 0);
@@ -393,7 +583,7 @@ function isRunning(pid) {
393
583
  }
394
584
  function readPid() {
395
585
  try {
396
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
586
+ const pid = parseInt(readFileSync2(PID_FILE, "utf-8").trim(), 10);
397
587
  return Number.isNaN(pid) ? null : isRunning(pid) ? pid : null;
398
588
  } catch {
399
589
  return null;
@@ -414,17 +604,17 @@ var start = defineCommand({
414
604
  console.log(`Daemon already running (PID ${existing})`);
415
605
  return;
416
606
  }
417
- mkdirSync(DATA_DIR, { recursive: true });
418
- const daemonEntry = findDaemonEntry();
607
+ mkdirSync2(DATA_DIR, { recursive: true });
608
+ const daemonEntry = findDaemonEntry2();
419
609
  if (args.foreground) {
420
610
  const child2 = spawn("node", [daemonEntry], {
421
611
  stdio: "inherit",
422
612
  env: { ...process.env, LENS_DATA_DIR: DATA_DIR }
423
613
  });
424
- writeFileSync(PID_FILE, String(child2.pid));
614
+ writeFileSync2(PID_FILE, String(child2.pid));
425
615
  child2.on("exit", (code) => {
426
616
  try {
427
- unlinkSync(PID_FILE);
617
+ unlinkSync2(PID_FILE);
428
618
  } catch {
429
619
  }
430
620
  process.exit(code ?? 1);
@@ -438,7 +628,7 @@ var start = defineCommand({
438
628
  env: { ...process.env, LENS_DATA_DIR: DATA_DIR }
439
629
  });
440
630
  child.unref();
441
- writeFileSync(PID_FILE, String(child.pid));
631
+ writeFileSync2(PID_FILE, String(child.pid));
442
632
  const ok = await waitForHealth(1500);
443
633
  if (ok) {
444
634
  console.log(`Daemon started (PID ${child.pid})`);
@@ -473,7 +663,7 @@ var stop = defineCommand({
473
663
  }
474
664
  process.kill(pid, "SIGTERM");
475
665
  try {
476
- unlinkSync(PID_FILE);
666
+ unlinkSync2(PID_FILE);
477
667
  } catch {
478
668
  }
479
669
  console.log(`Daemon stopped (PID ${pid})`);
@@ -481,7 +671,7 @@ var stop = defineCommand({
481
671
  });
482
672
  var daemon = defineCommand({
483
673
  meta: { description: "Manage the LENS daemon process." },
484
- subCommands: { start, stop }
674
+ subCommands: { start, stop, install, uninstall, status: statusCmd }
485
675
  });
486
676
 
487
677
  // packages/cli/src/commands/dashboard.ts
@@ -727,12 +917,66 @@ var list = defineCommand({
727
917
  }
728
918
  });
729
919
 
920
+ // packages/cli/src/commands/pattern.ts
921
+ var pattern = defineCommand({
922
+ meta: {
923
+ description: "Structural AST search via ast-grep pattern."
924
+ },
925
+ args: {
926
+ pattern: {
927
+ type: "positional",
928
+ required: true,
929
+ description: 'ast-grep pattern, e.g. "function $N($$$) { $$$ }"'
930
+ },
931
+ lang: {
932
+ type: "string",
933
+ alias: "l",
934
+ default: "typescript",
935
+ description: "Source language: typescript | tsx | javascript | csharp"
936
+ },
937
+ repo: {
938
+ type: "string",
939
+ alias: "r",
940
+ description: "Repo root path (defaults to cwd)"
941
+ },
942
+ limit: {
943
+ type: "string",
944
+ default: "50",
945
+ description: "Max matches"
946
+ },
947
+ json: {
948
+ type: "boolean",
949
+ description: "Output full JSON instead of compact text"
950
+ }
951
+ },
952
+ async run({ args }) {
953
+ const repoPath = args.repo ?? process.cwd();
954
+ const format = args.json ? "json" : "text";
955
+ const res = await daemonFetch("/pattern", {
956
+ method: "POST",
957
+ headers: { "Content-Type": "application/json" },
958
+ body: JSON.stringify({
959
+ repoPath,
960
+ pattern: args.pattern,
961
+ language: args.lang,
962
+ limit: parseInt(args.limit, 10),
963
+ format
964
+ })
965
+ });
966
+ if (format === "json") {
967
+ console.log(JSON.stringify(await res.json(), null, 2));
968
+ } else {
969
+ process.stdout.write(await res.text());
970
+ }
971
+ }
972
+ });
973
+
730
974
  // packages/cli/src/commands/register.ts
731
975
  import { resolve } from "path";
732
976
 
733
977
  // packages/cli/src/lib/inject-instructions.ts
734
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
735
- import { dirname as dirname2, join as join2 } from "path";
978
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
979
+ import { dirname as dirname4, join as join4 } from "path";
736
980
  var MARKER = "<!-- lens-context -->";
737
981
  var CANDIDATE_FILES = [
738
982
  "CLAUDE.md",
@@ -750,6 +994,7 @@ This repo is indexed by LENS. **Prefer LENS MCP tools over built-in Grep/Glob**
750
994
  - \`lens_grep\` \u2014 Ranked search with import graph, co-change, hub scores. One call replaces multiple Grep+Read cycles.
751
995
  - \`lens_graph\` \u2014 Dependency map. Architecture, module relationships, change impact.
752
996
  - \`lens_graph_neighbors\` \u2014 Blast radius for a file. Importers, co-change partners, what breaks.
997
+ - \`lens_pattern\` \u2014 Structural AST search via ast-grep. Pass a pattern with \`$NAME\` (single capture) or \`$$$\` (any sequence). Language: typescript | tsx | javascript | csharp. Use AFTER \`lens_grep\` when you know the exact AST shape. Example: \`class $C : IController { $$$ }\`.
753
998
 
754
999
  Load tools via \`ToolSearch\` before first use.
755
1000
  `;
@@ -757,14 +1002,14 @@ function injectInstructions(repoRoot) {
757
1002
  const results = [];
758
1003
  const found = [];
759
1004
  for (const file of CANDIDATE_FILES) {
760
- const fullPath = join2(repoRoot, file);
761
- if (existsSync2(fullPath)) {
1005
+ const fullPath = join4(repoRoot, file);
1006
+ if (existsSync4(fullPath)) {
762
1007
  found.push(file);
763
- const content = readFileSync2(fullPath, "utf-8");
1008
+ const content = readFileSync3(fullPath, "utf-8");
764
1009
  if (content.includes(MARKER)) {
765
1010
  results.push({ file, action: "skipped" });
766
1011
  } else {
767
- writeFileSync2(fullPath, `${TEMPLATE}
1012
+ writeFileSync3(fullPath, `${TEMPLATE}
768
1013
  ${content}`);
769
1014
  results.push({ file, action: "injected" });
770
1015
  }
@@ -772,10 +1017,10 @@ ${content}`);
772
1017
  }
773
1018
  if (found.length === 0) {
774
1019
  for (const file of ["CLAUDE.md", "AGENTS.md"]) {
775
- const fullPath = join2(repoRoot, file);
776
- const dir = dirname2(fullPath);
777
- if (!existsSync2(dir)) mkdirSync2(dir, { recursive: true });
778
- writeFileSync2(fullPath, `${TEMPLATE}
1020
+ const fullPath = join4(repoRoot, file);
1021
+ const dir = dirname4(fullPath);
1022
+ if (!existsSync4(dir)) mkdirSync3(dir, { recursive: true });
1023
+ writeFileSync3(fullPath, `${TEMPLATE}
779
1024
  `);
780
1025
  results.push({ file, action: "created" });
781
1026
  }
@@ -784,24 +1029,24 @@ ${content}`);
784
1029
  }
785
1030
 
786
1031
  // packages/cli/src/lib/inject-mcp.ts
787
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
788
- import { join as join3 } from "path";
1032
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1033
+ import { join as join5 } from "path";
789
1034
  var LENS_MCP_ENTRY = {
790
1035
  type: "http",
791
1036
  url: "http://localhost:4111/mcp"
792
1037
  };
793
1038
  function injectMcp(repoRoot) {
794
- const mcpPath = join3(repoRoot, ".mcp.json");
795
- if (!existsSync3(mcpPath)) {
796
- writeFileSync3(mcpPath, `${JSON.stringify({ mcpServers: { lens: LENS_MCP_ENTRY } }, null, 2)}
1039
+ const mcpPath = join5(repoRoot, ".mcp.json");
1040
+ if (!existsSync5(mcpPath)) {
1041
+ writeFileSync4(mcpPath, `${JSON.stringify({ mcpServers: { lens: LENS_MCP_ENTRY } }, null, 2)}
797
1042
  `);
798
1043
  return "created";
799
1044
  }
800
- const raw = readFileSync3(mcpPath, "utf-8");
1045
+ const raw = readFileSync4(mcpPath, "utf-8");
801
1046
  const config = JSON.parse(raw);
802
1047
  if (config.mcpServers?.lens) return "exists";
803
1048
  config.mcpServers = { ...config.mcpServers, lens: LENS_MCP_ENTRY };
804
- writeFileSync3(mcpPath, `${JSON.stringify(config, null, 2)}
1049
+ writeFileSync4(mcpPath, `${JSON.stringify(config, null, 2)}
805
1050
  `);
806
1051
  return "updated";
807
1052
  }
@@ -835,11 +1080,24 @@ var register = defineCommand({
835
1080
  },
836
1081
  async run({ args }) {
837
1082
  const absPath = resolve(args.path);
838
- const res = await daemonFetch("/repos", {
839
- method: "POST",
840
- headers: { "Content-Type": "application/json" },
841
- body: JSON.stringify({ path: absPath, name: args.name })
842
- });
1083
+ let res;
1084
+ try {
1085
+ res = await daemonFetch("/repos", {
1086
+ method: "POST",
1087
+ headers: { "Content-Type": "application/json" },
1088
+ body: JSON.stringify({ path: absPath, name: args.name })
1089
+ });
1090
+ } catch (err) {
1091
+ const msg = err instanceof Error ? err.message : String(err);
1092
+ if (msg.includes("Not a git repository")) {
1093
+ console.error(`
1094
+ \u2717 ${absPath} is not a git repository.
1095
+ LENS requires a git repo to index.
1096
+ `);
1097
+ process.exit(1);
1098
+ }
1099
+ throw err;
1100
+ }
843
1101
  const repo = await res.json();
844
1102
  console.log();
845
1103
  console.log(` ${bold2("\u26A1 LENS")} ${dim(repo.name)}`);
@@ -941,7 +1199,8 @@ var main = defineCommand({
941
1199
  remove,
942
1200
  list,
943
1201
  grep,
944
- graph
1202
+ graph,
1203
+ pattern
945
1204
  }
946
1205
  });
947
1206
  runMain(main);