pi-lens 3.8.18 → 3.8.21
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 +31 -0
- package/README.md +26 -17
- package/clients/dispatch/dispatcher.ts +53 -1
- package/clients/dispatch/integration.ts +37 -26
- package/clients/dispatch/plan.ts +26 -15
- package/clients/dispatch/runners/lsp.ts +6 -1
- package/clients/dispatch/runners/pyright.ts +4 -6
- package/clients/dispatch/runners/ruff.ts +52 -7
- package/clients/dispatch/runners/sqlfluff.ts +48 -1
- package/clients/dispatch/runners/yamllint.ts +50 -0
- package/clients/file-utils.ts +13 -2
- package/clients/formatters.ts +8 -4
- package/clients/installer/index.ts +371 -49
- package/clients/language-policy.ts +154 -0
- package/clients/language-profile.ts +167 -0
- package/clients/lsp/index.ts +81 -11
- package/clients/lsp/interactive-install.ts +35 -16
- package/clients/lsp/server.ts +357 -267
- package/clients/pipeline.ts +71 -40
- package/clients/runtime-context.ts +26 -0
- package/clients/runtime-coordinator.ts +30 -2
- package/clients/runtime-session.ts +293 -103
- package/clients/runtime-tool-result.ts +8 -10
- package/clients/runtime-turn.ts +21 -4
- package/clients/todo-scanner.ts +6 -1
- package/clients/type-coverage-client.ts +1 -1
- package/commands/booboo.ts +3 -1
- package/index.ts +15 -3
- package/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as nodeFs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import { ensureTool } from "../../installer/index.js";
|
|
2
4
|
import { safeSpawn } from "../../safe-spawn.js";
|
|
3
5
|
import { createAvailabilityChecker } from "./utils/runner-helpers.js";
|
|
@@ -10,6 +12,49 @@ import type {
|
|
|
10
12
|
|
|
11
13
|
const yamllint = createAvailabilityChecker("yamllint", ".exe");
|
|
12
14
|
|
|
15
|
+
const YAMLLINT_CONFIGS = [
|
|
16
|
+
".yamllint",
|
|
17
|
+
".yamllint.yml",
|
|
18
|
+
".yamllint.yaml",
|
|
19
|
+
"pyproject.toml",
|
|
20
|
+
"setup.cfg",
|
|
21
|
+
"tox.ini",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function hasYamllintConfig(cwd: string): boolean {
|
|
25
|
+
for (const cfg of YAMLLINT_CONFIGS) {
|
|
26
|
+
const cfgPath = path.join(cwd, cfg);
|
|
27
|
+
if (!nodeFs.existsSync(cfgPath)) continue;
|
|
28
|
+
if (cfg === "pyproject.toml") {
|
|
29
|
+
try {
|
|
30
|
+
const content = nodeFs.readFileSync(cfgPath, "utf-8");
|
|
31
|
+
if (content.includes("[tool.yamllint]")) return true;
|
|
32
|
+
} catch {}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (cfg === "setup.cfg" || cfg === "tox.ini") {
|
|
36
|
+
try {
|
|
37
|
+
const content = nodeFs.readFileSync(cfgPath, "utf-8");
|
|
38
|
+
if (content.includes("[yamllint]")) return true;
|
|
39
|
+
} catch {}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Dependency hint fallback for Python projects.
|
|
46
|
+
for (const depFile of ["requirements.txt", "Pipfile", "pyproject.toml"]) {
|
|
47
|
+
const depPath = path.join(cwd, depFile);
|
|
48
|
+
if (!nodeFs.existsSync(depPath)) continue;
|
|
49
|
+
try {
|
|
50
|
+
const content = nodeFs.readFileSync(depPath, "utf-8").toLowerCase();
|
|
51
|
+
if (content.includes("yamllint")) return true;
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
13
58
|
function parseYamllintParsable(raw: string, filePath: string): Diagnostic[] {
|
|
14
59
|
const diagnostics: Diagnostic[] = [];
|
|
15
60
|
for (const line of raw.split(/\r?\n/)) {
|
|
@@ -44,6 +89,11 @@ const yamllintRunner: RunnerDefinition = {
|
|
|
44
89
|
|
|
45
90
|
async run(ctx: DispatchContext): Promise<RunnerResult> {
|
|
46
91
|
const cwd = ctx.cwd || process.cwd();
|
|
92
|
+
const hasConfig = hasYamllintConfig(cwd);
|
|
93
|
+
if (!hasConfig) {
|
|
94
|
+
ctx.log("yamllint: no config detected, running with default rules");
|
|
95
|
+
}
|
|
96
|
+
|
|
47
97
|
let cmd: string | null = null;
|
|
48
98
|
if (yamllint.isAvailable(cwd)) {
|
|
49
99
|
cmd = yamllint.getCommand(cwd);
|
package/clients/file-utils.ts
CHANGED
|
@@ -23,8 +23,19 @@ export const EXCLUDED_DIRS = [
|
|
|
23
23
|
".gradle",
|
|
24
24
|
".next",
|
|
25
25
|
".pi-lens",
|
|
26
|
-
".pi",
|
|
27
|
-
".ruff_cache",
|
|
26
|
+
".pi", // pi agent directory
|
|
27
|
+
".ruff_cache", // Python linter cache
|
|
28
|
+
".worktrees",
|
|
29
|
+
".claude",
|
|
30
|
+
".codex",
|
|
31
|
+
".rescue",
|
|
32
|
+
".agents",
|
|
33
|
+
".gstack",
|
|
34
|
+
".superpowers",
|
|
35
|
+
".guardrails",
|
|
36
|
+
".playwright-cli",
|
|
37
|
+
".playwright-mcp",
|
|
38
|
+
".vscode",
|
|
28
39
|
"venv",
|
|
29
40
|
".venv",
|
|
30
41
|
"coverage",
|
package/clients/formatters.ts
CHANGED
|
@@ -346,6 +346,9 @@ export const ruffFormatter: FormatterInfo = {
|
|
|
346
346
|
async resolveCommand(filePath, cwd) {
|
|
347
347
|
const venv = await findInVenv("ruff", cwd);
|
|
348
348
|
if (venv) return [venv, "format", filePath];
|
|
349
|
+
const { getToolPath } = await import("./installer/index.js");
|
|
350
|
+
const installed = await getToolPath("ruff");
|
|
351
|
+
if (installed) return [installed, "format", filePath];
|
|
349
352
|
return null;
|
|
350
353
|
},
|
|
351
354
|
async detect(cwd: string) {
|
|
@@ -372,10 +375,11 @@ export const ruffFormatter: FormatterInfo = {
|
|
|
372
375
|
}
|
|
373
376
|
}
|
|
374
377
|
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
378
|
+
// No-config fallback: if Ruff is already available, allow formatter usage.
|
|
379
|
+
// This keeps Python default behavior consistent with startup defaults.
|
|
380
|
+
const { getToolPath } = await import("./installer/index.js");
|
|
381
|
+
const installed = await getToolPath("ruff");
|
|
382
|
+
return Boolean(installed);
|
|
379
383
|
},
|
|
380
384
|
};
|
|
381
385
|
|
|
@@ -36,14 +36,17 @@
|
|
|
36
36
|
|
|
37
37
|
import { spawn } from "node:child_process";
|
|
38
38
|
import fs from "node:fs/promises";
|
|
39
|
+
import os from "node:os";
|
|
39
40
|
import path from "node:path";
|
|
40
41
|
|
|
41
42
|
// Global installation directory for pi-lens tools
|
|
42
|
-
const TOOLS_DIR = path.join(
|
|
43
|
+
const TOOLS_DIR = path.join(os.homedir(), ".pi-lens", "tools");
|
|
43
44
|
|
|
44
45
|
// Debug flag - set via PI_LENS_DEBUG=1 or --debug
|
|
45
46
|
const DEBUG =
|
|
46
47
|
process.env.PI_LENS_DEBUG === "1" || process.argv.includes("--debug");
|
|
48
|
+
const SESSIONSTART_LOG_DIR = path.join(os.homedir(), ".pi-lens");
|
|
49
|
+
const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
|
|
47
50
|
|
|
48
51
|
/**
|
|
49
52
|
* Log debug messages only when DEBUG is enabled
|
|
@@ -54,6 +57,16 @@ function debugLog(...args: unknown[]): void {
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
function logSessionStart(msg: string): void {
|
|
61
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
62
|
+
void fs
|
|
63
|
+
.mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
|
|
64
|
+
.then(() => fs.appendFile(SESSIONSTART_LOG, line))
|
|
65
|
+
.catch(() => {
|
|
66
|
+
// best-effort logging
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
// --- Tool Definitions ---
|
|
58
71
|
|
|
59
72
|
interface ToolDefinition {
|
|
@@ -80,6 +93,15 @@ const TOOLS: ToolDefinition[] = [
|
|
|
80
93
|
packageName: "typescript-language-server",
|
|
81
94
|
binaryName: "typescript-language-server",
|
|
82
95
|
},
|
|
96
|
+
{
|
|
97
|
+
id: "typescript",
|
|
98
|
+
name: "TypeScript",
|
|
99
|
+
checkCommand: "tsc",
|
|
100
|
+
checkArgs: ["--version"],
|
|
101
|
+
installStrategy: "npm",
|
|
102
|
+
packageName: "typescript",
|
|
103
|
+
binaryName: "tsc",
|
|
104
|
+
},
|
|
83
105
|
{
|
|
84
106
|
id: "pyright",
|
|
85
107
|
name: "Pyright",
|
|
@@ -175,6 +197,8 @@ const TOOLS: ToolDefinition[] = [
|
|
|
175
197
|
},
|
|
176
198
|
];
|
|
177
199
|
|
|
200
|
+
const ensureInFlight = new Map<string, Promise<string | undefined>>();
|
|
201
|
+
|
|
178
202
|
// --- Check Functions ---
|
|
179
203
|
|
|
180
204
|
/**
|
|
@@ -202,27 +226,7 @@ async function isCommandAvailable(
|
|
|
202
226
|
* Check if a tool is installed (globally or locally)
|
|
203
227
|
*/
|
|
204
228
|
export async function isToolInstalled(toolId: string): Promise<boolean> {
|
|
205
|
-
|
|
206
|
-
if (!tool) return false;
|
|
207
|
-
|
|
208
|
-
// Check global PATH
|
|
209
|
-
if (await isCommandAvailable(tool.checkCommand, tool.checkArgs)) {
|
|
210
|
-
return true;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Check local tools directory
|
|
214
|
-
const localPath = path.join(
|
|
215
|
-
TOOLS_DIR,
|
|
216
|
-
"node_modules",
|
|
217
|
-
".bin",
|
|
218
|
-
tool.binaryName || tool.id,
|
|
219
|
-
);
|
|
220
|
-
try {
|
|
221
|
-
await fs.access(localPath);
|
|
222
|
-
return true;
|
|
223
|
-
} catch {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
229
|
+
return (await getToolPath(toolId)) !== undefined;
|
|
226
230
|
}
|
|
227
231
|
|
|
228
232
|
/**
|
|
@@ -237,6 +241,21 @@ export async function getToolPath(toolId: string): Promise<string | undefined> {
|
|
|
237
241
|
return tool.checkCommand;
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
if (tool.installStrategy === "npm") {
|
|
245
|
+
const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
|
|
246
|
+
if (npmPath) {
|
|
247
|
+
return npmPath;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// For pip tools, also probe user-level script locations
|
|
252
|
+
if (tool.installStrategy === "pip") {
|
|
253
|
+
const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
|
|
254
|
+
if (pipPath) {
|
|
255
|
+
return pipPath;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
240
259
|
// Check local
|
|
241
260
|
const localPath = path.join(
|
|
242
261
|
TOOLS_DIR,
|
|
@@ -252,6 +271,171 @@ export async function getToolPath(toolId: string): Promise<string | undefined> {
|
|
|
252
271
|
}
|
|
253
272
|
}
|
|
254
273
|
|
|
274
|
+
async function findNpmGlobalToolPath(
|
|
275
|
+
binaryName: string,
|
|
276
|
+
): Promise<string | undefined> {
|
|
277
|
+
const isWindows = process.platform === "win32";
|
|
278
|
+
const binDirs = await getNpmGlobalBinCandidates();
|
|
279
|
+
|
|
280
|
+
for (const dir of binDirs) {
|
|
281
|
+
const candidates = isWindows
|
|
282
|
+
? [
|
|
283
|
+
path.join(dir, `${binaryName}.cmd`),
|
|
284
|
+
path.join(dir, `${binaryName}.ps1`),
|
|
285
|
+
path.join(dir, `${binaryName}.exe`),
|
|
286
|
+
path.join(dir, binaryName),
|
|
287
|
+
]
|
|
288
|
+
: [path.join(dir, binaryName)];
|
|
289
|
+
|
|
290
|
+
for (const candidate of candidates) {
|
|
291
|
+
try {
|
|
292
|
+
await fs.access(candidate);
|
|
293
|
+
if (await verifyToolBinary(candidate)) {
|
|
294
|
+
return candidate;
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// continue
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function getNpmGlobalBinCandidates(): Promise<string[]> {
|
|
306
|
+
const dirs: string[] = [];
|
|
307
|
+
const seen = new Set<string>();
|
|
308
|
+
|
|
309
|
+
const add = (value: string | undefined): void => {
|
|
310
|
+
if (!value) return;
|
|
311
|
+
const normalized = path.resolve(value.trim());
|
|
312
|
+
if (!normalized) return;
|
|
313
|
+
if (seen.has(normalized)) return;
|
|
314
|
+
seen.add(normalized);
|
|
315
|
+
dirs.push(normalized);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (process.platform === "win32") {
|
|
319
|
+
add(path.join(process.env.APPDATA || "", "npm"));
|
|
320
|
+
} else {
|
|
321
|
+
add(path.join(os.homedir(), ".npm-global", "bin"));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const pm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
325
|
+
const prefix = await new Promise<string>((resolve) => {
|
|
326
|
+
const proc = spawn(pm, ["config", "get", "prefix"], {
|
|
327
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
328
|
+
shell: process.platform === "win32",
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
let stdout = "";
|
|
332
|
+
proc.stdout?.on("data", (data: Buffer | string) => (stdout += data));
|
|
333
|
+
proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
|
|
334
|
+
proc.on("error", () => resolve(""));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (prefix) {
|
|
338
|
+
add(process.platform === "win32" ? prefix : path.join(prefix, "bin"));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return dirs;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function findPipUserToolPath(
|
|
345
|
+
binaryName: string,
|
|
346
|
+
): Promise<string | undefined> {
|
|
347
|
+
const isWindows = process.platform === "win32";
|
|
348
|
+
const userBaseCandidates = await getPythonUserBaseCandidates();
|
|
349
|
+
|
|
350
|
+
for (const userBase of userBaseCandidates) {
|
|
351
|
+
const scriptDirs: string[] = [
|
|
352
|
+
path.join(userBase, isWindows ? "Scripts" : "bin"),
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
if (isWindows) {
|
|
356
|
+
try {
|
|
357
|
+
const children = await fs.readdir(userBase, { withFileTypes: true });
|
|
358
|
+
for (const entry of children) {
|
|
359
|
+
if (!entry.isDirectory()) continue;
|
|
360
|
+
if (!/^python\d+$/i.test(entry.name)) continue;
|
|
361
|
+
scriptDirs.push(path.join(userBase, entry.name, "Scripts"));
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
// ignore
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
for (const dir of scriptDirs) {
|
|
369
|
+
const candidates = isWindows
|
|
370
|
+
? [
|
|
371
|
+
path.join(dir, `${binaryName}.exe`),
|
|
372
|
+
path.join(dir, `${binaryName}.cmd`),
|
|
373
|
+
path.join(dir, binaryName),
|
|
374
|
+
]
|
|
375
|
+
: [path.join(dir, binaryName)];
|
|
376
|
+
|
|
377
|
+
for (const candidate of candidates) {
|
|
378
|
+
try {
|
|
379
|
+
await fs.access(candidate);
|
|
380
|
+
if (await verifyToolBinary(candidate)) {
|
|
381
|
+
return candidate;
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
// continue
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function getPythonUserBaseCandidates(): Promise<string[]> {
|
|
394
|
+
const candidates: string[] = [];
|
|
395
|
+
const seen = new Set<string>();
|
|
396
|
+
|
|
397
|
+
const add = (value: string | undefined): void => {
|
|
398
|
+
if (!value) return;
|
|
399
|
+
const normalized = value.trim();
|
|
400
|
+
if (!normalized) return;
|
|
401
|
+
if (seen.has(normalized)) return;
|
|
402
|
+
seen.add(normalized);
|
|
403
|
+
candidates.push(normalized);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
if (process.platform === "win32") {
|
|
407
|
+
add(path.join(process.env.APPDATA || "", "Python"));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const probes: Array<{ command: string; args: string[] }> =
|
|
411
|
+
process.platform === "win32"
|
|
412
|
+
? [
|
|
413
|
+
{ command: "py", args: ["-m", "site", "--user-base"] },
|
|
414
|
+
{ command: "python", args: ["-m", "site", "--user-base"] },
|
|
415
|
+
]
|
|
416
|
+
: [
|
|
417
|
+
{ command: "python3", args: ["-m", "site", "--user-base"] },
|
|
418
|
+
{ command: "python", args: ["-m", "site", "--user-base"] },
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
for (const probe of probes) {
|
|
422
|
+
const userBase = await new Promise<string>((resolve) => {
|
|
423
|
+
const proc = spawn(probe.command, probe.args, {
|
|
424
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
425
|
+
shell: process.platform === "win32",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
let stdout = "";
|
|
429
|
+
proc.stdout?.on("data", (data: Buffer | string) => (stdout += data));
|
|
430
|
+
proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
|
|
431
|
+
proc.on("error", () => resolve(""));
|
|
432
|
+
});
|
|
433
|
+
add(userBase);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return candidates;
|
|
437
|
+
}
|
|
438
|
+
|
|
255
439
|
// --- Verification Functions
|
|
256
440
|
|
|
257
441
|
/**
|
|
@@ -262,8 +446,9 @@ async function verifyToolBinary(binPath: string): Promise<boolean> {
|
|
|
262
446
|
return new Promise((resolve) => {
|
|
263
447
|
// Add .cmd extension on Windows for the actual binary
|
|
264
448
|
const isWindows = process.platform === "win32";
|
|
449
|
+
const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
|
|
265
450
|
const execPath =
|
|
266
|
-
isWindows && !
|
|
451
|
+
isWindows && !hasKnownWindowsExt ? `${binPath}.cmd` : binPath;
|
|
267
452
|
|
|
268
453
|
const proc = spawn(execPath, ["--version"], {
|
|
269
454
|
timeout: 10000, // 10 second timeout for verification
|
|
@@ -330,8 +515,6 @@ async function installNpmTool(
|
|
|
330
515
|
);
|
|
331
516
|
}
|
|
332
517
|
|
|
333
|
-
console.error(`[auto-install] Installing ${packageName}...`);
|
|
334
|
-
|
|
335
518
|
// Install via npm or bun (use .cmd on Windows)
|
|
336
519
|
const isWindows = process.platform === "win32";
|
|
337
520
|
const pm = process.env.BUN_INSTALL
|
|
@@ -427,27 +610,121 @@ async function installPipTool(
|
|
|
427
610
|
packageName: string,
|
|
428
611
|
): Promise<string | undefined> {
|
|
429
612
|
try {
|
|
430
|
-
const pipCmd = process.platform === "win32" ? "pip" : "pip3";
|
|
431
613
|
const isWindows = process.platform === "win32";
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
614
|
+
const pipCandidates = isWindows
|
|
615
|
+
? [
|
|
616
|
+
{ command: "pip", args: ["install", "--user", packageName] },
|
|
617
|
+
{ command: "py", args: ["-m", "pip", "install", "--user", packageName] },
|
|
618
|
+
{
|
|
619
|
+
command: "python",
|
|
620
|
+
args: ["-m", "pip", "install", "--user", packageName],
|
|
621
|
+
},
|
|
622
|
+
]
|
|
623
|
+
: [
|
|
624
|
+
{ command: "pip3", args: ["install", "--user", packageName] },
|
|
625
|
+
{ command: "pip", args: ["install", "--user", packageName] },
|
|
626
|
+
{
|
|
627
|
+
command: "python3",
|
|
628
|
+
args: ["-m", "pip", "install", "--user", packageName],
|
|
629
|
+
},
|
|
630
|
+
{ command: "python", args: ["-m", "pip", "install", "--user", packageName] },
|
|
631
|
+
];
|
|
632
|
+
|
|
633
|
+
let lastError = "";
|
|
634
|
+
for (const candidate of pipCandidates) {
|
|
635
|
+
const outcome = await new Promise<{ ok: boolean; error: string }>((resolve) => {
|
|
636
|
+
const proc = spawn(candidate.command, candidate.args, {
|
|
637
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
638
|
+
shell: isWindows, // Required for .cmd files on Windows
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
let stderr = "";
|
|
642
|
+
proc.stderr?.on("data", (data) => (stderr += data));
|
|
643
|
+
|
|
644
|
+
proc.on("exit", (code) => {
|
|
645
|
+
if (code === 0) {
|
|
646
|
+
resolve({ ok: true, error: "" });
|
|
647
|
+
} else {
|
|
648
|
+
resolve({ ok: false, error: stderr.trim() });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
436
651
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
652
|
+
proc.on("error", (err) => {
|
|
653
|
+
resolve({ ok: false, error: err.message });
|
|
654
|
+
});
|
|
655
|
+
});
|
|
440
656
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
657
|
+
if (outcome.ok) {
|
|
658
|
+
// Ensure user-level scripts directory is available in current process PATH.
|
|
659
|
+
// This helps tools installed via `pip install --user` become immediately callable.
|
|
660
|
+
const userBaseResult = await new Promise<string>((resolve) => {
|
|
661
|
+
const probe = spawn(candidate.command, ["-m", "site", "--user-base"], {
|
|
662
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
663
|
+
shell: isWindows,
|
|
664
|
+
});
|
|
665
|
+
let stdout = "";
|
|
666
|
+
probe.stdout?.on("data", (data) => (stdout += data));
|
|
667
|
+
probe.on("exit", (code) => {
|
|
668
|
+
if (code === 0) resolve(stdout.trim());
|
|
669
|
+
else resolve("");
|
|
670
|
+
});
|
|
671
|
+
probe.on("error", () => resolve(""));
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
if (userBaseResult) {
|
|
675
|
+
const candidateScriptDirs: string[] = [
|
|
676
|
+
path.join(userBaseResult, isWindows ? "Scripts" : "bin"),
|
|
677
|
+
];
|
|
678
|
+
|
|
679
|
+
if (isWindows) {
|
|
680
|
+
// Some Python setups report USER_BASE as ...\Roaming\Python,
|
|
681
|
+
// while scripts live in ...\Roaming\Python\PythonXY\Scripts.
|
|
682
|
+
try {
|
|
683
|
+
const children = await fs.readdir(userBaseResult, {
|
|
684
|
+
withFileTypes: true,
|
|
685
|
+
});
|
|
686
|
+
for (const entry of children) {
|
|
687
|
+
if (!entry.isDirectory()) continue;
|
|
688
|
+
if (!/^python\d+$/i.test(entry.name)) continue;
|
|
689
|
+
candidateScriptDirs.push(
|
|
690
|
+
path.join(userBaseResult, entry.name, "Scripts"),
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
} catch {
|
|
694
|
+
// ignore
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const currentPath = process.env.PATH || "";
|
|
699
|
+
const separator = isWindows ? ";" : ":";
|
|
700
|
+
const normalizedPath = currentPath
|
|
701
|
+
.toLowerCase()
|
|
702
|
+
.split(separator)
|
|
703
|
+
.map((p) => p.trim());
|
|
704
|
+
|
|
705
|
+
for (const scriptsDir of candidateScriptDirs) {
|
|
706
|
+
try {
|
|
707
|
+
await fs.access(scriptsDir);
|
|
708
|
+
if (!normalizedPath.includes(scriptsDir.toLowerCase())) {
|
|
709
|
+
process.env.PATH = `${scriptsDir}${separator}${process.env.PATH || ""}`;
|
|
710
|
+
debugLog(`Added pip user scripts dir to PATH: ${scriptsDir}`);
|
|
711
|
+
}
|
|
712
|
+
} catch {
|
|
713
|
+
debugLog(`pip user scripts dir not accessible: ${scriptsDir}`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
446
716
|
}
|
|
447
|
-
});
|
|
448
717
|
|
|
449
|
-
|
|
450
|
-
|
|
718
|
+
return packageName;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
lastError = `${candidate.command} ${candidate.args.join(" ")}: ${outcome.error}`;
|
|
722
|
+
debugLog(`[pip-fallback] ${lastError}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
throw new Error(
|
|
726
|
+
`Failed to install ${packageName}: no usable pip command found (${lastError || "unknown error"})`,
|
|
727
|
+
);
|
|
451
728
|
} catch (err) {
|
|
452
729
|
console.error(
|
|
453
730
|
`[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
|
|
@@ -464,35 +741,52 @@ export async function installTool(toolId: string): Promise<boolean> {
|
|
|
464
741
|
const tool = TOOLS.find((t) => t.id === toolId);
|
|
465
742
|
if (!tool) {
|
|
466
743
|
console.error(`[auto-install] Unknown tool: ${toolId}`);
|
|
744
|
+
logSessionStart(`auto-install ${toolId}: unknown tool id`);
|
|
467
745
|
return false;
|
|
468
746
|
}
|
|
469
747
|
|
|
470
748
|
console.error(`[auto-install] Installing ${tool.name}...`);
|
|
749
|
+
const startedAt = Date.now();
|
|
750
|
+
logSessionStart(
|
|
751
|
+
`auto-install ${tool.id}: start strategy=${tool.installStrategy} package=${tool.packageName ?? "n/a"}`,
|
|
752
|
+
);
|
|
471
753
|
|
|
472
754
|
try {
|
|
473
755
|
switch (tool.installStrategy) {
|
|
474
756
|
case "npm": {
|
|
475
757
|
if (!tool.packageName || !tool.binaryName) return false;
|
|
476
758
|
const npmPath = await installNpmTool(tool.packageName, tool.binaryName);
|
|
477
|
-
|
|
759
|
+
const ok = npmPath !== undefined;
|
|
760
|
+
logSessionStart(
|
|
761
|
+
`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
|
|
762
|
+
);
|
|
763
|
+
return ok;
|
|
478
764
|
}
|
|
479
765
|
|
|
480
766
|
case "pip": {
|
|
481
767
|
if (!tool.packageName) return false;
|
|
482
768
|
const pipPath = await installPipTool(tool.packageName);
|
|
483
|
-
|
|
769
|
+
const ok = pipPath !== undefined;
|
|
770
|
+
logSessionStart(
|
|
771
|
+
`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
|
|
772
|
+
);
|
|
773
|
+
return ok;
|
|
484
774
|
}
|
|
485
775
|
|
|
486
776
|
default:
|
|
487
777
|
console.error(
|
|
488
778
|
`[auto-install] Unsupported strategy: ${tool.installStrategy}`,
|
|
489
779
|
);
|
|
780
|
+
logSessionStart(`auto-install ${tool.id}: unsupported strategy`);
|
|
490
781
|
return false;
|
|
491
782
|
}
|
|
492
783
|
} catch (err) {
|
|
493
784
|
console.error(
|
|
494
785
|
`[auto-install] Failed to install ${tool.name}: ${(err as Error).message}`,
|
|
495
786
|
);
|
|
787
|
+
logSessionStart(
|
|
788
|
+
`auto-install ${tool.id}: exception ${(err as Error).message} (${Date.now() - startedAt}ms)`,
|
|
789
|
+
);
|
|
496
790
|
debugLog("Full error:", err);
|
|
497
791
|
return false;
|
|
498
792
|
}
|
|
@@ -502,20 +796,48 @@ export async function installTool(toolId: string): Promise<boolean> {
|
|
|
502
796
|
* Ensure a tool is installed (check first, install if missing)
|
|
503
797
|
*/
|
|
504
798
|
export async function ensureTool(toolId: string): Promise<string | undefined> {
|
|
799
|
+
const ensureStartMs = Date.now();
|
|
800
|
+
logSessionStart(`auto-install ensure ${toolId}: start`);
|
|
505
801
|
// Check if already installed
|
|
506
802
|
const existingPath = await getToolPath(toolId);
|
|
507
803
|
if (existingPath) {
|
|
804
|
+
logSessionStart(
|
|
805
|
+
`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`,
|
|
806
|
+
);
|
|
508
807
|
return existingPath;
|
|
509
808
|
}
|
|
510
809
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return
|
|
810
|
+
const inFlight = ensureInFlight.get(toolId);
|
|
811
|
+
if (inFlight) {
|
|
812
|
+
logSessionStart(`auto-install ensure ${toolId}: waiting for in-flight install`);
|
|
813
|
+
return inFlight;
|
|
515
814
|
}
|
|
516
815
|
|
|
517
|
-
|
|
518
|
-
|
|
816
|
+
const installPromise = (async () => {
|
|
817
|
+
const installed = await installTool(toolId);
|
|
818
|
+
if (!installed) {
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return getToolPath(toolId);
|
|
823
|
+
})();
|
|
824
|
+
|
|
825
|
+
ensureInFlight.set(toolId, installPromise);
|
|
826
|
+
try {
|
|
827
|
+
const result = await installPromise;
|
|
828
|
+
if (result) {
|
|
829
|
+
logSessionStart(
|
|
830
|
+
`auto-install ensure ${toolId}: success at ${result} (${Date.now() - ensureStartMs}ms)`,
|
|
831
|
+
);
|
|
832
|
+
} else {
|
|
833
|
+
logSessionStart(
|
|
834
|
+
`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
return result;
|
|
838
|
+
} finally {
|
|
839
|
+
ensureInFlight.delete(toolId);
|
|
840
|
+
}
|
|
519
841
|
}
|
|
520
842
|
|
|
521
843
|
// --- Integration Helpers ---
|