pi-lsp-lite 0.4.0 → 0.5.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/README.md CHANGED
@@ -53,7 +53,7 @@ The agent sees these too — they're appended to the tool result, so it can self
53
53
  | `pylsp` | Python | `pip install python-lsp-server` |
54
54
  | `clangd` | C/C++ | Xcode CLI tools / `apt install clangd` |
55
55
 
56
- Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio. Or add it to `.pi-lsp-lite.json`:
56
+ Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio. Or add it to global config (`~/.pi-lsp-lite.json`):
57
57
 
58
58
  ```json
59
59
  {
@@ -70,7 +70,7 @@ Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio
70
70
 
71
71
  ## Configuration
72
72
 
73
- Works without config. For customisation, create `.pi-lsp-lite.json` (project) or `~/.pi-lsp-lite.json` (global):
73
+ Works without config. Use project config (`.pi-lsp-lite.json` or `.pi/lsp-lite.json`) for safe local tuning, and global config (`~/.pi-lsp-lite.json`) for trusted executable/server-shape changes like custom servers, `command`, `args`, `extensions`, and `rootPatterns`:
74
74
 
75
75
  | Field | Description | Default |
76
76
  |-------|-------------|---------|
@@ -80,7 +80,7 @@ Works without config. For customisation, create `.pi-lsp-lite.json` (project) or
80
80
  | `diagnosticTimeout` | Global default timeout (ms) | `5000` |
81
81
  | `documentIdleTimeout` | Close idle documents after (ms) | `120000` |
82
82
 
83
- Project config merges over global. Partial overrides work only specify what you want to change.
83
+ Project config merges over global for safe tuning fields. Repositories can disable servers and tune timeouts/retries, but they cannot change the executable, argv, extensions, or root patterns for any existing server; put those trusted changes in global config.
84
84
 
85
85
  ## How it works
86
86
 
@@ -101,7 +101,7 @@ Servers are lazy (spawn on first edit), idle-shutdown after 240s, and clean up o
101
101
  git clone https://github.com/mcphailtom/pi-lsp-lite
102
102
  cd pi-lsp-lite && npm install
103
103
  npm run check # typecheck
104
- npm test # unit tests (106, no servers needed)
104
+ npm test # unit tests (no servers needed)
105
105
  npm run test:integration # real server tests (needs servers on PATH)
106
106
  ```
107
107
 
package/index.ts CHANGED
@@ -5,7 +5,8 @@ import { formatDiagnostics } from "./src/format.js";
5
5
  import { DiagnosticSeverity } from "vscode-languageserver-protocol";
6
6
  import { loadConfig, writeGlobalConfig, readGlobalConfig } from "./src/config.js";
7
7
  import { fileUri, which, isInsideCwd } from "./src/util.js";
8
- import { installRegistry } from "./src/install-registry.js";
8
+ import { installRegistry, installCommandFor } from "./src/install-registry.js";
9
+ import { buildServerStates, formatServerStates } from "./src/status.js";
9
10
  import { resolve } from "node:path";
10
11
  import { realpath } from "node:fs/promises";
11
12
  import { fileURLToPath } from "node:url";
@@ -29,6 +30,17 @@ export default function (pi: ExtensionAPI) {
29
30
  }
30
31
  }
31
32
 
33
+ async function currentServerStates() {
34
+ return buildServerStates({
35
+ builtins: builtinLanguages,
36
+ active: servers,
37
+ globalConfig: await readGlobalConfig(),
38
+ running: manager.status(),
39
+ installRegistry,
40
+ resolveCommand: which,
41
+ });
42
+ }
43
+
32
44
  pi.on("session_start", async (_event, ctx) => {
33
45
  await initConfig(ctx.cwd);
34
46
  });
@@ -71,19 +83,9 @@ export default function (pi: ExtensionAPI) {
71
83
  });
72
84
 
73
85
  pi.registerCommand("lsp-status", {
74
- description: "Show running LSP servers and recent diagnostic counts",
86
+ description: "Show configured LSP servers, install state, and running processes",
75
87
  handler: async (_args, ctx) => {
76
- const running = manager.status();
77
- if (running.length === 0) {
78
- ctx.ui.notify("pi-lsp-lite: no servers running", "info");
79
- return;
80
- }
81
- const lines = running.map((s) => {
82
- const idle = Math.round((Date.now() - s.lastActivity) / 1000);
83
- const up = Math.round(s.uptime / 1000);
84
- return `${s.id} (pid ${s.pid}) root=${s.root} — ${s.openDocuments} open files, up ${up}s, idle ${idle}s`;
85
- });
86
- ctx.ui.notify(lines.join("\n"), "info");
88
+ ctx.ui.notify(formatServerStates(await currentServerStates()), "info");
87
89
  },
88
90
  });
