howtofixcli 0.7.11 → 0.7.13

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.
Files changed (154) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/account-credential.test.js +38 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +228 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +120 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +294 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/context-extension.js +17 -0
  24. package/dist/dcp/commands.js +172 -0
  25. package/dist/dcp/compress-state.js +139 -0
  26. package/dist/dcp/compress.js +329 -0
  27. package/dist/dcp/dcp-extension.js +295 -0
  28. package/dist/dcp/dedup.js +93 -0
  29. package/dist/dcp/ids.js +189 -0
  30. package/dist/dcp/index.js +25 -0
  31. package/dist/dcp/middle-prune.js +76 -0
  32. package/dist/dcp/prompts.js +68 -0
  33. package/dist/dcp/protected-content.js +221 -0
  34. package/dist/dcp/protected.js +161 -0
  35. package/dist/dcp/prune.js +418 -0
  36. package/dist/dcp/state.js +176 -0
  37. package/dist/device-id.js +90 -0
  38. package/dist/device-id.test.js +23 -0
  39. package/dist/errors.js +63 -0
  40. package/dist/extensions.js +48 -0
  41. package/dist/fatal.js +35 -0
  42. package/dist/feed.js +498 -0
  43. package/dist/fs-atomic.js +54 -0
  44. package/dist/gateway-errors.js +90 -0
  45. package/dist/git.js +230 -0
  46. package/dist/goal-extension.js +88 -0
  47. package/dist/goal.js +222 -0
  48. package/dist/hooks-extension.js +176 -0
  49. package/dist/hooks.js +443 -0
  50. package/dist/howtofix-account.js +735 -0
  51. package/dist/hud-line.js +153 -0
  52. package/dist/hwid.js +130 -0
  53. package/dist/interrupt.js +60 -0
  54. package/dist/json-events.js +50 -0
  55. package/dist/level-style.js +112 -0
  56. package/dist/loop-guard.js +95 -0
  57. package/dist/lsp-tools.js +32 -0
  58. package/dist/lsp.js +429 -0
  59. package/dist/main.js +540 -94
  60. package/dist/mcp-config.js +85 -0
  61. package/dist/mcp-lifecycle.js +147 -0
  62. package/dist/model-catalog.js +217 -0
  63. package/dist/model-facts.js +35 -0
  64. package/dist/model-resolve.js +65 -0
  65. package/dist/notes.js +78 -0
  66. package/dist/oauth-login.js +568 -0
  67. package/dist/paths.js +61 -0
  68. package/dist/permissions.js +298 -0
  69. package/dist/plan-mode.js +363 -0
  70. package/dist/preset-command.js +227 -0
  71. package/dist/preset-extension.js +35 -0
  72. package/dist/preset-mcp.js +292 -0
  73. package/dist/presets.js +727 -0
  74. package/dist/prompt-files.js +103 -0
  75. package/dist/prompt-text.js +41 -0
  76. package/dist/proxy.js +137 -0
  77. package/dist/quality/ast.js +196 -0
  78. package/dist/quality/cognitive.js +83 -0
  79. package/dist/quality/cyclomatic.js +30 -0
  80. package/dist/quality/halstead.js +83 -0
  81. package/dist/quality/index.js +18 -0
  82. package/dist/quality/length.js +3 -0
  83. package/dist/quality/maintainability.js +15 -0
  84. package/dist/quality/nesting.js +57 -0
  85. package/dist/quality/report.js +803 -0
  86. package/dist/quality/smells.js +67 -0
  87. package/dist/quality-tool.js +63 -0
  88. package/dist/read-only-commands.js +597 -0
  89. package/dist/repo-map.js +292 -0
  90. package/dist/rtk.js +193 -0
  91. package/dist/sandbox.js +105 -0
  92. package/dist/schema.js +214 -0
  93. package/dist/session-lifecycle.js +19 -0
  94. package/dist/session-retention.js +111 -0
  95. package/dist/session.js +447 -0
  96. package/dist/sessions.js +100 -0
  97. package/dist/settings.js +148 -0
  98. package/dist/skills.js +35 -0
  99. package/dist/slop-tool.js +96 -0
  100. package/dist/spend-cap.js +43 -0
  101. package/dist/subagent.js +171 -0
  102. package/dist/tar.js +146 -0
  103. package/dist/theme.js +79 -0
  104. package/dist/thinking.js +45 -0
  105. package/dist/todo.js +114 -0
  106. package/dist/tool-loadout.js +97 -0
  107. package/dist/tool-render.js +769 -0
  108. package/dist/tools.js +176 -0
  109. package/dist/ui.js +4778 -0
  110. package/dist/update.js +551 -0
  111. package/dist/usage-segment.js +69 -0
  112. package/dist/web-tools.js +46 -0
  113. package/dist/web.js +265 -0
  114. package/dist/welcome.js +179 -0
  115. package/dist/workflow-engine.js +246 -0
  116. package/dist/workflow-progress.js +446 -0
  117. package/dist/workflow-view.js +195 -0
  118. package/dist/workflow.js +349 -0
  119. package/package.json +63 -16
  120. package/dist/chunk-27TRKD65.js +0 -2
  121. package/dist/chunk-3FYCFLNK.js +0 -1
  122. package/dist/chunk-56SEAGAQ.js +0 -1
  123. package/dist/chunk-6FUWMLWO.js +0 -1
  124. package/dist/chunk-7CHDNTH6.js +0 -1
  125. package/dist/chunk-ACOMNDUL.js +0 -185
  126. package/dist/chunk-AGGIYXN7.js +0 -2
  127. package/dist/chunk-BHVBO4SC.js +0 -1
  128. package/dist/chunk-C6HY4Q4J.js +0 -5
  129. package/dist/chunk-CMRKXR6X.js +0 -5
  130. package/dist/chunk-DMNE4TU6.js +0 -2
  131. package/dist/chunk-EHLH4G5R.js +0 -1
  132. package/dist/chunk-FPXVDP32.js +0 -8
  133. package/dist/chunk-GVTLSEBC.js +0 -2
  134. package/dist/chunk-GXB7RZHH.js +0 -1
  135. package/dist/chunk-ILDDDLRC.js +0 -1
  136. package/dist/chunk-JFYVRWJI.js +0 -2
  137. package/dist/chunk-K6ZKVFKO.js +0 -34
  138. package/dist/chunk-MVLCGU3G.js +0 -1
  139. package/dist/chunk-N345KWWK.js +0 -1
  140. package/dist/chunk-ORJ2QZBG.js +0 -3
  141. package/dist/chunk-PCALBZOO.js +0 -2
  142. package/dist/chunk-QERX2QO6.js +0 -1
  143. package/dist/chunk-S4TNEP7G.js +0 -109
  144. package/dist/chunk-T5YJVQXI.js +0 -6
  145. package/dist/chunk-TSEKPGK4.js +0 -1
  146. package/dist/chunk-UTYFLLSM.js +0 -1
  147. package/dist/chunk-V3R4FYTJ.js +0 -1
  148. package/dist/chunk-V656XVXV.js +0 -8
  149. package/dist/chunk-VXTUPGAD.js +0 -2
  150. package/dist/chunk-WKUJ7CFO.js +0 -1
  151. package/dist/chunk-WOT6VMZA.js +0 -1
  152. package/dist/chunk-WUPU6LTD.js +0 -4
  153. package/dist/chunk-WX3V5P7U.js +0 -5
  154. package/dist/chunk-WXPWYEZK.js +0 -1
