@dreki-gg/pi-lsp 0.3.0 → 0.4.1

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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Effect programs backing the unified `lsp` tool.
3
+ *
4
+ * Each operation is an Effect that depends on the `ServerManager` service to
5
+ * locate the right client, then wraps the client's async LSP calls in
6
+ * `Effect.tryPromise` so transport failures surface as typed
7
+ * `LspOperationError`s. Routing/validation failures are typed too
8
+ * (`LspValidationError`, `NoCapableServerError`, `NoServerAvailableError`).
9
+ */
10
+
11
+ import { Context, Effect } from 'effect';
12
+
13
+ import type { LspClient } from '../client';
14
+ import {
15
+ LspOperationError,
16
+ LspValidationError,
17
+ NoCapableServerError,
18
+ NoServerAvailableError,
19
+ type LspExtensionError,
20
+ } from '../errors';
21
+ import {
22
+ formatCallHierarchy,
23
+ formatCodeActions,
24
+ formatDiagnostics,
25
+ formatDocumentSymbols,
26
+ formatHover,
27
+ formatIncomingCalls,
28
+ formatLocations,
29
+ formatOutgoingCalls,
30
+ formatWorkspaceSymbols,
31
+ } from '../formatting';
32
+ import type { Diagnostic } from '../types';
33
+ import {
34
+ FILE_ONLY_OPERATIONS,
35
+ type LspOperation,
36
+ POSITION_OPERATIONS,
37
+ QUERY_OPERATIONS,
38
+ } from '../types';
39
+
40
+ // ── ServerManager service ─────────────────────────────────────────────────────
41
+
42
+ export interface ServerManagerService {
43
+ /** Get all LSP clients that handle a given file extension. */
44
+ clientsForFile: (filePath: string) => LspClient[];
45
+ /** Get the first LSP client that handles a file and has a capability. */
46
+ clientForFileWithCapability: (filePath: string, capability: string) => LspClient | null;
47
+ /** Get any initialized client (for workspace-wide ops). */
48
+ anyClient: () => LspClient | null;
49
+ /** Current root path. */
50
+ getRootPath: () => string;
51
+ }
52
+
53
+ export class ServerManager extends Context.Tag('Lsp/ServerManager')<
54
+ ServerManager,
55
+ ServerManagerService
56
+ >() {}
57
+
58
+ export interface ToolResult {
59
+ content: { type: 'text'; text: string }[];
60
+ details: Record<string, unknown>;
61
+ }
62
+
63
+ export interface LspToolParams {
64
+ operation: LspOperation;
65
+ filePath?: string;
66
+ line?: number;
67
+ character?: number;
68
+ query?: string;
69
+ }
70
+
71
+ // ── Capability map ────────────────────────────────────────────────────────────
72
+
73
+ const CAPABILITY_MAP: Record<LspOperation, string> = {
74
+ diagnostics: 'textDocumentSync',
75
+ hover: 'hoverProvider',
76
+ goToDefinition: 'definitionProvider',
77
+ findReferences: 'referencesProvider',
78
+ goToImplementation: 'implementationProvider',
79
+ documentSymbol: 'documentSymbolProvider',
80
+ workspaceSymbol: 'workspaceSymbolProvider',
81
+ prepareCallHierarchy: 'callHierarchyProvider',
82
+ incomingCalls: 'callHierarchyProvider',
83
+ outgoingCalls: 'callHierarchyProvider',
84
+ codeActions: 'codeActionProvider',
85
+ };
86
+
87
+ // ── Helpers ────────────────────────────────────────────────────────────────────
88
+
89
+ function cleanPath(path: string): string {
90
+ return path.replace(/^@/, '');
91
+ }
92
+
93
+ function toZeroIndexed(oneIndexed: number): number {
94
+ return Math.max(0, oneIndexed - 1);
95
+ }
96
+
97
+ function ok(text: string): ToolResult {
98
+ return { content: [{ type: 'text', text }], details: {} };
99
+ }
100
+
101
+ /** Server name for error context — tolerates partially-shaped clients. */
102
+ function serverName(client: LspClient): string {
103
+ return client.config?.name ?? 'lsp';
104
+ }
105
+
106
+ /** Wrap an async LSP client call as a typed Effect. */
107
+ function call<A>(
108
+ operation: string,
109
+ server: string,
110
+ thunk: () => Promise<A>,
111
+ ): Effect.Effect<A, LspOperationError> {
112
+ return Effect.tryPromise({
113
+ try: thunk,
114
+ catch: (cause) => new LspOperationError({ operation, server, cause }),
115
+ });
116
+ }
117
+
118
+ function validate(params: LspToolParams): Effect.Effect<void, LspValidationError> {
119
+ const { operation, filePath, line, character, query } = params;
120
+ const fail = (reason: string) => Effect.fail(new LspValidationError({ reason }));
121
+
122
+ if (POSITION_OPERATIONS.includes(operation)) {
123
+ if (!filePath) return fail(`Operation '${operation}' requires filePath`);
124
+ if (line === undefined) return fail(`Operation '${operation}' requires line`);
125
+ if (character === undefined) return fail(`Operation '${operation}' requires character`);
126
+ }
127
+ if (FILE_ONLY_OPERATIONS.includes(operation)) {
128
+ if (!filePath) return fail(`Operation '${operation}' requires filePath`);
129
+ }
130
+ if (QUERY_OPERATIONS.includes(operation)) {
131
+ if (!query) return fail(`Operation '${operation}' requires query`);
132
+ }
133
+ return Effect.void;
134
+ }
135
+
136
+ // ── Program ────────────────────────────────────────────────────────────────────
137
+
138
+ export function lspToolProgram(
139
+ raw: LspToolParams,
140
+ ): Effect.Effect<ToolResult, LspExtensionError, ServerManager> {
141
+ return Effect.gen(function* () {
142
+ yield* validate(raw);
143
+
144
+ const mgr = yield* ServerManager;
145
+ const operation = raw.operation;
146
+ const filePath = raw.filePath ? cleanPath(raw.filePath) : undefined;
147
+ const rootPath = mgr.getRootPath();
148
+
149
+ if (operation === 'diagnostics') {
150
+ return yield* diagnosticsProgram(mgr, filePath!);
151
+ }
152
+
153
+ if (operation === 'workspaceSymbol') {
154
+ return yield* workspaceSymbolProgram(mgr, raw.query!, rootPath);
155
+ }
156
+
157
+ const capability = CAPABILITY_MAP[operation];
158
+ const client = mgr.clientForFileWithCapability(filePath!, capability);
159
+ if (!client) {
160
+ return yield* new NoCapableServerError({ operation, filePath: filePath! });
161
+ }
162
+
163
+ const server = serverName(client);
164
+ const line = raw.line!;
165
+ const character = raw.character!;
166
+ const pos = { line: toZeroIndexed(line), character: toZeroIndexed(character) };
167
+
168
+ switch (operation) {
169
+ case 'hover': {
170
+ const result = yield* call(operation, server, () => client.hover(filePath!, pos));
171
+ return ok(formatHover(result, filePath!, pos.line, pos.character));
172
+ }
173
+ case 'goToDefinition': {
174
+ const locs = yield* call(operation, server, () => client.definition(filePath!, pos));
175
+ return ok(
176
+ formatLocations(locs, 'Definition', filePath!, pos.line, pos.character, rootPath),
177
+ );
178
+ }
179
+ case 'findReferences': {
180
+ const locs = yield* call(operation, server, () => client.references(filePath!, pos));
181
+ return ok(
182
+ formatLocations(locs, 'References', filePath!, pos.line, pos.character, rootPath),
183
+ );
184
+ }
185
+ case 'goToImplementation': {
186
+ const locs = yield* call(operation, server, () => client.implementation(filePath!, pos));
187
+ return ok(
188
+ formatLocations(locs, 'Implementation', filePath!, pos.line, pos.character, rootPath),
189
+ );
190
+ }
191
+ case 'documentSymbol': {
192
+ const symbols = yield* call(operation, server, () => client.documentSymbol(filePath!));
193
+ return ok(formatDocumentSymbols(symbols, filePath!, rootPath));
194
+ }
195
+ case 'prepareCallHierarchy': {
196
+ const items = yield* call(operation, server, () =>
197
+ client.prepareCallHierarchy(filePath!, pos),
198
+ );
199
+ return ok(formatCallHierarchy(items, filePath!, pos.line, pos.character, rootPath));
200
+ }
201
+ case 'incomingCalls': {
202
+ const items = yield* call(operation, server, () =>
203
+ client.prepareCallHierarchy(filePath!, pos),
204
+ );
205
+ if (items.length === 0) {
206
+ return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
207
+ }
208
+ const calls = yield* call(operation, server, () => client.incomingCalls(items[0]));
209
+ return ok(formatIncomingCalls(calls, items[0], rootPath));
210
+ }
211
+ case 'outgoingCalls': {
212
+ const items = yield* call(operation, server, () =>
213
+ client.prepareCallHierarchy(filePath!, pos),
214
+ );
215
+ if (items.length === 0) {
216
+ return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
217
+ }
218
+ const calls = yield* call(operation, server, () => client.outgoingCalls(items[0]));
219
+ return ok(formatOutgoingCalls(calls, items[0], rootPath));
220
+ }
221
+ case 'codeActions': {
222
+ const diagsForFile = yield* call(operation, server, () => client.getDiagnostics(filePath!));
223
+ const zeroLine = toZeroIndexed(line);
224
+ const lineDiags = diagsForFile.filter(
225
+ (d) => d.range.start.line <= zeroLine && d.range.end.line >= zeroLine,
226
+ );
227
+ const range = {
228
+ start: { line: zeroLine, character: 0 },
229
+ end: { line: zeroLine, character: Number.MAX_SAFE_INTEGER },
230
+ };
231
+ const actions = yield* call(operation, server, () =>
232
+ client.codeActions(filePath!, range, { diagnostics: lineDiags }),
233
+ );
234
+ return ok(formatCodeActions(actions, filePath!, zeroLine));
235
+ }
236
+ default:
237
+ return yield* new LspValidationError({ reason: `Unknown operation: ${operation}` });
238
+ }
239
+ });
240
+ }
241
+
242
+ function diagnosticsProgram(
243
+ mgr: ServerManagerService,
244
+ filePath: string,
245
+ ): Effect.Effect<ToolResult, never> {
246
+ return Effect.gen(function* () {
247
+ const groups: { source: string; diagnostics: Diagnostic[] }[] = [];
248
+ const errors: string[] = [];
249
+
250
+ for (const client of mgr.clientsForFile(filePath)) {
251
+ const name = serverName(client);
252
+ const result = yield* call('diagnostics', name, () => client.getDiagnostics(filePath)).pipe(
253
+ Effect.either,
254
+ );
255
+
256
+ if (result._tag === 'Right') {
257
+ if (result.right.length > 0) {
258
+ groups.push({ source: name, diagnostics: result.right });
259
+ }
260
+ } else {
261
+ errors.push(`${name}: ${result.left.message}`);
262
+ }
263
+ }
264
+
265
+ const text = formatDiagnostics(filePath, groups);
266
+ const errorNote = errors.length > 0 ? `\n\nNote: ${errors.join('; ')}` : '';
267
+
268
+ return {
269
+ content: [{ type: 'text' as const, text: text + errorNote }],
270
+ details: {
271
+ groups: groups.map((g) => ({ source: g.source, count: g.diagnostics.length })),
272
+ errors,
273
+ },
274
+ };
275
+ });
276
+ }
277
+
278
+ function workspaceSymbolProgram(
279
+ mgr: ServerManagerService,
280
+ query: string,
281
+ rootPath: string,
282
+ ): Effect.Effect<ToolResult, LspExtensionError> {
283
+ return Effect.gen(function* () {
284
+ const client = mgr.anyClient();
285
+ if (!client) {
286
+ return yield* new NoServerAvailableError({ operation: 'workspace symbol search' });
287
+ }
288
+ const symbols = yield* call('workspaceSymbol', serverName(client), () =>
289
+ client.workspaceSymbol(query),
290
+ );
291
+ return ok(formatWorkspaceSymbols(symbols, query, rootPath));
292
+ });
293
+ }
@@ -1,97 +1,31 @@
1
1
  /**
2
2
  * Single unified `lsp` tool registration.
3
3
  *
4
- * 11 operations routed to the right server by file extension.
4
+ * 11 operations routed to the right server by file extension. The execution
5
+ * logic lives in `tools/programs.ts` as Effect programs; this module owns the
6
+ * tool schema/description and runs the program with the live `ServerManager`.
5
7
  */
