@narumitw/pi-lsp 0.14.1 → 0.15.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
@@ -41,12 +41,14 @@ If no config is provided, pi-lsp ships compatible defaults for Biome, ty, and Ru
41
41
  Custom config can be supplied in one of these locations:
42
42
 
43
43
  1. `PI_LSP_CONFIG` as inline JSON or a path to a JSON file
44
- 2. `<workspace>/.pi/lsp.json`
45
- 3. `~/.pi/agent/lsp.json`
44
+ 2. `<workspace>/.pi/pi-lsp.json`
45
+ 3. `~/.pi/agent/pi-lsp.json`
46
46
 
47
47
  `PI_LSP_CONFIG` only accepts JSON or a JSON file path; JavaScript and TypeScript config files are not evaluated.
48
48
 
49
- `lsp.json` can be a plain object keyed by server name:
49
+ Compatibility: a user-scoped legacy `lsp.json` is migrated automatically. A project-scoped legacy `.pi/lsp.json` remains readable with a warning but is not renamed automatically, so the extension never modifies a repository working tree. New paths take precedence when both names exist.
50
+
51
+ `pi-lsp.json` can be a plain object keyed by server name:
50
52
 
51
53
  ```json
52
54
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-lsp",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Pi extension that exposes configurable, language-agnostic LSP tools through a shared runner.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/adapters.ts CHANGED
@@ -1,4 +1,14 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ linkSync,
6
+ lstatSync,
7
+ readFileSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
2
12
  import os from "node:os";
3
13
  import path from "node:path";
4
14
  import process from "node:process";
@@ -75,23 +85,132 @@ export function loadConfig(cwd = process.cwd()): LspConfig {
75
85
  return configured ?? { servers: DEFAULT_SERVER_CONFIGS };
76
86
  }
77
87
 
88
+ let pendingConfigNotice: string | undefined;
89
+
78
90
  function loadConfiguredConfig(cwd: string): LspConfig | undefined {
91
+ pendingConfigNotice = undefined;
79
92
  const rawConfig = process.env.PI_LSP_CONFIG?.trim();
80
93
  if (rawConfig) return parseConfigSource(rawConfig, cwd, "PI_LSP_CONFIG");
81
94
 
82
- const projectConfig = path.join(cwd, ".pi", "lsp.json");
83
- if (existsSync(projectConfig)) return parseConfigFile(projectConfig);
95
+ const projectConfig = path.join(cwd, ".pi", "pi-lsp.json");
96
+ const legacyProjectConfig = path.join(cwd, ".pi", "lsp.json");
97
+ if (existsSync(projectConfig)) {
98
+ if (existsSync(legacyProjectConfig)) {
99
+ pendingConfigNotice = ".pi/lsp.json ignored because .pi/pi-lsp.json takes precedence.";
100
+ }
101
+ return parseConfigFile(projectConfig);
102
+ }
103
+ if (existsSync(legacyProjectConfig)) {
104
+ pendingConfigNotice =
105
+ "Using legacy .pi/lsp.json. Rename it to .pi/pi-lsp.json; the repository file was not modified automatically.";
106
+ return parseConfigFile(legacyProjectConfig);
107
+ }
108
+
109
+ const userConfig = path.join(getAgentDir(), "pi-lsp.json");
110
+ const legacyUserConfig = path.join(getAgentDir(), "lsp.json");
111
+ if (existsSync(userConfig)) {
112
+ if (existsSync(legacyUserConfig)) {
113
+ pendingConfigNotice = "lsp.json ignored because pi-lsp.json takes precedence.";
114
+ }
115
+ return parseConfigFile(userConfig);
116
+ }
117
+ if (!existsSync(legacyUserConfig)) return undefined;
118
+
119
+ const legacyContents = readFileSync(legacyUserConfig, "utf8");
120
+ const legacy = normalizeConfig(JSON.parse(legacyContents), legacyUserConfig);
121
+ let installedIdentity: FileIdentity;
122
+ try {
123
+ installedIdentity = installFileExclusively(
124
+ userConfig,
125
+ legacyContents,
126
+ statSync(legacyUserConfig).mode & 0o777,
127
+ );
128
+ } catch (error) {
129
+ if (existsSync(userConfig)) {
130
+ pendingConfigNotice = "lsp.json ignored because pi-lsp.json was created concurrently.";
131
+ return parseConfigFile(userConfig);
132
+ }
133
+ pendingConfigNotice = `LSP config migration failed: ${formatError(error)}. The legacy file was used for this session.`;
134
+ return legacy;
135
+ }
136
+ if (!fileContentsEqual(legacyUserConfig, legacyContents)) {
137
+ if (removeFileIfIdentityMatches(userConfig, installedIdentity, legacyContents)) {
138
+ pendingConfigNotice =
139
+ "lsp.json changed during migration; the stale pi-lsp.json snapshot was removed and the legacy file was used for this session.";
140
+ } else {
141
+ pendingConfigNotice =
142
+ "lsp.json changed during migration, but pi-lsp.json was replaced concurrently and takes precedence on the next load.";
143
+ }
144
+ return legacy;
145
+ }
146
+ try {
147
+ rmSync(legacyUserConfig);
148
+ pendingConfigNotice = "LSP config migrated from lsp.json to pi-lsp.json.";
149
+ } catch (error) {
150
+ pendingConfigNotice = `LSP config migrated to pi-lsp.json, but lsp.json could not be removed: ${formatError(error)}.`;
151
+ }
152
+ return legacy;
153
+ }
154
+
155
+ type FileIdentity = { dev: number; ino: number };
84
156
 
85
- const userConfig = path.join(getAgentDir(), "lsp.json");
86
- if (existsSync(userConfig)) return parseConfigFile(userConfig);
157
+ function installFileExclusively(filePath: string, contents: string, mode: number): FileIdentity {
158
+ const tempFile = path.join(path.dirname(filePath), `.pi-lsp.json.${randomUUID()}.tmp`);
159
+ try {
160
+ writeFileSync(tempFile, contents, { encoding: "utf8", flag: "wx", mode });
161
+ chmodSync(tempFile, mode);
162
+ const identity = lstatSync(tempFile);
163
+ linkSync(tempFile, filePath);
164
+ return { dev: identity.dev, ino: identity.ino };
165
+ } finally {
166
+ try {
167
+ rmSync(tempFile, { force: true });
168
+ } catch {
169
+ // Preserve the migration result if best-effort temp cleanup fails.
170
+ }
171
+ }
172
+ }
173
+
174
+ function removeFileIfIdentityMatches(
175
+ filePath: string,
176
+ expected: FileIdentity,
177
+ expectedContents: string,
178
+ ) {
179
+ try {
180
+ const current = lstatSync(filePath);
181
+ if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
182
+ if (readFileSync(filePath, "utf8") !== expectedContents) return false;
183
+ rmSync(filePath);
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
87
189
 
88
- return undefined;
190
+ function fileContentsEqual(filePath: string, expected: string) {
191
+ try {
192
+ return readFileSync(filePath, "utf8") === expected;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ export function consumeLspConfigNotice() {
199
+ const notice = pendingConfigNotice;
200
+ pendingConfigNotice = undefined;
201
+ return notice;
202
+ }
203
+
204
+ function formatError(error: unknown) {
205
+ return error instanceof Error ? error.message : String(error);
89
206
  }
90
207
 
91
208
  function parseConfigSource(source: string, cwd: string, label: string): LspConfig {
92
209
  if (source.startsWith("{")) return normalizeConfig(JSON.parse(source), label);
93
210
  const expandedSource = expandHome(source);
94
- const filePath = path.isAbsolute(expandedSource) ? expandedSource : path.resolve(cwd, expandedSource);
211
+ const filePath = path.isAbsolute(expandedSource)
212
+ ? expandedSource
213
+ : path.resolve(cwd, expandedSource);
95
214
  return parseConfigFile(filePath);
96
215
  }
97
216
 
@@ -115,7 +234,9 @@ function normalizeConfig(value: unknown, label: string): LspConfig {
115
234
  const timeout = normalizeTimeout(value.timeout, label);
116
235
  const servers = value.servers;
117
236
  if (!isRecord(servers) || Array.isArray(servers)) {
118
- throw new Error(`${label}.servers must be a JSON object mapping server names to LSP server config.`);
237
+ throw new Error(
238
+ `${label}.servers must be a JSON object mapping server names to LSP server config.`,
239
+ );
119
240
  }
120
241
  return { timeout, servers: normalizeServerMap(servers, `${label}.servers`) };
121
242
  }
@@ -128,14 +249,13 @@ function normalizeConfig(value: unknown, label: string): LspConfig {
128
249
  }
129
250
 
130
251
  function normalizeServerMap(value: Record<string, unknown>, label: string) {
131
- return Object.entries(value).map(([name, server]) => normalizeServer(name, server, `${label}.${name}`));
252
+ return Object.entries(value).map(([name, server]) =>
253
+ normalizeServer(name, server, `${label}.${name}`),
254
+ );
132
255
  }
133
256
 
134
257
  function isServerEntry(value: unknown) {
135
- return (
136
- isRecord(value) &&
137
- (Array.isArray(value.command) || Array.isArray(value.extensions))
138
- );
258
+ return isRecord(value) && (Array.isArray(value.command) || Array.isArray(value.extensions));
139
259
  }
140
260
 
141
261
  function normalizeServer(name: string, value: unknown, label: string): InternalLspServer {
@@ -198,7 +318,10 @@ const LANGUAGE_IDS: Record<string, string> = {
198
318
  };
199
319
 
200
320
  function commandFromEnvName(name: string): string {
201
- return name.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
321
+ return name
322
+ .replace(/[^a-zA-Z0-9]+/g, "_")
323
+ .replace(/^_+|_+$/g, "")
324
+ .toUpperCase();
202
325
  }
203
326
 
204
327
  function envName(name: string, suffix: "COMMAND") {
package/src/pi-lsp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { loadRuntime } from "./adapters.js";
3
+ import { consumeLspConfigNotice, loadRuntime } from "./adapters.js";
4
4
  import { commandExists, commandFromEnv } from "./command.js";
5
5
  import { resolveRoot } from "./files.js";
6
6
  import { selectDiagnosticRoutes, selectFixRoute } from "./routes.js";
@@ -133,13 +133,26 @@ export default function lsp(pi: ExtensionAPI) {
133
133
  pi.registerCommand("lsp", {
134
134
  description: "Show shared LSP extension configuration",
135
135
  handler: async (_args, ctx) => {
136
- const { adapters } = loadRuntime(ctx.cwd);
137
- ctx.ui.notify(buildStatusMessage(adapters, ctx.cwd), statusLevel(adapters, ctx.cwd));
136
+ try {
137
+ const { adapters } = loadRuntime(ctx.cwd);
138
+ const notice = consumeLspConfigNotice();
139
+ if (notice) ctx.ui.notify(notice, "warning");
140
+ ctx.ui.notify(buildStatusMessage(adapters, ctx.cwd), statusLevel(adapters, ctx.cwd));
141
+ } catch (error) {
142
+ ctx.ui.notify(`LSP config ignored: ${formatError(error)}`, "warning");
143
+ }
138
144
  },
139
145
  });
140
146
 
141
147
  pi.on("session_start", (_event, ctx) => {
142
148
  ctx.ui.setStatus(STATUS_KEY, undefined);
149
+ try {
150
+ loadRuntime(ctx.cwd);
151
+ const notice = consumeLspConfigNotice();
152
+ if (notice) ctx.ui.notify(notice, "warning");
153
+ } catch (error) {
154
+ ctx.ui.notify(`LSP config ignored: ${formatError(error)}`, "warning");
155
+ }
143
156
  });
144
157
 
145
158
  pi.on("session_shutdown", (_event, ctx) => {
@@ -147,6 +160,10 @@ export default function lsp(pi: ExtensionAPI) {
147
160
  });
148
161
  }
149
162
 
163
+ function formatError(error: unknown) {
164
+ return error instanceof Error ? error.message : String(error);
165
+ }
166
+
150
167
  function textFromResult(result: { content?: Array<{ type?: string; text?: string }> }) {
151
168
  return result.content?.find((item) => item.type === "text")?.text ?? "";
152
169
  }