package/dist/lsp.js ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * LSP over stdio: `Content-Length` framing, a stream-driven client, the server
3
+ * table, and `askLsp`.
4
+ *
5
+ * Client lifecycle is process-wide: one client per server command, shared by
6
+ * every session and subagent — two agents on one TS project must not spawn two
7
+ * `typescript-language-server`s. Clients are killed when the main session
8
+ * disposes (`disposeAllLsp` from `openSession` in `session.ts`) and on process
9
+ * `exit`, which is registered here. Because the pool is keyed by command, the
10
+ * first spawn's root wins for the process; HowToFix runs one project per
11
+ * process, so that is the common case by design.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { delimiter, isAbsolute, join } from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+ export function encodeMessage(message) {
18
+ const body = JSON.stringify(message);
19
+ return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
20
+ }
21
+ /**
22
+ * The largest frame a language server may send.
23
+ *
24
+ * A server that announces a body this big is not sending a message, it is
25
+ * desynchronised or hostile. Waiting for the rest of it would grow the buffer
26
+ * until the process dies, so the frame is refused instead.
27
+ */
28
+ export const MAX_FRAME_BYTES = 32 * 1024 * 1024;
29
+ export function decodeMessages(buffer) {
30
+ const messages = [];
31
+ let offset = 0;
32
+ while (offset < buffer.length) {
33
+ const headerEnd = buffer.indexOf("\r\n\r\n", offset);
34
+ if (headerEnd === -1)
35
+ break;
36
+ const header = buffer.subarray(offset, headerEnd).toString("utf8");
37
+ const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
38
+ if (!lengthMatch)
39
+ break;
40
+ const length = Number(lengthMatch[1]);
41
+ if (!Number.isSafeInteger(length) || length < 0 || length > MAX_FRAME_BYTES) {
42
+ throw new Error(`lsp frame of ${lengthMatch[1]} bytes refused`);
43
+ }
44
+ const bodyStart = headerEnd + 4;
45
+ const bodyEnd = bodyStart + length;
46
+ if (bodyEnd > buffer.length)
47
+ break;
48
+ const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8");
49
+ // One malformed frame is skipped, not fatal: a language server that emits a
50
+ // bad message must not take the CLI down in the middle of a session.
51
+ try {
52
+ const parsed = JSON.parse(body);
53
+ if (parsed && typeof parsed === "object")
54
+ messages.push(parsed);
55
+ }
56
+ catch {
57
+ // dropped on purpose; the stream stays aligned because the length was read
58
+ }
59
+ offset = bodyEnd;
60
+ }
61
+ return { messages, rest: buffer.subarray(offset) };
62
+ }
63
+ export class LspClient {
64
+ io;
65
+ nextId = 1;
66
+ pending = new Map();
67
+ buffer = Buffer.alloc(0);
68
+ broken = null;
69
+ opened = new Set();
70
+ diagnosticsByUri = new Map();
71
+ diagnosticsWaiters = new Map();
72
+ constructor(io) {
73
+ this.io = io;
74
+ io.stdout.on("data", (chunk) => {
75
+ if (this.broken)
76
+ return;
77
+ this.buffer = Buffer.concat([this.buffer, chunk]);
78
+ let decoded;
79
+ try {
80
+ decoded = decodeMessages(this.buffer);
81
+ }
82
+ catch (error) {
83
+ // A stream we cannot trust is dropped whole. Keeping it would let a
84
+ // desynchronised server grow the buffer without bound, and every
85
+ // request queued behind it would wait out its timeout for nothing.
86
+ this.buffer = Buffer.alloc(0);
87
+ this.fail(error instanceof Error ? error : new Error(String(error)));
88
+ return;
89
+ }
90
+ this.buffer = decoded.rest;
91
+ for (const message of decoded.messages)
92
+ this.dispatch(message);
93
+ });
94
+ // A server that dies leaves the pipe behind: the next write raises EPIPE on
95
+ // the stream, and with no listener that becomes an uncaught exception and
96
+ // takes the CLI down. Failing the client turns it into a normal error.
97
+ io.stdin.on("error", (error) => {
98
+ this.fail(error instanceof Error ? error : new Error(String(error)));
99
+ });
100
+ }
101
+ /** Mark the stream unusable and answer every queued request with the reason. */
102
+ fail(error) {
103
+ if (this.broken)
104
+ return;
105
+ this.broken = error;
106
+ for (const [id, waiter] of this.pending) {
107
+ this.pending.delete(id);
108
+ waiter.reject(error);
109
+ }
110
+ }
111
+ dispatch(message) {
112
+ if (message.method === "textDocument/publishDiagnostics") {
113
+ const params = message.params;
114
+ if (params?.uri) {
115
+ const list = Array.isArray(params.diagnostics) ? params.diagnostics : [];
116
+ this.diagnosticsByUri.set(params.uri, list);
117
+ const waiters = this.diagnosticsWaiters.get(params.uri);
118
+ if (waiters) {
119
+ this.diagnosticsWaiters.delete(params.uri);
120
+ for (const waiter of waiters)
121
+ waiter(list);
122
+ }
123
+ }
124
+ return;
125
+ }
126
+ if (message.id === undefined)
127
+ return; // other notifications ignored in v1
128
+ const waiter = this.pending.get(Number(message.id));
129
+ if (!waiter)
130
+ return;
131
+ this.pending.delete(Number(message.id));
132
+ if (message.error)
133
+ waiter.reject(new Error(message.error.message));
134
+ else
135
+ waiter.resolve(message.result);
136
+ }
137
+ request(method, params, timeoutMs = 10_000) {
138
+ if (this.broken)
139
+ return Promise.reject(this.broken);
140
+ const id = this.nextId++;
141
+ const payload = encodeMessage({ jsonrpc: "2.0", id, method, params });
142
+ const { promise, resolve, reject } = Promise.withResolvers();
143
+ const timer = setTimeout(() => {
144
+ this.pending.delete(id);
145
+ reject(new Error(`lsp ${method} timed out`));
146
+ }, timeoutMs);
147
+ this.pending.set(id, {
148
+ resolve: (value) => {
149
+ clearTimeout(timer);
150
+ resolve(value);
151
+ },
152
+ reject: (error) => {
153
+ clearTimeout(timer);
154
+ reject(error);
155
+ },
156
+ });
157
+ try {
158
+ this.io.stdin.write(payload);
159
+ }
160
+ catch (error) {
161
+ // A pipe that is already gone throws here instead of emitting; the caller
162
+ // must see a failure, not a request that waits out its timeout.
163
+ const reason = error instanceof Error ? error : new Error(String(error));
164
+ this.pending.delete(id);
165
+ clearTimeout(timer);
166
+ reject(reason);
167
+ this.fail(reason);
168
+ }
169
+ return promise;
170
+ }
171
+ notify(method, params) {
172
+ if (this.broken)
173
+ return;
174
+ try {
175
+ this.io.stdin.write(encodeMessage({ jsonrpc: "2.0", method, params }));
176
+ }
177
+ catch (error) {
178
+ // Notifications have no caller waiting, so a dead pipe would surface as an
179
+ // uncaught exception from a fire-and-forget write. Record it instead.
180
+ this.fail(error instanceof Error ? error : new Error(String(error)));
181
+ }
182
+ }
183
+ async initialize() {
184
+ await this.request("initialize", {
185
+ processId: null,
186
+ rootUri: pathToFileURL(this.io.root).toString(),
187
+ capabilities: { textDocument: { hover: {}, definition: {}, references: {}, documentSymbol: {}, publishDiagnostics: {} } },
188
+ });
189
+ this.notify("initialized", {});
190
+ }
191
+ // Positions are 0-based on the wire; callers that take editor-style 1-based lines subtract one before calling.
192
+ async hover(file, line, character) {
193
+ const result = (await this.request("textDocument/hover", {
194
+ textDocument: { uri: pathToFileURL(file).toString() },
195
+ position: { line, character },
196
+ }));
197
+ return result?.contents?.value;
198
+ }
199
+ async definition(file, line, character) {
200
+ const result = (await this.request("textDocument/definition", {
201
+ textDocument: { uri: pathToFileURL(file).toString() },
202
+ position: { line, character },
203
+ }));
204
+ if (Array.isArray(result))
205
+ return result[0];
206
+ return result ?? undefined;
207
+ }
208
+ async references(file, line, character) {
209
+ const result = await this.request("textDocument/references", {
210
+ textDocument: { uri: pathToFileURL(file).toString() },
211
+ position: { line, character },
212
+ context: { includeDeclaration: true },
213
+ });
214
+ return Array.isArray(result) ? result : [];
215
+ }
216
+ async documentSymbols(file) {
217
+ const result = await this.request("textDocument/documentSymbol", {
218
+ textDocument: { uri: pathToFileURL(file).toString() },
219
+ });
220
+ return Array.isArray(result) ? result : [];
221
+ }
222
+ /**
223
+ * Stored `publishDiagnostics` for the file, waiting up to `timeoutMs` for the
224
+ * first publish. A server that never publishes gets `[]`, never a hang.
225
+ */
226
+ diagnostics(file, timeoutMs = 2_000) {
227
+ const uri = pathToFileURL(file).toString();
228
+ const stored = this.diagnosticsByUri.get(uri);
229
+ if (stored)
230
+ return Promise.resolve(stored);
231
+ const { promise, resolve } = Promise.withResolvers();
232
+ const timer = setTimeout(() => {
233
+ const waiters = this.diagnosticsWaiters.get(uri);
234
+ if (waiters) {
235
+ this.diagnosticsWaiters.delete(uri);
236
+ for (const waiter of waiters)
237
+ waiter(this.diagnosticsByUri.get(uri) ?? []);
238
+ }
239
+ }, timeoutMs);
240
+ const waiters = this.diagnosticsWaiters.get(uri) ?? [];
241
+ waiters.push((value) => {
242
+ clearTimeout(timer);
243
+ resolve(value);
244
+ });
245
+ this.diagnosticsWaiters.set(uri, waiters);
246
+ return promise;
247
+ }
248
+ async ensureOpen(file, text, languageId) {
249
+ const uri = pathToFileURL(file).toString();
250
+ if (this.opened.has(uri))
251
+ return;
252
+ this.notify("textDocument/didOpen", {
253
+ textDocument: { uri, languageId, version: 1, text },
254
+ });
255
+ this.opened.add(uri);
256
+ }
257
+ dispose() {
258
+ for (const [id, waiter] of this.pending) {
259
+ waiter.reject(new Error("lsp client disposed"));
260
+ this.pending.delete(id);
261
+ }
262
+ // Flush diagnostic waiters so a dispose mid-query answers [] instead of
263
+ // leaving the timer to fire into a dead client.
264
+ for (const [uri, waiters] of this.diagnosticsWaiters) {
265
+ const stored = this.diagnosticsByUri.get(uri) ?? [];
266
+ this.diagnosticsWaiters.delete(uri);
267
+ for (const waiter of waiters)
268
+ waiter(stored);
269
+ }
270
+ }
271
+ }
272
+ export const SERVERS = [
273
+ {
274
+ command: "typescript-language-server",
275
+ args: ["--stdio"],
276
+ languageId: "typescript",
277
+ match: (file) => /\.(ts|tsx|js|jsx|mts|cts)$/.test(file),
278
+ },
279
+ { command: "rust-analyzer", args: [], languageId: "rust", match: (file) => file.endsWith(".rs") },
280
+ { command: "gopls", args: [], languageId: "go", match: (file) => file.endsWith(".go") },
281
+ { command: "pylsp", args: [], languageId: "python", match: (file) => file.endsWith(".py") },
282
+ ];
283
+ export function specFor(file) {
284
+ return SERVERS.find((spec) => spec.match(file));
285
+ }
286
+ /** Process-wide, keyed by server command (see the header): every session and
287
+ * subagent shares one server per language. */
288
+ const clients = new Map();
289
+ async function clientFor(spec, root) {
290
+ const existing = clients.get(spec.command);
291
+ if (existing) {
292
+ await existing.ready;
293
+ return existing.client;
294
+ }
295
+ // cwd is the session's project root, not the file's directory: gopls and
296
+ // rust-analyzer want the module root, and `rootUri` is set from it too.
297
+ const child = spawn(spec.command, spec.args, { cwd: root, stdio: ["pipe", "pipe", "ignore"] });
298
+ const stdin = child.stdin;
299
+ const stdout = child.stdout;
300
+ if (!stdin || !stdout) {
301
+ child.kill();
302
+ throw new Error(`${spec.command}: stdio pipes unavailable`);
303
+ }
304
+ const client = new LspClient({ stdin, stdout, root });
305
+ // A spawn that fails after the PATH check (EACCES, a stale symlink) must not
306
+ // crash the process on an unhandled 'error' event or leave a corpse pooled.
307
+ child.on("error", () => {
308
+ const pooled = clients.get(spec.command);
309
+ if (pooled?.child === child)
310
+ clients.delete(spec.command);
311
+ pooled?.client.dispose();
312
+ });
313
+ // A server that dies on its own has to leave the pool, or every later query
314
+ // for this language talks to a client whose pipes are gone and waits out a
315
+ // timeout before failing.
316
+ child.on("exit", () => {
317
+ const pooled = clients.get(spec.command);
318
+ if (pooled?.child === child)
319
+ clients.delete(spec.command);
320
+ client.dispose();
321
+ });
322
+ const ready = client.initialize().catch((error) => {
323
+ clients.delete(spec.command);
324
+ child.kill();
325
+ throw error;
326
+ });
327
+ clients.set(spec.command, { client, child, ready });
328
+ await ready;
329
+ return client;
330
+ }
331
+ /**
332
+ * Kill every pooled server. Called from the main session's `dispose`
333
+ * (`openSession` in `session.ts`) and on process `exit` — subagent sessions
334
+ * share the pool, so only the process-wide teardown kills it (see the header).
335
+ */
336
+ export function disposeAllLsp() {
337
+ for (const [command, pooled] of clients) {
338
+ clients.delete(command);
339
+ pooled.client.dispose();
340
+ pooled.child.kill();
341
+ }
342
+ }
343
+ process.on("exit", disposeAllLsp);
344
+ const SEVERITY_NAME = { 1: "error", 2: "warning", 3: "info", 4: "hint" };
345
+ function locationText(location) {
346
+ let file = location.uri;
347
+ try {
348
+ file = fileURLToPath(location.uri);
349
+ }
350
+ catch {
351
+ // Not a file:// uri — a workspace or virtual scheme; show it as it came.
352
+ }
353
+ const start = location.range?.start;
354
+ return start ? `${file}:${start.line + 1}:${start.character + 1}` : file;
355
+ }
356
+ function symbolRows(symbols, depth = 0) {
357
+ const rows = [];
358
+ for (const symbol of symbols) {
359
+ const line = (symbol.selectionRange ?? symbol.range)?.start.line;
360
+ rows.push(`${" ".repeat(depth)}${symbol.name}${line === undefined ? "" : ` :${line + 1}`}`);
361
+ if (symbol.children)
362
+ rows.push(...symbolRows(symbol.children, depth + 1));
363
+ }
364
+ return rows;
365
+ }
366
+ /**
367
+ * Run one language-server query. Every failure mode — no server for this file,
368
+ * a missing binary, no line, an unreadable file, a dead child — comes back as
369
+ * `{ ok: false, error }`; the tool contract is results, never throws.
370
+ */
371
+ export async function askLsp(query) {
372
+ try {
373
+ const spec = specFor(query.file);
374
+ if (!spec)
375
+ return { ok: false, error: `no language server configured for ${query.file}` };
376
+ const needsLine = query.action === "definition" || query.action === "references" || query.action === "hover";
377
+ if (needsLine && (query.line === undefined || !Number.isInteger(query.line) || query.line < 1)) {
378
+ return { ok: false, error: `line required (1-based) for ${query.action}` };
379
+ }
380
+ // PATH presence is checked by scanning, not by spawning: a missing binary
381
+ // has to become a tool result, and a probe child would cost a fork per call.
382
+ const onPath = spec.command.includes("/") ? existsSync(spec.command) : (process.env.PATH ?? "").split(delimiter).some((dir) => existsSync(join(dir, spec.command)));
383
+ if (!onPath) {
384
+ return { ok: false, error: `${spec.command} is not on PATH. Install it to use lsp on ${query.file}.` };
385
+ }
386
+ const file = isAbsolute(query.file) ? query.file : join(query.root, query.file);
387
+ const text = readFileSync(file, "utf8");
388
+ const client = await clientFor(spec, query.root);
389
+ await client.ensureOpen(file, text, spec.languageId);
390
+ // The tool takes 1-based lines (editor habit); LSP positions on the wire
391
+ // are 0-based — this is that conversion.
392
+ const wireLine = (query.line ?? 1) - 1;
393
+ let character = 0;
394
+ if (query.symbol) {
395
+ const at = (text.split("\n")[wireLine] ?? "").indexOf(query.symbol);
396
+ if (at >= 0)
397
+ character = at;
398
+ }
399
+ switch (query.action) {
400
+ case "definition": {
401
+ const location = await client.definition(file, wireLine, character);
402
+ if (!location)
403
+ return { ok: true, text: "no definition found", count: 0 };
404
+ return { ok: true, text: locationText(location), count: 1 };
405
+ }
406
+ case "references": {
407
+ const locations = await client.references(file, wireLine, character);
408
+ const lines = locations.map(locationText);
409
+ return { ok: true, text: lines.length > 0 ? lines.join("\n") : "no references found", count: lines.length };
410
+ }
411
+ case "hover": {
412
+ const hover = (await client.hover(file, wireLine, character))?.trim();
413
+ return { ok: true, text: hover ? hover : "no hover at that position" };
414
+ }
415
+ case "symbols": {
416
+ const rows = symbolRows(await client.documentSymbols(file));
417
+ return { ok: true, text: rows.length > 0 ? rows.join("\n") : "no symbols in this file", count: rows.length };
418
+ }
419
+ case "diagnostics": {
420
+ const diagnostics = await client.diagnostics(file);
421
+ const rows = diagnostics.map((d) => `${d.range.start.line + 1}:${d.range.start.character + 1} ${SEVERITY_NAME[d.severity ?? 3] ?? "info"} ${d.message}`);
422
+ return { ok: true, text: rows.length > 0 ? rows.join("\n") : "no diagnostics", count: rows.length };
423
+ }
424
+ }
425
+ }
426
+ catch (error) {
427
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
428
+ }
429
+ }