@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +1 -1
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/auth/env-provider.js +1 -1
- package/dist/core/checkpoints/shadow-git.js +1 -1
- package/dist/core/context/compaction.js +1 -1
- package/dist/core/context/markdown-traverse.js +1 -1
- package/dist/core/credentials.js +1 -1
- package/dist/core/denial-tracking/state.js +1 -1
- package/dist/core/edits/fuzzy-ladder.js +1 -1
- package/dist/core/edits/layer-a-fuzzy-apply.js +1 -1
- package/dist/core/engine/anvil-client.js +76 -2
- package/dist/core/engine/native-pugi.js +1 -1
- package/dist/core/engine/tool-bridge.js +436 -0
- package/dist/core/hooks/events.js +3 -1
- package/dist/core/hooks/registry.js +3 -0
- package/dist/core/hooks/worktree-events.js +158 -0
- package/dist/core/lsp/client.js +453 -0
- package/dist/core/lsp/server-detect.js +173 -0
- package/dist/core/lsp/symbol-cache.js +162 -0
- package/dist/core/lsp/symbol-tools.js +296 -4
- package/dist/core/mcp/server-tools.js +1 -1
- package/dist/core/mcp/server.js +1 -1
- package/dist/core/memory/secret-scanner.js +6 -6
- package/dist/core/onboarding/ensure-initialized.js +1 -1
- package/dist/core/plans/plan-artifact.js +2 -2
- package/dist/core/repl/ask.js +1 -1
- package/dist/core/repl/cap-warning.js +1 -1
- package/dist/core/repl/session.js +3 -3
- package/dist/core/repl/slash-commands.js +1 -1
- package/dist/core/routing/pre-flight-estimator.js +1 -1
- package/dist/core/settings.js +38 -0
- package/dist/core/worktree/include-parser.js +249 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +176 -28
- package/dist/runtime/commands/agents.js +1 -1
- package/dist/runtime/commands/config.js +41 -7
- package/dist/runtime/commands/hooks.js +3 -0
- package/dist/runtime/commands/review-consensus.js +1 -1
- package/dist/runtime/sigint-guard.js +272 -0
- package/dist/runtime/version.js +1 -1
- package/dist/runtime/worktree-bootstrap.js +579 -0
- package/dist/skills/bundled/batch.js +2 -2
- package/dist/skills/bundled/index.js +3 -3
- package/dist/skills/bundled/loop.js +2 -2
- package/dist/skills/bundled/remember.js +1 -1
- package/dist/skills/bundled/simplify.js +1 -1
- package/dist/skills/bundled/skillify.js +2 -2
- package/dist/skills/bundled/stuck.js +1 -1
- package/dist/skills/bundled/verify.js +2 -2
- package/dist/testing/vcr.js +2 -2
- package/dist/tools/ask-user-question.js +66 -0
- package/dist/tools/bash.js +2 -2
- package/dist/tools/lsp-tools.js +377 -1
- package/dist/tools/powershell.js +1 -1
- package/dist/tools/registry.js +23 -0
- package/dist/tui/ask-user-question-chips.js +257 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/render.js +1 -1
- package/dist/tui/repl.js +1 -1
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/update-banner.js +1 -1
- package/dist/tui/welcome-data.js +4 -4
- package/package.json +4 -3
- package/test/scenarios/compact-force.scenario.txt +3 -2
- package/test/scenarios/identity.scenario.txt +6 -5
- package/test/scenarios/persona-handoff.scenario.txt +2 -1
- package/test/scenarios/walkback.scenario.txt +6 -6
package/dist/core/lsp/client.js
CHANGED
|
@@ -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
|