89
91
 
@@ -169,18 +171,19 @@ export default function (pi: ExtensionAPI) {
169
171
  const rootPatterns = rootRaw ? rootRaw.split(",").map((r) => r.trim()).filter(Boolean) : [];
170
172
 
171
173
  const resolved = await which(command);
172
- if (!resolved) {
173
- ctx.ui.notify(`pi-lsp-lite: "${command}" not found on PATH — server added but won't start until installed`, "warning");
174
- }
175
-
176
174
  await writeGlobalConfig({ servers: { [id]: { command, args, extensions, rootPatterns } } });
177
175
  await initConfig(ctx.cwd);
178
- ctx.ui.notify(`pi-lsp-lite: added server "${id}"`, "info");
176
+
177
+ if (!resolved) {
178
+ ctx.ui.notify(`pi-lsp-lite: configured server "${id}", but "${command}" is missing from PATH — install it manually before use`, "warning");
179
+ return;
180
+ }
181
+ ctx.ui.notify(`pi-lsp-lite: configured server "${id}" (${resolved})`, "info");
179
182
  },
180
183
  });
181
184
 
182
185
  pi.registerCommand("lsp-remove", {
183
- description: "Remove or disable a language server",
186
+ description: "Disable a language server",
184
187
  handler: async (_args, ctx) => {
185
188
  if (!ctx.hasUI) {
186
189
  ctx.ui.notify("pi-lsp-lite: /lsp-remove requires interactive mode", "error");
@@ -193,10 +196,10 @@ export default function (pi: ExtensionAPI) {
193
196
  }
194
197
 
195
198
  const ids = servers.map((s) => s.id);
196
- const selected = await ctx.ui.select("Remove which server?", ids);
199
+ const selected = await ctx.ui.select("Disable which server?", ids);
197
200
  if (!selected) return;
198
201
 
199
- const confirmed = await ctx.ui.confirm("Confirm removal", `Disable server "${selected}"?`);
202
+ const confirmed = await ctx.ui.confirm("Confirm disable", `Disable server "${selected}"?`);
200
203
  if (!confirmed) return;
201
204
 
202
205
  await writeGlobalConfig({ servers: { [selected]: { disabled: true } } });
@@ -246,7 +249,23 @@ export default function (pi: ExtensionAPI) {
246
249
  }
247
250
 
248
251
  await initConfig(ctx.cwd);
249
- ctx.ui.notify(`pi-lsp-lite: ${isCurrentlyEnabled ? "disabled" : "enabled"} server "${id}"`, "info");
252
+
253
+ if (isCurrentlyEnabled) {
254
+ ctx.ui.notify(`pi-lsp-lite: disabled server "${id}"`, "info");
255
+ return;
256
+ }
257
+
258
+ const state = (await currentServerStates()).find((s) => s.id === id);
259
+ if (state?.installed === false) {
260
+ const installHint = state.installable ? "run /lsp-install" : "install it manually";
261
+ ctx.ui.notify(`pi-lsp-lite: enabled server "${id}", but "${state.command}" is missing from PATH — ${installHint} before use`, "warning");
262
+ return;
263
+ }
264
+ if (state?.installed === null) {
265
+ ctx.ui.notify(`pi-lsp-lite: enabled server "${id}", but its command is incomplete — check global config`, "warning");
266
+ return;
267
+ }
268
+ ctx.ui.notify(`pi-lsp-lite: enabled server "${id}"`, "info");
250
269
  },
251
270
  });
252
271
 
@@ -263,13 +282,13 @@ export default function (pi: ExtensionAPI) {
263
282
  const lang = builtinLanguages.find((l) => l.id === id);
264
283
  const binary = lang?.command ?? id;
265
284
  const found = await which(binary);
266
- return found ? null : { id, command: binary, installCmd: entry.command, description: entry.description };
285
+ return found ? null : { id, command: binary, installCmd: installCommandFor(entry), description: entry.description };
267
286
  }),
268
287
  );
269
288
  const missing = checks.filter((c): c is NonNullable<typeof c> => c !== null);
270
289
 
271
290
  if (missing.length === 0) {
272
- ctx.ui.notify("pi-lsp-lite: all known servers are available", "info");
291
+ ctx.ui.notify("pi-lsp-lite: all built-in installable servers are available; custom servers must be installed manually", "info");
273
292
  return;
274
293
  }
