pi-lens 3.6.7 → 3.7.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.
@@ -17,10 +17,16 @@ import { safeSpawn } from "./safe-spawn.js";
17
17
 
18
18
  export interface FormatterInfo {
19
19
  name: string;
20
- command: string[]; // Command with $FILE placeholder
20
+ command: string[]; // Command with $FILE placeholder — used as fallback
21
21
  extensions: string[];
22
22
  /** Detect if this formatter should be used for a project */
23
23
  detect(cwd: string): Promise<boolean>;
24
+ /**
25
+ * Optionally resolve the full command at runtime (venv, vendor/bin, bundle exec).
26
+ * Return null to fall back to the static `command` field.
27
+ * filePath is already resolved to an absolute path.
28
+ */
29
+ resolveCommand?(filePath: string, cwd: string): Promise<string[] | null>;
24
30
  }
25
31
 
26
32
  export interface FormatterResult {
@@ -82,11 +88,107 @@ async function which(command: string): Promise<string | null> {
82
88
  return result.stdout?.trim().split("\n")[0] ?? null;
83
89
  }
84
90
 
91
+ // --- Venv / Local Binary Helpers ---
92
+
93
+ /**
94
+ * Walk up from cwd looking for a binary in .venv or venv.
95
+ * Returns the absolute path if found, null otherwise.
96
+ */
97
+ async function findInVenv(binary: string, cwd: string): Promise<string | null> {
98
+ const isWin = process.platform === "win32";
99
+ const candidates = isWin
100
+ ? [
101
+ `.venv/Scripts/${binary}.exe`,
102
+ `venv/Scripts/${binary}.exe`,
103
+ `.venv/Scripts/${binary}`,
104
+ `venv/Scripts/${binary}`,
105
+ ]
106
+ : [`.venv/bin/${binary}`, `venv/bin/${binary}`];
107
+
108
+ let dir = cwd;
109
+ const root = path.parse(dir).root;
110
+ while (dir !== root) {
111
+ for (const candidate of candidates) {
112
+ const full = path.join(dir, candidate);
113
+ if (await fileExists(full)) return full;
114
+ }
115
+ const parent = path.dirname(dir);
116
+ if (parent === dir) break;
117
+ dir = parent;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Check vendor/bin for PHP Composer-managed tools.
124
+ * Walks up from cwd to find vendor/bin/<binary>.
125
+ */
126
+ async function findInVendorBin(
127
+ binary: string,
128
+ cwd: string,
129
+ ): Promise<string | null> {
130
+ const isWin = process.platform === "win32";
131
+ const name = isWin ? `${binary}.bat` : binary;
132
+ let dir = cwd;
133
+ const root = path.parse(dir).root;
134
+ while (dir !== root) {
135
+ const full = path.join(dir, "vendor", "bin", name);
136
+ if (await fileExists(full)) return full;
137
+ const parent = path.dirname(dir);
138
+ if (parent === dir) break;
139
+ dir = parent;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Check node_modules/.bin for locally installed Node tools.
146
+ * Walks up from cwd to find node_modules/.bin/<binary>.
147
+ */
148
+ async function findInNodeModules(
149
+ binary: string,
150
+ cwd: string,
151
+ ): Promise<string | null> {
152
+ const isWin = process.platform === "win32";
153
+ let dir = cwd;
154
+ const root = path.parse(dir).root;
155
+ while (dir !== root) {
156
+ const candidates = isWin
157
+ ? [
158
+ path.join(dir, "node_modules", ".bin", `${binary}.cmd`),
159
+ path.join(dir, "node_modules", ".bin", binary),
160
+ ]
161
+ : [path.join(dir, "node_modules", ".bin", binary)];
162
+ for (const full of candidates) {
163
+ if (await fileExists(full)) return full;
164
+ }
165
+ const parent = path.dirname(dir);
166
+ if (parent === dir) break;
167
+ dir = parent;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Returns true if `bundle exec <gem>` should be used:
174
+ * bundle binary is available AND Gemfile.lock exists in the tree.
175
+ */
176
+ async function canUseBundleExec(cwd: string): Promise<boolean> {
177
+ if ((await which("bundle")) === null) return false;
178
+ const lockfiles = await findUp(["Gemfile.lock"], cwd);
179
+ return lockfiles.length > 0;
180
+ }
181
+
85
182
  // --- Formatter Definitions ---
86
183
 
87
184
  export const biomeFormatter: FormatterInfo = {
88
185
  name: "biome",
89
186
  command: ["npx", "@biomejs/biome", "format", "--write", "$FILE"],
187
+ async resolveCommand(filePath, cwd) {
188
+ const local = await findInNodeModules("biome", cwd);
189
+ if (local) return [local, "format", "--write", filePath];
190
+ return null;
191
+ },
90
192
  extensions: [
91
193
  ".js",
92
194
  ".jsx",
@@ -111,10 +213,10 @@ export const biomeFormatter: FormatterInfo = {
111
213
  const found = await findUp(configs, cwd);
112
214
  if (found.length > 0) return true;
113
215
 
114
- // Check if biome is in package.json devDependencies
115
- const pkgPath = path.join(cwd, "package.json");
116
- if (await fileExists(pkgPath)) {
117
- const pkg = (await readJson(pkgPath)) as {
216
+ // Check if biome is in the nearest package.json devDependencies
217
+ const pkgPaths = await findUp(["package.json"], cwd);
218
+ if (pkgPaths.length > 0) {
219
+ const pkg = (await readJson(pkgPaths[0])) as {
118
220
  devDependencies?: Record<string, string>;
119
221
  };
120
222
  if (pkg.devDependencies?.["@biomejs/biome"]) return true;
@@ -127,6 +229,11 @@ export const biomeFormatter: FormatterInfo = {
127
229
  export const prettierFormatter: FormatterInfo = {
128
230
  name: "prettier",
129
231
  command: ["npx", "prettier", "--write", "$FILE"],
232
+ async resolveCommand(filePath, cwd) {
233
+ const local = await findInNodeModules("prettier", cwd);
234
+ if (local) return [local, "--write", filePath];
235
+ return null;
236
+ },
130
237
  extensions: [
131
238
  ".js",
132
239
  ".jsx",
@@ -168,10 +275,10 @@ export const prettierFormatter: FormatterInfo = {
168
275
  const found = await findUp(configs, cwd);
169
276
  if (found.length > 0) return true;
170
277
 
171
- // Check package.json
172
- const pkgPath = path.join(cwd, "package.json");
173
- if (await fileExists(pkgPath)) {
174
- const pkg = (await readJson(pkgPath)) as {
278
+ // Check the nearest package.json for prettier
279
+ const pkgPaths = await findUp(["package.json"], cwd);
280
+ if (pkgPaths.length > 0) {
281
+ const pkg = (await readJson(pkgPaths[0])) as {
175
282
  devDependencies?: Record<string, string>;
176
283
  dependencies?: Record<string, string>;
177
284
  prettier?: unknown;
@@ -191,6 +298,11 @@ export const ruffFormatter: FormatterInfo = {
191
298
  name: "ruff",
192
299
  command: ["ruff", "format", "$FILE"],
193
300
  extensions: [".py", ".pyi"],
301
+ async resolveCommand(filePath, cwd) {
302
+ const venv = await findInVenv("ruff", cwd);
303
+ if (venv) return [venv, "format", filePath];
304
+ return null;
305
+ },
194
306
  async detect(cwd: string) {
195
307
  // Check for ruff config
196
308
  const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"];
@@ -226,6 +338,11 @@ export const blackFormatter: FormatterInfo = {
226
338
  name: "black",
227
339
  command: ["black", "$FILE"],
228
340
  extensions: [".py", ".pyi"],
341
+ async resolveCommand(filePath, cwd) {
342
+ const venv = await findInVenv("black", cwd);
343
+ if (venv) return [venv, filePath];
344
+ return null;
345
+ },
229
346
  async detect(cwd: string) {
230
347
  // Check for black config in pyproject.toml
231
348
  const configs = ["pyproject.toml"];
@@ -351,6 +468,11 @@ export const rubocopFormatter: FormatterInfo = {
351
468
  name: "rubocop",
352
469
  command: ["rubocop", "-a", "--no-color", "$FILE"],
353
470
  extensions: [".rb", ".rake", ".gemspec", ".ru"],
471
+ async resolveCommand(filePath, cwd) {
472
+ if (await canUseBundleExec(cwd))
473
+ return ["bundle", "exec", "rubocop", "-a", "--no-color", filePath];
474
+ return null;
475
+ },
354
476
  async detect(cwd: string) {
355
477
  // Only run if project has explicit RuboCop config
356
478
  const configs = [".rubocop.yml", ".rubocop.yaml"];
@@ -370,6 +492,11 @@ export const standardrbFormatter: FormatterInfo = {
370
492
  name: "standardrb",
371
493
  command: ["standardrb", "--fix", "$FILE"],
372
494
  extensions: [".rb", ".rake"],
495
+ async resolveCommand(filePath, cwd) {
496
+ if (await canUseBundleExec(cwd))
497
+ return ["bundle", "exec", "standardrb", "--fix", filePath];
498
+ return null;
499
+ },
373
500
  async detect(cwd: string) {
374
501
  // standardrb is only used if explicitly in Gemfile (no config file — it is the config)
375
502
  const gemfile = path.join(cwd, "Gemfile");
@@ -403,6 +530,80 @@ export const terraformFormatter: FormatterInfo = {
403
530
  },
404
531
  };
405
532
 
533
+ export const phpCsFixerFormatter: FormatterInfo = {
534
+ name: "php-cs-fixer",
535
+ command: ["php-cs-fixer", "fix", "$FILE"],
536
+ extensions: [".php"],
537
+ async resolveCommand(filePath, cwd) {
538
+ const vendor = await findInVendorBin("php-cs-fixer", cwd);
539
+ if (vendor) return [vendor, "fix", filePath];
540
+ return null;
541
+ },
542
+ async detect(cwd: string) {
543
+ const vendorBin = await findInVendorBin("php-cs-fixer", cwd);
544
+ const globalBin = await which("php-cs-fixer");
545
+ if (!vendorBin && !globalBin) return false;
546
+ // Only run if project has explicit config
547
+ const configs = [".php-cs-fixer.php", ".php-cs-fixer.dist.php"];
548
+ const found = await findUp(configs, cwd);
549
+ return found.length > 0;
550
+ },
551
+ };
552
+
553
+ export const csharpierFormatter: FormatterInfo = {
554
+ name: "csharpier",
555
+ command: ["dotnet", "csharpier", "$FILE"],
556
+ extensions: [".cs"],
557
+ async detect(_cwd: string) {
558
+ // Check dotnet is available AND csharpier tool is installed
559
+ if ((await which("dotnet")) === null) return false;
560
+ const result = safeSpawn("dotnet", ["csharpier", "--version"], {
561
+ timeout: 5000,
562
+ });
563
+ return !result.error && result.status === 0;
564
+ },
565
+ };
566
+
567
+ export const fantomasFormatter: FormatterInfo = {
568
+ name: "fantomas",
569
+ command: ["fantomas", "$FILE"],
570
+ extensions: [".fs", ".fsi", ".fsx"],
571
+ async detect(_cwd: string) {
572
+ return (await which("fantomas")) !== null;
573
+ },
574
+ };
575
+
576
+ export const swiftformatFormatter: FormatterInfo = {
577
+ name: "swiftformat",
578
+ command: ["swiftformat", "$FILE"],
579
+ extensions: [".swift"],
580
+ async detect(_cwd: string) {
581
+ return (await which("swiftformat")) !== null;
582
+ },
583
+ };
584
+
585
+ export const styluaFormatter: FormatterInfo = {
586
+ name: "stylua",
587
+ command: ["stylua", "$FILE"],
588
+ extensions: [".lua"],
589
+ async detect(cwd: string) {
590
+ if ((await which("stylua")) === null) return false;
591
+ // Prefer explicit config but also run if binary is present in a Lua project
592
+ const configs = ["stylua.toml", ".stylua.toml"];
593
+ const found = await findUp(configs, cwd);
594
+ return found.length > 0;
595
+ },
596
+ };
597
+
598
+ export const ormoluFormatter: FormatterInfo = {
599
+ name: "ormolu",
600
+ command: ["ormolu", "--mode", "inplace", "$FILE"],
601
+ extensions: [".hs", ".lhs"],
602
+ async detect(_cwd: string) {
603
+ return (await which("ormolu")) !== null;
604
+ },
605
+ };
606
+
406
607
  // --- Registry ---
407
608
 
408
609
  const ALL_FORMATTERS: FormatterInfo[] = [
@@ -421,6 +622,12 @@ const ALL_FORMATTERS: FormatterInfo[] = [
421
622
  clangFormatFormatter,
422
623
  ktlintFormatter,
423
624
  terraformFormatter,
625
+ phpCsFixerFormatter,
626
+ csharpierFormatter,
627
+ fantomasFormatter,
628
+ swiftformatFormatter,
629
+ styluaFormatter,
630
+ ormoluFormatter,
424
631
  rubocopFormatter,
425
632
  standardrbFormatter,
426
633
  gleamFormatter,
@@ -514,19 +721,13 @@ export async function formatFile(
514
721
  const cwd = path.dirname(absolutePath);
515
722
  const contentBefore = await fs.readFile(absolutePath, "utf-8");
516
723
 
517
- // Replace $FILE placeholder
518
- let cmd = formatter.command.map((c) => c.replace("$FILE", absolutePath));
519
-
520
- // OPTIMIZATION: Use local biome binary instead of npx if available
521
- if (formatter.name === "biome" && cmd[0] === "npx") {
522
- const localBiome = path.join(cwd, "node_modules", ".bin", "biome");
523
- const localBiomeWin = path.join(cwd, "node_modules", ".bin", "biome.cmd");
524
- if (await fileExists(localBiome)) {
525
- cmd = [localBiome, ...cmd.slice(2)]; // Replace "npx" "@biomejs/biome" with local path
526
- } else if (await fileExists(localBiomeWin)) {
527
- cmd = [localBiomeWin, ...cmd.slice(2)];
528
- }
529
- }
724
+ // Resolve command: prefer local (venv/vendor/node_modules) over global
725
+ const resolved = formatter.resolveCommand
726
+ ? await formatter.resolveCommand(absolutePath, cwd)
727
+ : null;
728
+ const cmd =
729
+ resolved ??
730
+ formatter.command.map((c) => c.replace("$FILE", absolutePath));
530
731
 
531
732
  // Run formatter
532
733
  const result = safeSpawn(cmd[0], cmd.slice(1), { timeout: 15000, cwd });
@@ -88,6 +88,15 @@ const TOOLS: ToolDefinition[] = [
88
88
  binaryName: "pyright",
89
89
  },
90
90
  // Linting/formatting tools
91
+ {
92
+ id: "prettier",
93
+ name: "Prettier",
94
+ checkCommand: "prettier",
95
+ checkArgs: ["--version"],
96
+ installStrategy: "npm",
97
+ packageName: "prettier",
98
+ binaryName: "prettier",
99
+ },
91
100
  {
92
101
  id: "ruff",
93
102
  name: "Ruff",
@@ -366,7 +375,9 @@ async function installNpmTool(
366
375
  proc.on("error", (err) => reject(err));
367
376
  });
368
377
  } catch (err) {
369
- console.error(`[auto-install] Failed to install ${packageName}: ${(err as Error).message}`);
378
+ console.error(
379
+ `[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
380
+ );
370
381
  debugLog("Full error:", err);
371
382
  return undefined;
372
383
  }
@@ -400,7 +411,9 @@ async function installPipTool(
400
411
  proc.on("error", (err) => reject(err));
401
412
  });
402
413
  } catch (err) {
403
- console.error(`[auto-install] Failed to install ${packageName}: ${(err as Error).message}`);
414
+ console.error(
415
+ `[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
416
+ );
404
417
  debugLog("Full error:", err);
405
418
  return undefined;
406
419
  }
@@ -439,7 +452,9 @@ export async function installTool(toolId: string): Promise<boolean> {
439
452
  return false;
440
453
  }
441
454
  } catch (err) {
442
- console.error(`[auto-install] Failed to install ${tool.name}: ${(err as Error).message}`);
455
+ console.error(
456
+ `[auto-install] Failed to install ${tool.name}: ${(err as Error).message}`,
457
+ );
443
458
  debugLog("Full error:", err);
444
459
  return false;
445
460
  }
@@ -13,7 +13,10 @@ try {
13
13
 
14
14
  export interface LatencyEntry {
15
15
  type: "runner" | "tool_result" | "phase";
16
+ /** ISO timestamp when this entry was written (= finish time for runners) */
16
17
  ts?: string;
18
+ /** ISO timestamp when the runner/phase started — diff with ts = durationMs */
19
+ startedAt?: string;
17
20
  toolName?: string;
18
21
  filePath: string;
19
22
  fullPath?: string;
@@ -25,6 +28,12 @@ export interface LatencyEntry {
25
28
  status?: string;
26
29
  diagnosticCount?: number;
27
30
  semantic?: string;
31
+ /** For dispatch_complete: actual wall-clock time (groups run in parallel) */
32
+ wallClockMs?: number;
33
+ /** For dispatch_complete: sum of all individual runner durationMs */
34
+ sumMs?: number;
35
+ /** wallClockMs - sumMs ≥ 0 means parallelism saved this many ms */
36
+ parallelGainMs?: number;
28
37
  metadata?: Record<string, unknown>;
29
38
  }
30
39
 
@@ -14,45 +14,173 @@ import { spawn } from "node:child_process";
14
14
  import * as fs from "node:fs/promises";
15
15
  import * as path from "node:path";
16
16
 
17
+ /**
18
+ * Install strategy:
19
+ * - "npm": npm install -g <packageName> (managed by pi-lens, goes into .pi-lens/tools)
20
+ * - "shell": run installCommand verbatim in a shell (gem, dotnet, brew, etc.)
21
+ * - "manual": can't auto-install — show installCommand and tell the user to run it
22
+ */
23
+ type InstallStrategy = "npm" | "shell" | "manual";
24
+
25
+ interface LanguageConfig {
26
+ toolId: string;
27
+ toolName: string;
28
+ /** Shown to user and used as the shell command for "shell" strategy */
29
+ installCommand: string;
30
+ /** npm package name — required for "npm" strategy */
31
+ packageName?: string;
32
+ installStrategy: InstallStrategy;
33
+ }
34
+
17
35
  // Languages that support interactive auto-install prompt
18
- const COMMON_LANGUAGES: Record<
19
- string,
20
- {
21
- toolId: string;
22
- toolName: string;
23
- installCommand: string;
24
- packageName: string;
25
- }
26
- > = {
36
+ const COMMON_LANGUAGES: Record<string, LanguageConfig> = {
37
+ // --- Originally supported ---
27
38
  go: {
28
39
  toolId: "gopls",
29
40
  toolName: "Go Language Server (gopls)",
30
41
  installCommand: "go install golang.org/x/tools/gopls@latest",
31
- packageName: "golang.org/x/tools/gopls",
42
+ installStrategy: "shell",
32
43
  },
33
44
  rust: {
34
45
  toolId: "rust-analyzer",
35
46
  toolName: "Rust Language Server (rust-analyzer)",
36
47
  installCommand: "rustup component add rust-analyzer",
37
- packageName: "rust-analyzer",
48
+ installStrategy: "shell",
38
49
  },
39
50
  yaml: {
40
51
  toolId: "yaml-language-server",
41
52
  toolName: "YAML Language Server",
42
53
  installCommand: "npm install -g yaml-language-server",
43
54
  packageName: "yaml-language-server",
55
+ installStrategy: "npm",
44
56
  },
45
57
  json: {
46
58
  toolId: "vscode-json-language-server",
47
59
  toolName: "JSON Language Server",
48
60
  installCommand: "npm install -g vscode-langservers-extracted",
49
61
  packageName: "vscode-langservers-extracted",
62
+ installStrategy: "npm",
50
63
  },
51
64
  bash: {
52
65
  toolId: "bash-language-server",
53
66
  toolName: "Bash Language Server",
54
67
  installCommand: "npm install -g bash-language-server",
55
68
  packageName: "bash-language-server",
69
+ installStrategy: "npm",
70
+ },
71
+ // --- Tier-4: previously silent on ENOENT ---
72
+ ruby: {
73
+ toolId: "ruby-lsp",
74
+ toolName: "Ruby LSP",
75
+ installCommand: "gem install ruby-lsp",
76
+ installStrategy: "shell",
77
+ },
78
+ php: {
79
+ toolId: "intelephense",
80
+ toolName: "PHP Language Server (Intelephense)",
81
+ installCommand: "npm install -g intelephense",
82
+ packageName: "intelephense",
83
+ installStrategy: "npm",
84
+ },
85
+ csharp: {
86
+ toolId: "csharp-ls",
87
+ toolName: "C# Language Server (csharp-ls)",
88
+ installCommand: "dotnet tool install -g csharp-ls",
89
+ installStrategy: "shell",
90
+ },
91
+ fsharp: {
92
+ toolId: "fsautocomplete",
93
+ toolName: "F# Language Server (FSAutocomplete)",
94
+ installCommand: "dotnet tool install -g fsautocomplete",
95
+ installStrategy: "shell",
96
+ },
97
+ java: {
98
+ toolId: "jdtls",
99
+ toolName: "Java Language Server (Eclipse JDT LS)",
100
+ installCommand:
101
+ "brew install jdtls # or: https://github.com/eclipse-jdtls/eclipse.jdt.ls",
102
+ installStrategy: "manual",
103
+ },
104
+ kotlin: {
105
+ toolId: "kotlin-language-server",
106
+ toolName: "Kotlin Language Server",
107
+ installCommand:
108
+ "brew install kotlin-language-server # or: https://github.com/fwcd/kotlin-language-server",
109
+ installStrategy: "manual",
110
+ },
111
+ swift: {
112
+ toolId: "sourcekit-lsp",
113
+ toolName: "Swift Language Server (SourceKit-LSP)",
114
+ installCommand:
115
+ "xcode-select --install # bundled with Xcode / Swift toolchain",
116
+ installStrategy: "manual",
117
+ },
118
+ dart: {
119
+ toolId: "dart",
120
+ toolName: "Dart Language Server",
121
+ installCommand: "# Install Dart SDK: https://dart.dev/get-dart",
122
+ installStrategy: "manual",
123
+ },
124
+ lua: {
125
+ toolId: "lua-language-server",
126
+ toolName: "Lua Language Server",
127
+ installCommand: "brew install lua-language-server",
128
+ installStrategy: "shell",
129
+ },
130
+ cpp: {
131
+ toolId: "clangd",
132
+ toolName: "C/C++ Language Server (clangd)",
133
+ installCommand: "brew install llvm # or: apt install clangd",
134
+ installStrategy: "manual",
135
+ },
136
+ zig: {
137
+ toolId: "zls",
138
+ toolName: "Zig Language Server (ZLS)",
139
+ installCommand: "brew install zls",
140
+ installStrategy: "shell",
141
+ },
142
+ haskell: {
143
+ toolId: "haskell-language-server-wrapper",
144
+ toolName: "Haskell Language Server",
145
+ installCommand: "ghcup install hls",
146
+ installStrategy: "shell",
147
+ },
148
+ elixir: {
149
+ toolId: "elixir-ls",
150
+ toolName: "Elixir Language Server (ElixirLS)",
151
+ installCommand:
152
+ "# Download from: https://github.com/elixir-lsp/elixir-ls/releases",
153
+ installStrategy: "manual",
154
+ },
155
+ gleam: {
156
+ toolId: "gleam",
157
+ toolName: "Gleam Language Server",
158
+ installCommand: "brew install gleam",
159
+ installStrategy: "shell",
160
+ },
161
+ ocaml: {
162
+ toolId: "ocamllsp",
163
+ toolName: "OCaml Language Server (ocamllsp)",
164
+ installCommand: "opam install ocaml-lsp-server",
165
+ installStrategy: "shell",
166
+ },
167
+ clojure: {
168
+ toolId: "clojure-lsp",
169
+ toolName: "Clojure Language Server",
170
+ installCommand: "brew install clojure-lsp/brew/clojure-lsp",
171
+ installStrategy: "shell",
172
+ },
173
+ terraform: {
174
+ toolId: "terraform-ls",
175
+ toolName: "Terraform Language Server",
176
+ installCommand: "brew install hashicorp/tap/terraform-ls",
177
+ installStrategy: "shell",
178
+ },
179
+ nix: {
180
+ toolId: "nixd",
181
+ toolName: "Nix Language Server (nixd)",
182
+ installCommand: "nix profile install nixpkgs#nixd",
183
+ installStrategy: "shell",
56
184
  },
57
185
  };
58
186
 
@@ -160,19 +288,32 @@ function isAutoInstallEnabled(): boolean {
160
288
  }
161
289
 
162
290
  /**
163
- * Attempt to install a tool
291
+ * Attempt to install a tool using the configured strategy.
292
+ *
293
+ * - "npm": npm install -g <packageName>
294
+ * - "shell": run installCommand verbatim via shell (gem, dotnet, brew, etc.)
295
+ * - "manual": can't auto-install — print the command and return false
164
296
  */
165
- async function installTool(
166
- toolId: string,
167
- packageName: string,
168
- ): Promise<boolean> {
297
+ async function installTool(config: LanguageConfig): Promise<boolean> {
298
+ const { toolId, toolName, installCommand, packageName, installStrategy } =
299
+ config;
300
+
301
+ if (installStrategy === "manual") {
302
+ console.error(
303
+ `[pi-lens] ${toolName} must be installed manually:\n ${installCommand}`,
304
+ );
305
+ return false;
306
+ }
307
+
169
308
  console.error(`[pi-lens] Installing ${toolId}...`);
170
309
 
310
+ const [cmd, ...args] =
311
+ installStrategy === "npm" && packageName
312
+ ? ["npm", "install", "-g", packageName]
313
+ : ["sh", "-c", installCommand];
314
+
171
315
  return new Promise((resolve) => {
172
- const proc = spawn("npm", ["install", "-g", packageName], {
173
- stdio: "inherit",
174
- shell: true,
175
- });
316
+ const proc = spawn(cmd, args, { stdio: "inherit", shell: false });
176
317
 
177
318
  proc.on("close", (code) => {
178
319
  if (code === 0) {
@@ -242,19 +383,24 @@ export async function promptForInstall(
242
383
  `[pi-lens] Auto-install enabled, installing ${config.toolName}...`,
243
384
  );
244
385
  await saveChoice(cwd, config.toolId, "auto");
245
- return await installTool(config.toolId, config.packageName);
386
+ return await installTool(config);
246
387
  }
247
388
 
248
389
  // Show interactive prompt
249
390
  console.error(`\n⚠️ ${config.toolName} not found`);
250
391
  console.error(` Install: ${config.installCommand}`);
392
+ // For manual-only tools, skip the Y/n prompt — user must install themselves
393
+ if (config.installStrategy === "manual") {
394
+ await saveChoice(cwd, config.toolId, "no");
395
+ return false;
396
+ }
251
397
  console.error(`\n Install now? [Y/n] (auto-accepts in 30s)`);
252
398
 
253
399
  const answer = await promptUser(30000);
254
400
  await saveChoice(cwd, config.toolId, answer);
255
401
 
256
402
  if (answer === "yes") {
257
- return await installTool(config.toolId, config.packageName);
403
+ return await installTool(config);
258
404
  }
259
405
 
260
406
  console.error(`[pi-lens] Skipped ${config.toolName} installation`);
@@ -268,6 +414,15 @@ export function getInstallCommand(language: string): string | undefined {
268
414
  return COMMON_LANGUAGES[language]?.installCommand;
269
415
  }
270
416
 
417
+ /**
418
+ * Get install strategy for a language (exposed for testing)
419
+ */
420
+ export function getInstallStrategy(
421
+ language: string,
422
+ ): InstallStrategy | undefined {
423
+ return COMMON_LANGUAGES[language]?.installStrategy;
424
+ }
425
+
271
426
  /**
272
427
  * Check if a language supports interactive install
273
428
  */