@pugi/cli 0.1.0-beta.87 → 0.1.0-beta.89

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 (68) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +1 -1
  3. package/dist/core/agents/registry.js +1 -1
  4. package/dist/core/auth/env-provider.js +1 -1
  5. package/dist/core/checkpoints/shadow-git.js +1 -1
  6. package/dist/core/context/compaction.js +1 -1
  7. package/dist/core/context/markdown-traverse.js +1 -1
  8. package/dist/core/credentials.js +1 -1
  9. package/dist/core/denial-tracking/state.js +1 -1
  10. package/dist/core/edits/fuzzy-ladder.js +1 -1
  11. package/dist/core/edits/layer-a-fuzzy-apply.js +1 -1
  12. package/dist/core/engine/anvil-client.js +76 -2
  13. package/dist/core/engine/native-pugi.js +1 -1
  14. package/dist/core/engine/tool-bridge.js +436 -0
  15. package/dist/core/hooks/events.js +3 -1
  16. package/dist/core/hooks/registry.js +3 -0
  17. package/dist/core/hooks/worktree-events.js +158 -0
  18. package/dist/core/lsp/client.js +453 -0
  19. package/dist/core/lsp/server-detect.js +173 -0
  20. package/dist/core/lsp/symbol-cache.js +162 -0
  21. package/dist/core/lsp/symbol-tools.js +296 -4
  22. package/dist/core/mcp/server-tools.js +1 -1
  23. package/dist/core/mcp/server.js +1 -1
  24. package/dist/core/memory/secret-scanner.js +6 -6
  25. package/dist/core/onboarding/ensure-initialized.js +1 -1
  26. package/dist/core/plans/plan-artifact.js +2 -2
  27. package/dist/core/repl/ask.js +1 -1
  28. package/dist/core/repl/cap-warning.js +1 -1
  29. package/dist/core/repl/session.js +3 -3
  30. package/dist/core/repl/slash-commands.js +1 -1
  31. package/dist/core/routing/pre-flight-estimator.js +1 -1
  32. package/dist/core/settings.js +38 -0
  33. package/dist/core/worktree/include-parser.js +249 -0
  34. package/dist/index.js +8 -0
  35. package/dist/runtime/cli.js +176 -28
  36. package/dist/runtime/commands/agents.js +1 -1
  37. package/dist/runtime/commands/config.js +41 -7
  38. package/dist/runtime/commands/hooks.js +3 -0
  39. package/dist/runtime/commands/review-consensus.js +1 -1
  40. package/dist/runtime/sigint-guard.js +272 -0
  41. package/dist/runtime/version.js +1 -1
  42. package/dist/runtime/worktree-bootstrap.js +579 -0
  43. package/dist/skills/bundled/batch.js +2 -2
  44. package/dist/skills/bundled/index.js +3 -3
  45. package/dist/skills/bundled/loop.js +2 -2
  46. package/dist/skills/bundled/remember.js +1 -1
  47. package/dist/skills/bundled/simplify.js +1 -1
  48. package/dist/skills/bundled/skillify.js +2 -2
  49. package/dist/skills/bundled/stuck.js +1 -1
  50. package/dist/skills/bundled/verify.js +2 -2
  51. package/dist/testing/vcr.js +2 -2
  52. package/dist/tools/ask-user-question.js +66 -0
  53. package/dist/tools/bash.js +2 -2
  54. package/dist/tools/lsp-tools.js +377 -1
  55. package/dist/tools/powershell.js +1 -1
  56. package/dist/tools/registry.js +23 -0
  57. package/dist/tui/ask-user-question-chips.js +257 -0
  58. package/dist/tui/input-box.js +1 -1
  59. package/dist/tui/render.js +1 -1
  60. package/dist/tui/repl.js +1 -1
  61. package/dist/tui/status-bar.js +1 -1
  62. package/dist/tui/update-banner.js +1 -1
  63. package/dist/tui/welcome-data.js +4 -4
  64. package/package.json +4 -3
  65. package/test/scenarios/compact-force.scenario.txt +3 -2
  66. package/test/scenarios/identity.scenario.txt +6 -5
  67. package/test/scenarios/persona-handoff.scenario.txt +2 -1
  68. package/test/scenarios/walkback.scenario.txt +6 -6