6
8
 
7
9
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
10
+ import { Effect } from 'effect';
8
11
  import { Type } from 'typebox';
9
12
  import { StringEnum } from '@earendil-works/pi-ai';
10
13
 
11
- import type { LspClient } from './client';
14
+ import { toNativeError } from './errors';
12
15
  import {
13
- formatCallHierarchy,
14
- formatCodeActions,
15
- formatDiagnostics,
16
- formatDocumentSymbols,
17
- formatHover,
18
- formatIncomingCalls,
19
- formatLocations,
20
- formatOutgoingCalls,
21
- formatWorkspaceSymbols,
22
- } from './formatting';
23
- import type { Diagnostic } from './types';
24
- import {
25
- FILE_ONLY_OPERATIONS,
26
- LSP_OPERATIONS,
27
- type LspOperation,
28
- POSITION_OPERATIONS,
29
- QUERY_OPERATIONS,
30
- } from './types';
31
-
32
- // ── Helpers ─────────────────────────────────────────────────────────────────
33
-
34
- function cleanPath(path: string): string {
35
- return path.replace(/^@/, '');
36
- }
37
-
38
- function toZeroIndexed(oneIndexed: number): number {
39
- return Math.max(0, oneIndexed - 1);
40
- }
41
-
42
- function validateParams(
43
- operation: LspOperation,
44
- filePath?: string,
45
- line?: number,
46
- character?: number,
47
- query?: string,
48
- ): string | null {
49
- if (POSITION_OPERATIONS.includes(operation)) {
50
- if (!filePath) return `Operation '${operation}' requires filePath`;
51
- if (line === undefined) return `Operation '${operation}' requires line`;
52
- if (character === undefined) return `Operation '${operation}' requires character`;
53
- }
54
- if (FILE_ONLY_OPERATIONS.includes(operation)) {
55
- if (!filePath) return `Operation '${operation}' requires filePath`;
56
- }
57
- if (QUERY_OPERATIONS.includes(operation)) {
58
- if (!query) return `Operation '${operation}' requires query`;
59
- }
60
- return null;
61
- }
62
-
63
- // ── Types ───────────────────────────────────────────────────────────────────
64
-
65
- export interface ServerManager {
66
- /** Get all LSP clients that handle a given file extension. */
67
- clientsForFile: (filePath: string) => LspClient[];
68
- /** Get the first LSP client that handles a file and has a capability. */
69
- clientForFileWithCapability: (filePath: string, capability: string) => LspClient | null;
70
- /** Get any initialized client (for workspace-wide ops). */
71
- anyClient: () => LspClient | null;
72
- /** Current root path. */
73
- getRootPath: () => string;
74
- }
75
-
76
- // ── Capability map ──────────────────────────────────────────────────────────
16
+ lspToolProgram,
17
+ ServerManager,
18
+ type LspToolParams,
19
+ type ServerManagerService,
20
+ } from './tools/programs';
21
+ import { LSP_OPERATIONS, type LspOperation } from './types';
77
22
 