275
294
 
@@ -283,7 +302,9 @@ export default function (pi: ExtensionAPI) {
283
302
  const confirmed = await ctx.ui.confirm("Confirm install", `Run: ${selected.installCmd}`);
284
303
  if (!confirmed) return;
285
304
 
286
- const result = await pi.exec("sh", ["-c", selected.installCmd]);
305
+ const result = process.platform === "win32"
306
+ ? await pi.exec(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", selected.installCmd])
307
+ : await pi.exec("sh", ["-c", selected.installCmd]);
287
308
  if (result.code !== 0) {
288
309
  ctx.ui.notify(`pi-lsp-lite: install failed (exit ${result.code})\n${result.stderr}`, "error");
289
310
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lsp-lite",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "LSP diagnostics for pi — errors and warnings on every edit, same turn. Go, Rust, TypeScript, Python, C/C++.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -29,7 +29,9 @@
29
29
  ]
30
30
  },
31
31
  "dependencies": {
32
- "vscode-languageserver-protocol": "^3.17.5"
32
+ "cross-spawn": "^7.0.6",
33
+ "vscode-languageserver-protocol": "~3.18.1",
34
+ "which": "^2.0.2"
33
35
  },
34
36
  "peerDependencies": {
35
37
  "@earendil-works/pi-coding-agent": "*",
@@ -37,13 +39,19 @@
37
39
  },
38
40
  "devDependencies": {
39
41
  "@earendil-works/pi-coding-agent": "^0.74.0",
40
- "@types/node": "^20.0.0",
42
+ "@types/cross-spawn": "^6.0.6",
43
+ "@types/node": "^22.0.0",
44
+ "@types/which": "^2.0.2",
45
+ "cross-env": "^7.0.3",
41
46
  "tsx": "^4.21.0",
42
47
  "typescript": "^5.4.0"
43
48
  },
44
49
  "scripts": {
45
50
  "check": "tsc --noEmit",
46
51
  "test": "tsx --test test/*.test.ts",
47
- "test:integration": "INTEGRATION=1 tsx --test test/*.test.ts test/integration/*.test.ts"
52
+ "test:integration": "cross-env INTEGRATION=1 tsx --test test/*.test.ts test/integration/*.test.ts"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.19.0"
48
56
  }
49
57
  }
package/src/client.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  DiagnosticSeverity,
14
14
  type InitializeParams,
15
15
  type Diagnostic,
16
- } from "vscode-languageserver-protocol/node.js";
16
+ } from "vscode-languageserver-protocol/node";
17
17
  import type { ChildProcess } from "node:child_process";
18
18
  import { fileUri } from "./util.js";
19
19
 
@@ -54,8 +54,12 @@ function countDiagnostics(diags: Diagnostic[]): { errors: number; warnings: numb
54
54
  return { errors, warnings };
55
55
  }
56
56
 
57
+ function diagnosticMessage(d: Diagnostic): string {
58
+ return typeof d.message === "string" ? d.message : d.message.value;
59
+ }
60
+
57
61
  function diagnosticFingerprint(d: Diagnostic): string {
58
- return `${d.severity}:${d.range.start.line}:${d.range.start.character}:${d.message}`;
62
+ return `${d.severity}:${d.range.start.line}:${d.range.start.character}:${diagnosticMessage(d)}`;
59
63
  }
60
64
 
61
65
  function fingerprintSet(diags: Diagnostic[]): Set<string> {
@@ -202,7 +206,7 @@ export function createLspClient(child: ChildProcess): LspClient {
202
206
  severity: first.severity ?? DiagnosticSeverity.Error,
203
207
  line: first.range.start.line,
204
208
  col: first.range.start.character,
205
- message: first.message,
209
+ message: diagnosticMessage(first),
206
210
  ...(first.source && { source: first.source }),
207
211
  },
208
212
  }),
@@ -285,7 +289,7 @@ export function createLspClient(child: ChildProcess): LspClient {
285
289
  timer = setTimeout(() => reject(new Error("shutdown timed out")), SHUTDOWN_TIMEOUT_MS);
286
290
  }),
287
291
  ]);
