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.
- package/CHANGELOG.md +56 -0
- package/clients/ast-grep-client.ts +31 -9
- package/clients/dispatch/dispatcher.ts +165 -116
- package/clients/dispatch/integration.ts +4 -11
- package/clients/dispatch/runners/lsp.ts +5 -1
- package/clients/dispatch/runners/pyright.ts +5 -1
- package/clients/dispatch/runners/type-safety.ts +5 -1
- package/clients/formatters.ts +223 -22
- package/clients/installer/index.ts +18 -3
- package/clients/latency-logger.ts +9 -0
- package/clients/lsp/interactive-install.ts +177 -22
- package/clients/lsp/server.ts +142 -50
- package/clients/pipeline.ts +16 -11
- package/clients/test-runner-client.ts +86 -1
- package/index.ts +32 -0
- package/package.json +1 -1
- package/skills/ast-grep/SKILL.md +49 -0
package/clients/formatters.ts
CHANGED
|
@@ -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
|
|
116
|
-
if (
|
|
117
|
-
const pkg = (await readJson(
|
|
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
|
|
173
|
-
if (
|
|
174
|
-
const pkg = (await readJson(
|
|
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
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
*/
|