78
- const CAPABILITY_MAP: Record<LspOperation, string> = {
79
- diagnostics: 'textDocumentSync', // all servers with sync support
80
- hover: 'hoverProvider',
81
- goToDefinition: 'definitionProvider',
82
- findReferences: 'referencesProvider',
83
- goToImplementation: 'implementationProvider',
84
- documentSymbol: 'documentSymbolProvider',
85
- workspaceSymbol: 'workspaceSymbolProvider',
86
- prepareCallHierarchy: 'callHierarchyProvider',
87
- incomingCalls: 'callHierarchyProvider',
88
- outgoingCalls: 'callHierarchyProvider',
89
- codeActions: 'codeActionProvider',
90
- };
23
+ export type { ServerManagerService } from './tools/programs';
24
+ export { ServerManager } from './tools/programs';
91
25
 
92
26
  // ── Registration ────────────────────────────────────────────────────────────
93
27
 
94
- export function registerLspTool(pi: ExtensionAPI, mgr: ServerManager) {
28
+ export function registerLspTool(pi: ExtensionAPI, mgr: ServerManagerService) {
95
29
  pi.registerTool({
96
30
  name: 'lsp',
97
31
  label: 'LSP',
@@ -144,158 +78,12 @@ export function registerLspTool(pi: ExtensionAPI, mgr: ServerManager) {
144
78
  query: Type.Optional(Type.String({ description: 'Search query (for workspaceSymbol)' })),
145
79
  }),
146
80
  async execute(_toolCallId, params) {
147
- const operation = params.operation as LspOperation;
148
- const filePath = params.filePath ? cleanPath(params.filePath) : undefined;
149
- const line = params.line;
150
- const character = params.character;
151
- const query = params.query;
152
- const rootPath = mgr.getRootPath();
153
-
154
- // Validate required params
155
- const validationError = validateParams(operation, filePath, line, character, query);
156
- if (validationError) throw new Error(validationError);
157
-
158
- // ── diagnostics (aggregate from all matching servers) ──
159
- if (operation === 'diagnostics') {
160
- return executeDiagnostics(mgr, filePath!, rootPath);
161
- }
162
-
163
- // ── workspaceSymbol (doesn't need a file-based server lookup) ──
164
- if (operation === 'workspaceSymbol') {
165
- return executeWorkspaceSymbol(mgr, query!, rootPath);
166
- }
167
-
168
- // ── all other operations: route to first capable server ──
169
- const capability = CAPABILITY_MAP[operation];
170
- const client = mgr.clientForFileWithCapability(filePath!, capability);
171
- if (!client) {
172
- throw new Error(
173
- `No LSP server with '${operation}' capability found for ${filePath}. Check /lsp status.`,
174
- );
175
- }
176
-
177
- const pos = { line: toZeroIndexed(line!), character: toZeroIndexed(character!) };
178
-
179
- switch (operation) {
180
- case 'hover': {
181
- const result = await client.hover(filePath!, pos);
182
- return ok(formatHover(result, filePath!, pos.line, pos.character));
183
- }
184
-
185
- case 'goToDefinition': {
186
- const locs = await client.definition(filePath!, pos);
187
- return ok(
188
- formatLocations(locs, 'Definition', filePath!, pos.line, pos.character, rootPath),
189
- );
190
- }
191
-
192
- case 'findReferences': {
193
- const locs = await client.references(filePath!, pos);
194
- return ok(
195
- formatLocations(locs, 'References', filePath!, pos.line, pos.character, rootPath),
196
- );
197
- }
198
-
199
- case 'goToImplementation': {
200
- const locs = await client.implementation(filePath!, pos);
201
- return ok(
202
- formatLocations(locs, 'Implementation', filePath!, pos.line, pos.character, rootPath),
203
- );
204
- }
205
-
206
- case 'documentSymbol': {
207
- const symbols = await client.documentSymbol(filePath!);
208
- return ok(formatDocumentSymbols(symbols, filePath!, rootPath));
209
- }
210
-
211
- case 'prepareCallHierarchy': {
212
- const items = await client.prepareCallHierarchy(filePath!, pos);
213
- return ok(formatCallHierarchy(items, filePath!, pos.line, pos.character, rootPath));
214
- }
215
-
216
- case 'incomingCalls': {
217
- const items = await client.prepareCallHierarchy(filePath!, pos);
218
- if (items.length === 0) {
219
- return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
220
- }
221
- const calls = await client.incomingCalls(items[0]);
222
- return ok(formatIncomingCalls(calls, items[0], rootPath));
223
- }
224
-
225
- case 'outgoingCalls': {
226
- const items = await client.prepareCallHierarchy(filePath!, pos);
227
- if (items.length === 0) {
228
- return ok(`No call hierarchy item at ${filePath!}:${line}:${character}`);
229
- }
230
- const calls = await client.outgoingCalls(items[0]);
231
- return ok(formatOutgoingCalls(calls, items[0], rootPath));
232
- }
233
-
234
- case 'codeActions': {
235
- const diagsForFile = await client.getDiagnostics(filePath!);
236
- const zeroLine = toZeroIndexed(line!);
237
- const lineDiags = diagsForFile.filter(
238
- (d) => d.range.start.line <= zeroLine && d.range.end.line >= zeroLine,
239
- );
240
- const range = {
241
- start: { line: zeroLine, character: 0 },
242
- end: { line: zeroLine, character: Number.MAX_SAFE_INTEGER },
243
- };
244
- const actions = await client.codeActions(filePath!, range, { diagnostics: lineDiags });
245
- return ok(formatCodeActions(actions, filePath!, zeroLine));
246
- }
247
-
248
- default:
249
- throw new Error(`Unknown operation: ${operation}`);
250
- }
81
+ const program = lspToolProgram(params as LspToolParams);
82
+ return Effect.runPromise(
83
+ program.pipe(Effect.provideService(ServerManager, mgr), Effect.mapError(toNativeError)),
84
+ );
251
85
  },
252
86
  });
253
87
  }