288
- connection.sendNotification(ExitNotification.type);
292
+ await connection.sendNotification(ExitNotification.type);
289
293
  } catch {
290
294
  // timed out or server already exited
291
295
  } finally {
package/src/config.ts CHANGED
@@ -183,9 +183,13 @@ function mergeConfigs(
183
183
  const existing = result.get(id);
184
184
  if (existing) {
185
185
  const { disabled: _, diagnosticTimeout: __, ...lspFields } = override;
186
- if (source === "project" && lspFields.command !== undefined) {
187
- console.error(`[pi-lsp-lite] project config cannot override "command" for server "${id}" ignoring (use global config instead)`);
188
- delete lspFields.command;
186
+ if (source === "project") {
187
+ for (const field of ["command", "args", "extensions", "rootPatterns"] as const) {
188
+ if (lspFields[field] !== undefined) {
189
+ console.error(`[pi-lsp-lite] project config cannot override "${field}" for server "${id}" — ignoring (use global config instead)`);
190
+ delete lspFields[field];
191
+ }
192
+ }
189
193
  }
190
194
  const defined = Object.fromEntries(
191
195
  Object.entries(lspFields).filter(([, v]) => v !== undefined),
@@ -1,27 +1,37 @@
1
1
  export interface InstallEntry {
2
- command: string;
2
+ // Per-platform install command; `win32` overrides `default` on Windows.
3
+ command: { default: string; win32?: string };
3
4
  description: string;
4
5
  }
5
6
 
6
7
  export const installRegistry = new Map<string, InstallEntry>([
7
8
  ["go", {
8
- command: "go install golang.org/x/tools/gopls@latest",
9
+ command: { default: "go install golang.org/x/tools/gopls@latest" },
9
10
  description: "Go language server",
10
11
  }],
11
12
  ["rust", {
12
- command: "rustup component add rust-analyzer",
13
+ command: { default: "rustup component add rust-analyzer" },
13
14
  description: "Rust language server",
14
15
  }],
15
16
  ["typescript", {
16
- command: "npm install -g typescript-language-server typescript",
17
+ command: { default: "npm install -g typescript-language-server typescript" },
17
18
  description: "TypeScript/JavaScript language server",
18
19
  }],
19
20
  ["python", {
20
- command: "pip install python-lsp-server",
21
+ command: { default: "pip install python-lsp-server" },
21
22
  description: "Python language server",
22
23
  }],
23
24
  ["cpp", {
24
- command: "sudo apt-get install -y clangd || brew install llvm",
25
+ command: {
26
+ default: "sudo apt-get install -y clangd || brew install llvm",
27
+ win32: "winget install -e --id LLVM.clangd",
28
+ },
25
29
  description: "C/C++ language server",
26
30
  }],
27
31
  ]);
32
+
33
+ // Resolve the install command for the current platform.
34
+ export function installCommandFor(entry: InstallEntry): string {
35
+ if (process.platform === "win32" && entry.command.win32) return entry.command.win32;
36
+ return entry.command.default;
37
+ }
@@ -1,4 +1,5 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
1
+ import { type ChildProcess } from "node:child_process";
2
+ import spawn from "cross-spawn";
2
3
  import { which, fileUri, findWorkspaceRoot } from "./util.js";
3
4
  import { createLspClient, type LspClient, type DiagnosticResult } from "./client.js";
4
5
  import { type LanguageServerConfig, languageIdForFile } from "./languages.js";
package/src/status.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { UserConfig } from "./config.js";
2
+ import type { InstallEntry } from "./install-registry.js";
3
+ import type { LanguageServerConfig } from "./languages.js";
4
+ import type { ServerStatus } from "./server-manager.js";
5
+
6
+ export interface ServerState {
7
+ id: string;
8
+ command: string | null;
9
+ enabled: boolean;
10
+ installed: boolean | null;
11
+ installable: boolean;
12
+ running: ServerStatus[];
13
+ }
14
+
15
+ export interface BuildServerStatesOptions {
16
+ builtins: LanguageServerConfig[];
17
+ active: LanguageServerConfig[];
18
+ globalConfig: UserConfig | null;
19
+ running: ServerStatus[];
20
+ installRegistry: Map<string, InstallEntry>;
21
+ resolveCommand(command: string): Promise<string | null>;
22
+ }
23
+
24
+ const RESERVED_SERVER_KEYS = new Set(["__proto__", "constructor", "prototype"]);
25
+
26
+ function globalServerIds(globalConfig: UserConfig | null): string[] {
27
+ const servers = globalConfig?.servers;
28
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return [];
29
+ return Object.keys(servers).filter((id) => !RESERVED_SERVER_KEYS.has(id));
30
+ }
31
+
32
+ function commandFor(id: string, active: Map<string, LanguageServerConfig>, builtins: Map<string, LanguageServerConfig>, globalConfig: UserConfig | null): string | null {
33
+ const activeConfig = active.get(id);
34
+ if (activeConfig) return activeConfig.command;
35
+
36
+ const globalCommand = globalConfig?.servers?.[id]?.command;
37
+ if (typeof globalCommand === "string" && globalCommand.length > 0) return globalCommand;
38
+
39
+ const builtin = builtins.get(id);
40
+ return builtin?.command ?? null;
41
+ }
42
+
43
+ export async function buildServerStates(options: BuildServerStatesOptions): Promise<ServerState[]> {
44
+ const active = new Map(options.active.map((server) => [server.id, server]));
45
+ const builtins = new Map(options.builtins.map((server) => [server.id, server]));
46
+ const runningById = new Map<string, ServerStatus[]>();
47
+
48
+ for (const status of options.running) {
49
+ const entries = runningById.get(status.id) ?? [];
50
+ entries.push(status);
51
+ runningById.set(status.id, entries);
52
+ }
53
+
54
+ const ids = new Set<string>([
55
+ ...builtins.keys(),
56
+ ...active.keys(),
57
+ ...globalServerIds(options.globalConfig),
58
+ ]);
59
+
60
+ const states = await Promise.all([...ids].sort().map(async (id): Promise<ServerState> => {
61
+ const command = commandFor(id, active, builtins, options.globalConfig);
62
+ const installed = command ? (await options.resolveCommand(command)) !== null : null;
63
+ return {
64
+ id,
65
+ command,
66
+ enabled: active.has(id),
67
+ installed,
68
+ installable: options.installRegistry.has(id),
69
+ running: runningById.get(id) ?? [],
70
+ };
71
+ }));
72
+
73
+ return states;
74
+ }
75
+
76
+ export function formatServerStates(states: ServerState[]): string {
77
+ if (states.length === 0) return "pi-lsp-lite: no servers configured";
78
+
79
+ return states.map((state) => {
80
+ const enabled = state.enabled ? "enabled" : "disabled";
81
+ const installed = state.installed === null ? "unknown" : state.installed ? "installed" : "missing";
82
+ const installHint = state.installed === false
83
+ ? state.installable ? "installable via /lsp-install" : "manual install required"
84
+ : "";
85
+ const running = state.running.length > 0
86
+ ? state.running.map((s) => {
87
+ const idle = Math.round((Date.now() - s.lastActivity) / 1000);
88
+ const up = Math.round(s.uptime / 1000);
89
+ return `running pid=${s.pid} root=${s.root} open=${s.openDocuments} up=${up}s idle=${idle}s`;
90
+ }).join("; ")
91
+ : "not running";
92
+ const command = state.command ? `cmd=${state.command}` : "cmd=unknown";
93
+ return [state.id, enabled, installed, running, command, installHint].filter(Boolean).join(" — ");
94
+ }).join("\n");
95
+ }
package/src/util.ts CHANGED
@@ -1,29 +1,28 @@
1
- import { access, constants } from "node:fs/promises";
1
+ import { access } from "node:fs/promises";
2
2
  import { join, dirname, relative, isAbsolute } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import which_ from "which";
4
5
 
5
6
  export function fileUri(absolutePath: string): string {
6
7
  return pathToFileURL(absolutePath).href;
7
8
  }
8
9
 
10
+ // Find a binary on PATH. Delegates to the `which` package — the same resolver
11
+ // cross-spawn uses to locate the command it launches — so preflight resolution
12
+ // and the eventual spawn agree on every platform.
13
+ function isNotFoundError(err: unknown): boolean {
14
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
15
+ }
16
+
9
17
  export async function which(command: string): Promise<string | null> {
10
- if (command.includes("/")) {
11
- try {
12
- await access(command, constants.X_OK);
13
- return command;
14
- } catch {
15
- return null;
18
+ try {
19
+ return await which_(command);
20
+ } catch (err) {
21
+ if (!isNotFoundError(err)) {
22
+ console.error(`[pi-lsp-lite] failed to resolve command "${command}":`, err);
16
23
  }
24
+ return null;
17
25
  }
18
- const pathDirs = (process.env.PATH ?? "").split(":");
19
- for (const dir of pathDirs) {
20
- const candidate = join(dir, command);
21
- try {
22
- await access(candidate, constants.X_OK);
23
- return candidate;
24
- } catch {}
25
- }
26
- return null;
27
26
  }
28
27
 
29
28
  export async function findWorkspaceRoot(filePath: string, rootPatterns: string[], cwd: string): Promise<string> {