@@ -206,6 +206,189 @@ export class LspClient {
206
206
  return { ok: true, value: locations };
207
207
  });
208
208
  }
209
+ /**
210
+ * PUGI-78 Phase 1: document outline. Returns the LSP raw shape (either
211
+ * `DocumentSymbol[]` hierarchical or `SymbolInformation[]` flat); the
212
+ * symbol-tools layer normalizes both to the agent-facing flat form.
213
+ */
214
+ async documentSymbols(file, token) {
215
+ return this.withDocument(file, async (uri) => {
216
+ const raw = await this.sendRequest('textDocument/documentSymbol', { textDocument: { uri } }, token);
217
+ if (!Array.isArray(raw))
218
+ return { ok: true, value: [] };
219
+ return { ok: true, value: raw };
220
+ });
221
+ }
222
+ /**
223
+ * PUGI-78 Phase 1: workspace-wide symbol fuzzy search. The query is
224
+ * server-defined (substring / fuzzy / prefix); we forward verbatim.
225
+ * Files outside the workspace are kept as raw URIs — the symbol-tools
226
+ * layer surfaces them to the operator as-is.
227
+ */
228
+ async workspaceSymbols(query, token) {
229
+ // workspace/symbol does not need a document open, but we still
230
+ // honor the cancellation token. We call sendRequest directly via
231
+ // the typed internal accessor used by the handshake.
232
+ try {
233
+ const internal = this;
234
+ const raw = await internal.sendRequest('workspace/symbol', { query }, token);
235
+ if (!Array.isArray(raw))
236
+ return { ok: true, value: [] };
237
+ return { ok: true, value: raw };
238
+ }
239
+ catch (error) {
240
+ if (error instanceof OperatorAbortedError) {
241
+ return { ok: false, reason: 'operator_aborted', detail: error.message };
242
+ }
243
+ if (error instanceof Error && error.message === 'request_timeout') {
244
+ return { ok: false, reason: 'request_timeout', detail: 'workspace/symbol timed out' };
245
+ }
246
+ return {
247
+ ok: false,
248
+ reason: 'lsp_error',
249
+ detail: error instanceof Error ? error.message : String(error),
250
+ };
251
+ }
252
+ }
253
+ /**
254
+ * PUGI-78 Phase 1: implementations of an interface / abstract method.
255
+ * LSP `textDocument/implementation` returns the same `Location[]` shape
256
+ * as `definition`, so we re-use `normalizeLocations`.
257
+ */
258
+ async implementations(file, pos, token) {
259
+ return this.withDocument(file, async (uri) => {
260
+ const raw = await this.sendRequest('textDocument/implementation', { textDocument: { uri }, position: pos }, token);
261
+ const locations = normalizeLocations(raw, this.cwd);
262
+ return { ok: true, value: locations };
263
+ });
264
+ }
265
+ /**
266
+ * PUGI-78 Phase 1: type-definition (vs value-definition). Same wire
267
+ * shape as `definition` — server reports the location of the symbol's
268
+ * type declaration, not its instantiation site.
269
+ */
270
+ async typeDefinition(file, pos, token) {
271
+ return this.withDocument(file, async (uri) => {
272
+ const raw = await this.sendRequest('textDocument/typeDefinition', { textDocument: { uri }, position: pos }, token);
273
+ const locations = normalizeLocations(raw, this.cwd);
274
+ return { ok: true, value: locations };
275
+ });
276
+ }
277
+ /**
278
+ * PUGI-78 Phase 1: signature help at a call site. Returns the active
279
+ * overload's label, parameters, and active-parameter index. Null when
280
+ * the server reports no signature (cursor is not inside a call).
281
+ */
282
+ async signatureHelp(file, pos, token) {
283
+ return this.withDocument(file, async (uri) => {
284
+ const raw = await this.sendRequest('textDocument/signatureHelp', { textDocument: { uri }, position: pos }, token);
285
+ return { ok: true, value: normalizeSignatureHelp(raw) };
286
+ });
287
+ }
288
+ /**
289
+ * PUGI-78 Phase 1: prepare a rename. Returns the range LSP says the
290
+ * symbol occupies; the dispatcher uses this to confirm the cursor is
291
+ * actually on a renameable token before issuing the full rename.
292
+ */
293
+ async prepareRename(file, pos, token) {
294
+ return this.withDocument(file, async (uri) => {
295
+ try {
296
+ const raw = await this.sendRequest('textDocument/prepareRename', { textDocument: { uri }, position: pos }, token);
297
+ if (!raw)
298
+ return { ok: true, value: null };
299
+ // LSP allows { range, placeholder } OR a raw Range; surface
300
+ // either as a flat Range.
301
+ if (typeof raw === 'object' && raw && 'start' in raw && 'end' in raw) {
302
+ const range = parseRange(raw);
303
+ return { ok: true, value: range ?? null };
304
+ }
305
+ if (typeof raw === 'object' && raw && 'range' in raw) {
306
+ const range = parseRange(raw.range);
307
+ return { ok: true, value: range ?? null };
308
+ }
309
+ return { ok: true, value: null };
310
+ }
311
+ catch (error) {
312
+ // Some servers (pyright pre-1.1) lack prepareRename support and
313
+ // return a JSON-RPC error. The dispatcher should still be able
314
+ // to attempt the rename — surface null instead of bubbling the
315
+ // failure as the agent surface treats null as "skip prepare".
316
+ if (error instanceof Error && /method not (found|supported)/i.test(error.message)) {
317
+ return { ok: true, value: null };
318
+ }
319
+ throw error;
320
+ }
321
+ });
322
+ }
323
+ /**
324
+ * PUGI-78 Phase 1: atomic rename refactor. Returns the workspace edit
325
+ * the server proposes; the dispatcher previews + applies in a future
326
+ * ticket (Phase 2). For Phase 1 the agent surface lists affected files
327
+ * + line:character of each edit so the model can summarize the impact.
328
+ */
329
+ async rename(file, pos, newName, token) {
330
+ return this.withDocument(file, async (uri) => {
331
+ const raw = await this.sendRequest('textDocument/rename', { textDocument: { uri }, position: pos, newName }, token);
332
+ return { ok: true, value: normalizeWorkspaceEdit(raw, this.cwd) };
333
+ });
334
+ }
335
+ /**
336
+ * PUGI-78 Phase 1: quick-fix list at a range. The server returns either
337
+ * `Command[]` (legacy) or `CodeAction[]` (modern); we normalize to the
338
+ * union shape so the surface stays stable.
339
+ */
340
+ async codeActions(file, range, token) {
341
+ return this.withDocument(file, async (uri) => {
342
+ const raw = await this.sendRequest('textDocument/codeAction', {
343
+ textDocument: { uri },
344
+ range,
345
+ context: { diagnostics: [] },
346
+ }, token);
347
+ return { ok: true, value: normalizeCodeActions(raw) };
348
+ });
349
+ }
350
+ /**
351
+ * PUGI-78 Phase 1: formatter. Returns the text edits the server would
352
+ * apply for `textDocument/formatting`. The dispatcher composes them
353
+ * into a single rewrite in a future ticket; Phase 1 surfaces the edits
354
+ * for inspection.
355
+ */
356
+ async formatting(file, token, options) {
357
+ return this.withDocument(file, async (uri) => {
358
+ const raw = await this.sendRequest('textDocument/formatting', {
359
+ textDocument: { uri },
360
+ options: {
361
+ tabSize: options?.tabSize ?? 2,
362
+ insertSpaces: options?.insertSpaces ?? true,
363
+ },
364
+ }, token);
365
+ return { ok: true, value: normalizeTextEdits(raw) };
366
+ });
367
+ }
368
+ /**
369
+ * PUGI-78 Phase 1: call hierarchy at a symbol position. The LSP wire
370
+ * is a two-step protocol — first `prepareCallHierarchy` to get the
371
+ * item handle, then `incomingCalls` and `outgoingCalls`. We surface
372
+ * both edges in a single call.
373
+ */
374
+ async callHierarchy(file, pos, token) {
375
+ return this.withDocument(file, async (uri) => {
376
+ const prepared = await this.sendRequest('textDocument/prepareCallHierarchy', { textDocument: { uri }, position: pos }, token);
377
+ if (!Array.isArray(prepared) || prepared.length === 0) {
378
+ return { ok: true, value: { incoming: [], outgoing: [] } };
379
+ }
380
+ const item = prepared[0];
381
+ const incomingRaw = await this.sendRequest('callHierarchy/incomingCalls', { item }, token);
382
+ const outgoingRaw = await this.sendRequest('callHierarchy/outgoingCalls', { item }, token);
383
+ return {
384
+ ok: true,
385
+ value: {
386
+ incoming: normalizeCallHierarchyEdges(incomingRaw, 'from', this.cwd),
387
+ outgoing: normalizeCallHierarchyEdges(outgoingRaw, 'to', this.cwd),
388
+ },
389
+ };
390
+ });
391
+ }
209
392
  /**
210
393
  * Diagnostics in LSP arrive as PUSH (`textDocument/publishDiagnostics`)
211
394
  * not pull. We open the document, wait one short tick for the server
@@ -632,6 +815,27 @@ async function initializeHandshake(client, cwd) {
632
815
  definition: { linkSupport: false },
633
816
  references: {},
634
817
  publishDiagnostics: { relatedInformation: false },
818
+ // PUGI-78 Phase 1: full 13-tool symbol surface. Each block
819
+ // mirrors the LSP §3.17 dynamic capability advertisement so
820
+ // the server enables the corresponding feature (e.g. pyright
821
+ // gates `workspace/symbol` on this flag). `dynamicRegistration`
822
+ // stays false for every entry — we never register a capability
823
+ // post-initialize; the static block is the only handshake.
824
+ documentSymbol: {
825
+ hierarchicalDocumentSymbolSupport: true,
826
+ symbolKind: { valueSet: Array.from({ length: 26 }, (_, i) => i + 1) },
827
+ },
828
+ signatureHelp: { signatureInformation: { documentationFormat: ['plaintext', 'markdown'] } },
829
+ implementation: { linkSupport: false },
830
+ typeDefinition: { linkSupport: false },
831
+ rename: { prepareSupport: true },
832
+ codeAction: { codeActionLiteralSupport: { codeActionKind: { valueSet: ['', 'quickfix', 'refactor', 'source'] } } },
833
+ formatting: {},
834
+ callHierarchy: {},
835
+ },
836
+ workspace: {
837
+ symbol: { symbolKind: { valueSet: Array.from({ length: 26 }, (_, i) => i + 1) } },
838
+ workspaceEdit: { documentChanges: true },
635
839
  },
636
840
  },
637
841
  workspaceFolders: [{ uri: rootUri, name: 'pugi-workspace' }],
@@ -762,6 +966,249 @@ function normalizeDiagnostic(raw) {
762
966
  };
763
967
  return out;
764
968
  }
969
+ /**
970
+ * PUGI-78 Phase 1: collapse a `textDocument/signatureHelp` response to
971
+ * one active overload. LSP returns `{ signatures, activeSignature,
972
+ * activeParameter }`; we surface the active overload's label, params,
973
+ * and the active-param index. Returns null when the server reports no
974
+ * signatures (cursor not at a call site).
975
+ */
976
+ function normalizeSignatureHelp(raw) {
977
+ if (!raw || typeof raw !== 'object')
978
+ return null;
979
+ const obj = raw;
980
+ if (!Array.isArray(obj.signatures) || obj.signatures.length === 0)
981
+ return null;
982
+ const idx = typeof obj.activeSignature === 'number' && obj.activeSignature >= 0
983
+ ? Math.min(obj.activeSignature, obj.signatures.length - 1)
984
+ : 0;
985
+ const sig = obj.signatures[idx];
986
+ if (!sig || typeof sig !== 'object')
987
+ return null;
988
+ const sigObj = sig;
989
+ if (typeof sigObj.label !== 'string')
990
+ return null;
991
+ const params = [];
992
+ if (Array.isArray(sigObj.parameters)) {
993
+ for (const p of sigObj.parameters) {
994
+ if (!p || typeof p !== 'object')
995
+ continue;
996
+ const po = p;
997
+ // LSP allows `label` as string OR [start,end] tuple of offsets;
998
+ // we surface the string form, and for tuple form we slice the
999
+ // signature label.
1000
+ let label = '';
1001
+ if (typeof po.label === 'string')
1002
+ label = po.label;
1003
+ else if (Array.isArray(po.label) && po.label.length === 2 && typeof po.label[0] === 'number' && typeof po.label[1] === 'number') {
1004
+ label = sigObj.label.slice(po.label[0], po.label[1]);
1005
+ }
1006
+ if (!label)
1007
+ continue;
1008
+ const doc = extractMarkupString(po.documentation);
1009
+ params.push({ label, ...(doc ? { documentation: doc } : {}) });
1010
+ }
1011
+ }
1012
+ const docTop = extractMarkupString(sigObj.documentation);
1013
+ const out = {
1014
+ label: sigObj.label,
1015
+ parameters: params,
1016
+ raw,
1017
+ ...(docTop ? { documentation: docTop } : {}),
1018
+ ...(typeof obj.activeParameter === 'number' ? { activeParameter: obj.activeParameter } : {}),
1019
+ };
1020
+ return out;
1021
+ }
1022
+ /**
1023
+ * LSP `MarkupContent | string` -> string. Used by both signature help
1024
+ * (top-level docs + per-param docs) and hover normalization.
1025
+ */
1026
+ function extractMarkupString(raw) {
1027
+ if (typeof raw === 'string')
1028
+ return raw.length > 0 ? raw : undefined;
1029
+ if (raw && typeof raw === 'object' && 'value' in raw) {
1030
+ const value = raw.value;
1031
+ if (typeof value === 'string' && value.length > 0)
1032
+ return value;
1033
+ }
1034
+ return undefined;
1035
+ }
1036
+ /**
1037
+ * PUGI-78 Phase 1: collapse a `textDocument/rename` `WorkspaceEdit` to
1038
+ * the agent-facing flat list. Handles BOTH shapes:
1039
+ * - legacy `changes: { [uri]: TextEdit[] }`
1040
+ * - modern `documentChanges: (TextDocumentEdit | FileOp)[]`
1041
+ *
1042
+ * File-op entries (`CreateFile`, `RenameFile`, `DeleteFile`) are NOT
1043
+ * surfaced in Phase 1 — the only renames Pugi enables out of the box
1044
+ * are symbol-level. Phase 2 wires the file-op variants.
1045
+ */
1046
+ function normalizeWorkspaceEdit(raw, cwd) {
1047
+ if (!raw || typeof raw !== 'object')
1048
+ return null;
1049
+ const obj = raw;
1050
+ const edits = [];
1051
+ const fileSet = new Set();
1052
+ const pushEdit = (uri, edit) => {
1053
+ if (!edit || typeof edit !== 'object')
1054
+ return;
1055
+ const editObj = edit;
1056
+ const range = parseRange(editObj.range);
1057
+ if (!range)
1058
+ return;
1059
+ const newText = typeof editObj.newText === 'string' ? editObj.newText : '';
1060
+ const file = uriToWorkspacePath(uri, cwd);
1061
+ if (!file)
1062
+ return;
1063
+ fileSet.add(file);
1064
+ edits.push({ file, line: range.start.line, character: range.start.character, newText });
1065
+ };
1066
+ if (obj.changes && typeof obj.changes === 'object') {
1067
+ for (const [uri, list] of Object.entries(obj.changes)) {
1068
+ if (!Array.isArray(list))
1069
+ continue;
1070
+ for (const edit of list)
1071
+ pushEdit(uri, edit);
1072
+ }
1073
+ }
1074
+ if (Array.isArray(obj.documentChanges)) {
1075
+ for (const docChange of obj.documentChanges) {
1076
+ if (!docChange || typeof docChange !== 'object')
1077
+ continue;
1078
+ const dc = docChange;
1079
+ // Skip file-op entries — they have a `kind` discriminator.
1080
+ if (typeof dc.kind === 'string' && dc.kind !== '')
1081
+ continue;
1082
+ const td = dc.textDocument;
1083
+ if (!td || typeof td !== 'object')
1084
+ continue;
1085
+ const uri = td.uri;
1086
+ if (typeof uri !== 'string')
1087
+ continue;
1088
+ if (Array.isArray(dc.edits)) {
1089
+ for (const edit of dc.edits)
1090
+ pushEdit(uri, edit);
1091
+ }
1092
+ }
1093
+ }
1094
+ if (fileSet.size === 0)
1095
+ return null;
1096
+ return { files: Array.from(fileSet).sort(), edits, raw };
1097
+ }
1098
+ /**
1099
+ * Convert an LSP `file://` URI to a workspace-relative path. Empty
1100
+ * string when the URI escapes the workspace; the caller decides whether
1101
+ * to drop the edit or surface the raw URI.
1102
+ */
1103
+ function uriToWorkspacePath(uri, cwd) {
1104
+ try {
1105
+ const url = new URL(uri);
1106
+ if (url.protocol !== 'file:')
1107
+ return uri;
1108
+ const abs = decodeURIComponent(url.pathname);
1109
+ if (abs === cwd)
1110
+ return '.';
1111
+ if (abs.startsWith(cwd + sep))
1112
+ return abs.slice(cwd.length + 1);
1113
+ return uri;
1114
+ }
1115
+ catch {
1116
+ return '';
1117
+ }
1118
+ }
1119
+ /**
1120
+ * PUGI-78 Phase 1: parse `textDocument/codeAction` response. Server
1121
+ * returns either `Command[]` or `CodeAction[]`. Both have `title`;
1122
+ * `CodeAction` additionally carries `kind` + `isPreferred` + `edit`.
1123
+ */
1124
+ function normalizeCodeActions(raw) {
1125
+ if (!Array.isArray(raw))
1126
+ return [];
1127
+ const out = [];
1128
+ for (const item of raw) {
1129
+ if (!item || typeof item !== 'object')
1130
+ continue;
1131
+ const obj = item;
1132
+ if (typeof obj.title !== 'string' || obj.title.length === 0)
1133
+ continue;
1134
+ out.push({
1135
+ title: obj.title,
1136
+ ...(typeof obj.kind === 'string' ? { kind: obj.kind } : {}),
1137
+ ...(typeof obj.isPreferred === 'boolean' ? { isPreferred: obj.isPreferred } : {}),
1138
+ });
1139
+ }
1140
+ return out;
1141
+ }
1142
+ /**
1143
+ * PUGI-78 Phase 1: parse a `TextEdit[]` payload returned by the
1144
+ * formatter. Skips rows with missing range or non-string newText.
1145
+ */
1146
+ function normalizeTextEdits(raw) {
1147
+ if (!Array.isArray(raw))
1148
+ return [];
1149
+ const out = [];
1150
+ for (const item of raw) {
1151
+ if (!item || typeof item !== 'object')
1152
+ continue;
1153
+ const obj = item;
1154
+ const range = parseRange(obj.range);
1155
+ if (!range)
1156
+ continue;
1157
+ const newText = typeof obj.newText === 'string' ? obj.newText : '';
1158
+ out.push({ range, newText });
1159
+ }
1160
+ return out;
1161
+ }
1162
+ /**
1163
+ * PUGI-78 Phase 1: collapse `callHierarchy/{incoming,outgoing}Calls`.
1164
+ * The two response shapes share the same `from`/`to` field for the
1165
+ * counterpart item; `fromRanges`/`toRanges` flatten to `callRanges`.
1166
+ */
1167
+ function normalizeCallHierarchyEdges(raw, itemKey, cwd) {
1168
+ if (!Array.isArray(raw))
1169
+ return [];
1170
+ const out = [];
1171
+ const rangeKey = itemKey === 'from' ? 'fromRanges' : 'toRanges';
1172
+ for (const row of raw) {
1173
+ if (!row || typeof row !== 'object')
1174
+ continue;
1175
+ const rowObj = row;
1176
+ const item = rowObj[itemKey];
1177
+ if (!item || typeof item !== 'object')
1178
+ continue;
1179
+ const itemObj = item;
1180
+ if (typeof itemObj.name !== 'string' || itemObj.name.length === 0)
1181
+ continue;
1182
+ const kind = typeof itemObj.kind === 'number' ? itemObj.kind : 0;
1183
+ if (!kind)
1184
+ continue;
1185
+ const uri = typeof itemObj.uri === 'string' ? itemObj.uri : '';
1186
+ if (!uri)
1187
+ continue;
1188
+ const file = uriToWorkspacePath(uri, cwd);
1189
+ const selRange = parseRange(itemObj.selectionRange) ?? parseRange(itemObj.range);
1190
+ if (!selRange)
1191
+ continue;
1192
+ const rangesRaw = rowObj[rangeKey];
1193
+ const callRanges = [];
1194
+ if (Array.isArray(rangesRaw)) {
1195
+ for (const r of rangesRaw) {
1196
+ const parsed = parseRange(r);
1197
+ if (parsed)
1198
+ callRanges.push(parsed);
1199
+ }
1200
+ }
1201
+ out.push({
1202
+ name: itemObj.name,
1203
+ kind,
1204
+ file,
1205
+ line: selRange.start.line,
1206
+ character: selRange.start.character,
1207
+ callRanges,
1208
+ });
1209
+ }
1210
+ return out;
1211
+ }
765
1212
  /**
766
1213
  * Test-only surface so specs can hand-craft an `LspClient` over a mock
767
1214
  * stdio pipe without paying for the real `startLspClient` spawn cost.
@@ -772,5 +1219,11 @@ export const __test__ = {
772
1219
  normalizeHover,
773
1220
  normalizeLocations,
774
1221
  normalizeDiagnostic,
1222
+ normalizeSignatureHelp,
1223
+ normalizeWorkspaceEdit,
1224
+ normalizeCodeActions,
1225
+ normalizeTextEdits,
1226
+ normalizeCallHierarchyEdges,
1227
+ uriToWorkspacePath,
775
1228
  };
776
1229
  //# sourceMappingURL=client.js.map
@@ -0,0 +1,173 @@
1
+ /**
2
+ * PUGI-78 Phase 1: LSP server discovery via `~/.pugi/lsp-config.json`
3
+ * + PATH probe.
4
+ *
5
+ * The base `LANGUAGE_SERVERS` registry in `client.ts` ships sane
6
+ * defaults (TypeScript via npx, pyright/gopls/rust-analyzer assumed on
7
+ * PATH). Operators who run a non-standard layout (NixOS, asdf, custom
8
+ * monorepo binaries) override per-language commands via a small JSON
9
+ * file at `$HOME/.pugi/lsp-config.json`:
10
+ *
11
+ * ```json
12
+ * {
13
+ * "typescript": {
14
+ * "command": "/usr/local/bin/typescript-language-server",
15
+ * "args": ["--stdio"]
16
+ * },
17
+ * "python": {
18
+ * "command": "pylsp",
19
+ * "args": []
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * The file is loaded once per process; failures are non-fatal (missing
25
+ * file => empty override map, parse error => log + empty override).
26
+ * The standard registry stays the fallback. The operator-facing CLI
27
+ * surface (`pugi lsp servers`) reports both detected binaries (via
28
+ * `inspectLspServers` in client.ts) and any operator overrides loaded
29
+ * from this module.
30
+ *
31
+ * Why a JSON file (not a flag): the operator may have several distinct
32
+ * LSP layouts (e.g. one for the monorepo, one for an isolated package).
33
+ * A central config in `~/.pugi/` lets every workspace inherit the same
34
+ * defaults without re-typing flags on every `pugi` invocation; the
35
+ * workspace-local `.pugi/settings.json::lsp` toggle (already shipped
36
+ * in β7 L9) layers on top to disable specific languages per-workspace.
37
+ *
38
+ * Brand voice: ASCII only, no emoji, no banned words.
39
+ */
40
+ import { homedir } from 'node:os';
41
+ import { readFileSync, existsSync } from 'node:fs';
42
+ import { join } from 'node:path';
43
+ import { spawnSync } from 'node:child_process';
44
+ /**
45
+ * Settings file path. Computed lazily so a spec can inject `HOME` via
46
+ * `process.env.HOME = '/tmp/test'` before importing this module.
47
+ */
48
+ export function lspConfigPath() {
49
+ return join(homedir(), '.pugi', 'lsp-config.json');
50
+ }
51
+ /**
52
+ * Load the operator override map from `$HOME/.pugi/lsp-config.json`.
53
+ * Non-fatal failures: missing file -> empty map; malformed JSON ->
54
+ * empty map + stderr warning when `PUGI_LSP_DEBUG=1`. Returns the
55
+ * parsed map; any unrecognized language slug is dropped silently.
56
+ *
57
+ * Synchronous because this is a one-shot bootstrap path called from
58
+ * the CLI surface before any LSP client spawn; the file is tiny (<2 KB
59
+ * in practice) so the sync read cost is negligible.
60
+ */
61
+ export function loadOperatorOverrides(path) {
62
+ const resolved = path ?? lspConfigPath();
63
+ if (!existsSync(resolved))
64
+ return {};
65
+ let raw;
66
+ try {
67
+ raw = readFileSync(resolved, 'utf8');
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(raw);
75
+ }
76
+ catch {
77
+ if (process.env.PUGI_LSP_DEBUG === '1') {
78
+ process.stderr.write(`[pugi lsp] ignored ${resolved} - invalid JSON\n`);
79
+ }
80
+ return {};
81
+ }
82
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
83
+ return {};
84
+ const out = {};
85
+ const supported = ['ts', 'js', 'py', 'go', 'rust'];
86
+ // Accept both short slug ("ts") and long name ("typescript") on the
87
+ // operator side - long names are friendlier in a hand-edited file.
88
+ const longNameMap = {
89
+ typescript: 'ts',
90
+ javascript: 'js',
91
+ python: 'py',
92
+ go: 'go',
93
+ rust: 'rust',
94
+ ts: 'ts',
95
+ js: 'js',
96
+ py: 'py',
97
+ rs: 'rust',
98
+ };
99
+ for (const [key, value] of Object.entries(parsed)) {
100
+ const lang = longNameMap[key.toLowerCase()];
101
+ if (!lang || !supported.includes(lang))
102
+ continue;
103
+ if (!value || typeof value !== 'object' || Array.isArray(value))
104
+ continue;
105
+ const v = value;
106
+ if (typeof v.command !== 'string' || v.command.length === 0)
107
+ continue;
108
+ const args = [];
109
+ if (Array.isArray(v.args)) {
110
+ for (const a of v.args) {
111
+ if (typeof a === 'string')
112
+ args.push(a);
113
+ }
114
+ }
115
+ out[lang] = { command: v.command, args };
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * Discover every supported language's server given the registry defaults
121
+ * + the operator override map. `defaultRegistry` is injected so the
122
+ * caller (the CLI surface) can pass the live `LANGUAGE_SERVERS` from
123
+ * `client.ts` without forming a circular module dep.
124
+ */
125
+ export function detectServers(defaultRegistry, overrides = loadOperatorOverrides()) {
126
+ const out = [];
127
+ for (const lang of Object.keys(defaultRegistry)) {
128
+ const base = defaultRegistry[lang];
129
+ const override = overrides[lang];
130
+ if (override) {
131
+ out.push({
132
+ language: lang,
133
+ source: 'override',
134
+ command: override.command,
135
+ args: override.args,
136
+ available: detectBinaryOnPath(override.command),
137
+ });
138
+ }
139
+ else {
140
+ out.push({
141
+ language: lang,
142
+ source: 'default',
143
+ command: base.command,
144
+ args: base.args,
145
+ available: detectBinaryOnPath(base.probe),
146
+ });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+ /**
152
+ * Cross-platform binary detection. POSIX = `which`, Windows = `where`.
153
+ * Failures are non-fatal — we just report unavailable. We avoid
154
+ * `spawnSync(name, ['--version'])` because some servers (gopls, older
155
+ * pyright) do not honor `--version` and exit non-zero, which would
156
+ * mis-flag them as missing.
157
+ */
158
+ export function detectBinaryOnPath(name) {
159
+ const probe = process.platform === 'win32' ? 'where' : 'which';
160
+ try {
161
+ const result = spawnSync(probe, [name], { stdio: 'ignore' });
162
+ return result.status === 0;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ /** Test-only — direct access to the long-name accepting map. */
169
+ export const __test__ = {
170
+ detectBinaryOnPath,
171
+ lspConfigPath,
172
+ };
173
+ //# sourceMappingURL=server-detect.js.map