254
88
 
255
- // ── Operation executors ─────────────────────────────────────────────────────
256
-
257
- async function executeDiagnostics(mgr: ServerManager, filePath: string, _rootPath: string) {
258
- const groups: { source: string; diagnostics: Diagnostic[] }[] = [];
259
- const errors: string[] = [];
260
-
261
- // Gather from all LSP servers that handle this file
262
- const clients = mgr.clientsForFile(filePath);
263
- for (const client of clients) {
264
- try {
265
- const diags = await client.getDiagnostics(filePath);
266
- if (diags.length > 0) {
267
- groups.push({ source: client.config.name, diagnostics: diags });
268
- }
269
- } catch (err) {
270
- errors.push(`${client.config.name}: ${(err as Error).message}`);
271
- }
272
- }
273
-
274
- const text = formatDiagnostics(filePath, groups);
275
- const errorNote = errors.length > 0 ? `\n\nNote: ${errors.join('; ')}` : '';
276
-
277
- return {
278
- content: [{ type: 'text' as const, text: text + errorNote }],
279
- details: {
280
- groups: groups.map((g) => ({ source: g.source, count: g.diagnostics.length })),
281
- errors,
282
- },
283
- };
284
- }
285
-
286
- async function executeWorkspaceSymbol(mgr: ServerManager, query: string, rootPath: string) {
287
- const client = mgr.anyClient();
288
- if (!client) throw new Error('No LSP server available for workspace symbol search.');
289
-
290
- const symbols = await client.workspaceSymbol(query);
291
- return ok(formatWorkspaceSymbols(symbols, query, rootPath));
292
- }
293
-
294
- // ── Helpers ─────────────────────────────────────────────────────────────────
295
-
296
- function ok(text: string) {
297
- return {
298
- content: [{ type: 'text' as const, text }],
299
- details: {},
300
- };
301
- }
89
+ export type { LspOperation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-lsp",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Language-agnostic LSP code intelligence for pi — diagnostics, hover, definitions, references, symbols, and call hierarchy",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -25,6 +25,9 @@
25
25
  "./extensions/lsp"
26
26
  ]
27
27
  },
28
+ "dependencies": {
29
+ "effect": "^3.21.2"
30
+ },
28
31
  "devDependencies": {
29
32
  "@earendil-works/pi-ai": "^0.74.0",
30
33
  "@types/node": "24",
@@ -56,6 +56,40 @@ describe('config loader', () => {
56
56
  expect(again).toBe(false);
57
57
  });
58
58
 
59
+ test('scaffolded starter config does not enable typescript (or any) server by default', async () => {
60
+ const home = await makeTempDir('pi-lsp-home-');
61
+ const cwd = await makeTempDir('pi-lsp-cwd-');
62
+ process.env.HOME = home;
63
+
64
+ await scaffoldGlobalConfig(cwd);
65
+
66
+ const globalConfigPath = join(home, '.pi', 'agent', 'extensions', 'lsp', 'config.json');
67
+ const parsed = JSON.parse(await readFile(globalConfigPath, 'utf8'));
68
+ // Every example server is opt-in (disabled), so typescript is not a default.
69
+ for (const server of Object.values(parsed.lsp as Record<string, { disabled?: boolean }>)) {
70
+ expect(server.disabled).toBe(true);
71
+ }
72
+
73
+ const config = await loadConfig(cwd);
74
+ expect(config.globalDisabled).toBe(false);
75
+ expect(config.servers).toEqual([]);
76
+ });
77
+
78
+ test('disabled servers are excluded even when their command exists', async () => {
79
+ const home = await makeTempDir('pi-lsp-home-');
80
+ const cwd = await makeTempDir('pi-lsp-cwd-');
81
+ process.env.HOME = home;
82
+
83
+ await writeJson(join(cwd, '.pi', 'lsp.json'), {
84
+ lsp: {
85
+ node: { command: ['node'], extensions: ['.js'], disabled: true },
86
+ },
87
+ });
88
+
89
+ const config = await loadConfig(cwd);
90
+ expect(config.servers).toEqual([]);
91
+ });
92
+
59
93
  test('does not scaffold when project config exists', async () => {
60
94
  const home = await makeTempDir('pi-lsp-home-');
61
95
  const cwd = await makeTempDir('pi-lsp-cwd-');