@trim21/personal-pi-extensions 0.0.299 → 0.0.301
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/package.json +3 -1
- package/src/aft/ast-edit.ts +6 -7
- package/src/aft/bridge.ts +9 -16
- package/src/claude-code/files.ts +168 -137
- package/src/lib/lsp/adapter.ts +41 -0
- package/src/lib/lsp/adapters/clangd.ts +26 -0
- package/src/lib/lsp/adapters/index.ts +11 -0
- package/src/lib/lsp/adapters/pyright.ts +50 -0
- package/src/lib/lsp/adapters/ruff.ts +27 -0
- package/src/lib/lsp/adapters/typescript.ts +34 -0
- package/src/lib/lsp/bin.ts +88 -0
- package/src/lib/lsp/client.ts +693 -0
- package/src/lib/lsp/diagnostic.ts +35 -0
- package/src/lib/lsp/language.ts +125 -0
- package/src/lib/lsp/launch.ts +22 -0
- package/src/lib/lsp/lsp.ts +289 -0
- package/src/opencode/{read.ts → files.ts} +276 -31
- package/src/opencode/index.ts +13 -14
- package/src/spawn-agent.ts +6 -4
- package/src/opencode/edit.ts +0 -169
- package/src/opencode/write.ts +0 -116
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.301",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -88,6 +88,8 @@
|
|
|
88
88
|
"@cortexkit/aft-bridge": "0.51.0",
|
|
89
89
|
"@vscode/tree-sitter-wasm": "^0.3.1",
|
|
90
90
|
"jsonc-parser": "^3.3.1",
|
|
91
|
+
"vscode-jsonrpc": "^9.0.1",
|
|
92
|
+
"vscode-languageserver-types": "^3.18.0",
|
|
91
93
|
"web-tree-sitter": "^0.26.12"
|
|
92
94
|
},
|
|
93
95
|
"optionalDependencies": {
|
package/src/aft/ast-edit.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface PreviewExtract {
|
|
|
35
35
|
truncated: boolean;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** AFT preview diff
|
|
38
|
+
/** AFT preview diff 条目:preview 模式下默认带 before/after,>512KB 只带 truncated。 */
|
|
39
39
|
const previewDiffSchema = Type.Object({
|
|
40
40
|
before: Type.Optional(Type.String()),
|
|
41
41
|
after: Type.Optional(Type.String()),
|
|
@@ -232,12 +232,11 @@ export function registerAstEditTool(pi: ExtensionAPI, ctx: AftToolContext): void
|
|
|
232
232
|
const rawArgs = buildWireArgs(params, filePath);
|
|
233
233
|
|
|
234
234
|
// 第一步:preview(不写盘)拿所有变动文件的 before/after,供写保护审批。
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
235
|
+
// preview 必须经 options 传入(bridge 会放到 tool_call 消息顶层),
|
|
236
|
+
// 放进 arguments 会被 AFT 忽略并真的写盘。
|
|
237
|
+
const { response } = await callAftTool(bridgeFor(ctx), "edit", rawArgs, extCtx, {
|
|
238
|
+
preview: true,
|
|
239
|
+
});
|
|
241
240
|
const { files: previewFiles, truncated } = extractPreviewFiles(response);
|
|
242
241
|
if (truncated) {
|
|
243
242
|
throw new Error("ast_edit: file too large for preview (over 512KB)");
|
package/src/aft/bridge.ts
CHANGED
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
* aft-bridge 锁一致就不会走到最后的网络下载。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { createRequire } from "node:module";
|
|
11
|
-
|
|
12
10
|
import {
|
|
13
11
|
type AftProjectTransport,
|
|
14
12
|
type AftTransportPool,
|
|
@@ -22,16 +20,6 @@ import {
|
|
|
22
20
|
} from "@cortexkit/aft-bridge";
|
|
23
21
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
22
|
|
|
25
|
-
/** aft-bridge 包自身版本,作为二进制版本匹配基准。 */
|
|
26
|
-
const BRIDGE_VERSION: string = (() => {
|
|
27
|
-
try {
|
|
28
|
-
const req = createRequire(import.meta.url);
|
|
29
|
-
return (req("@cortexkit/aft-bridge/package.json") as { version?: string }).version ?? "0.0.0";
|
|
30
|
-
} catch {
|
|
31
|
-
return "0.0.0";
|
|
32
|
-
}
|
|
33
|
-
})();
|
|
34
|
-
|
|
35
23
|
/** Pi 会话 ID:Rust 侧用它做 session 作用域(undo/checkpoint),感知工具可留空。 */
|
|
36
24
|
export function resolveSessionId(extCtx: ExtensionContext): string | undefined {
|
|
37
25
|
const manager = (extCtx as unknown as { sessionManager?: { getSessionId?: () => string } })
|
|
@@ -40,9 +28,14 @@ export function resolveSessionId(extCtx: ExtensionContext): string | undefined {
|
|
|
40
28
|
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
41
29
|
}
|
|
42
30
|
|
|
43
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* 解析 aft 二进制;失败时抛出(调用方决定是否降级不注册工具)。
|
|
33
|
+
* 不带版本参数:findBinary 内部用 @cortexkit/aft-bridge 自身版本作为匹配基准,
|
|
34
|
+
* 与 npm 平台包(@cortexkit/aft-<platform>)精确对齐,避免手动读 package.json
|
|
35
|
+
* (其 exports 不暴露 ./package.json)。
|
|
36
|
+
*/
|
|
44
37
|
export async function resolveAftBinary(): Promise<string> {
|
|
45
|
-
const path = await findBinary(
|
|
38
|
+
const path = await findBinary();
|
|
46
39
|
if (!path) {
|
|
47
40
|
throw new Error(
|
|
48
41
|
"AFT binary not found. Install via npm platform package (@cortexkit/aft-<platform>), cargo install agent-file-tools, or place `aft` on PATH.",
|
|
@@ -64,7 +57,7 @@ export async function createAftPool(cwd: string): Promise<AftPool> {
|
|
|
64
57
|
const pool = await createAftTransportPool({
|
|
65
58
|
harness: "pi",
|
|
66
59
|
binaryPath,
|
|
67
|
-
poolOptions: {
|
|
60
|
+
poolOptions: {},
|
|
68
61
|
configOverrides: {
|
|
69
62
|
storage_dir: resolveCortexKitStorageRoot(),
|
|
70
63
|
cortexkit_user_config_path: paths.userConfigPath,
|
|
@@ -85,7 +78,7 @@ export async function callAftTool(
|
|
|
85
78
|
command: string,
|
|
86
79
|
rawArgs: Record<string, unknown>,
|
|
87
80
|
extCtx: ExtensionContext,
|
|
88
|
-
options?: BridgeRequestOptions,
|
|
81
|
+
options?: BridgeRequestOptions & { preview?: boolean },
|
|
89
82
|
softCodes?: ReadonlySet<string>,
|
|
90
83
|
): Promise<{ text: string; response: Record<string, unknown> }> {
|
|
91
84
|
const timeoutMs = timeoutForCommand(command);
|
package/src/claude-code/files.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
|
|
17
|
+
import { createLspService, initLsp, type LspService } from "../lib/lsp/lsp.js";
|
|
17
18
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
18
19
|
import {
|
|
19
20
|
type ClaudeCodeState,
|
|
@@ -211,7 +212,11 @@ function requireCurrentRead(
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
export function registerFileTools(
|
|
215
|
+
export function registerFileTools(
|
|
216
|
+
pi: ExtensionAPI,
|
|
217
|
+
state: ClaudeCodeState,
|
|
218
|
+
service: LspService,
|
|
219
|
+
): void {
|
|
215
220
|
pi.registerTool({
|
|
216
221
|
name: "Read",
|
|
217
222
|
label: "Read",
|
|
@@ -305,6 +310,10 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
305
310
|
const snapshot = snapshotOf(buffer);
|
|
306
311
|
const key = await readStateKey(filePath);
|
|
307
312
|
state.reads.set(key, snapshot);
|
|
313
|
+
// LSP warm-up 是后台任务,失败不影响读取
|
|
314
|
+
void service.touchFile(filePath, ctx.cwd).catch(() => {
|
|
315
|
+
// 后台 warm-up 失败不影响读取
|
|
316
|
+
});
|
|
308
317
|
return {
|
|
309
318
|
content: [{ type: "text", text: formatted.text }],
|
|
310
319
|
details: { reads: { [key]: snapshot } },
|
|
@@ -348,118 +357,127 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
348
357
|
replaceAll: params.replace_all,
|
|
349
358
|
},
|
|
350
359
|
});
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
if (
|
|
365
|
-
|
|
360
|
+
const [message, details] = await withFileMutationQueue<[string, FileToolDetails]>(
|
|
361
|
+
filePath,
|
|
362
|
+
async () => {
|
|
363
|
+
const oldString = params.old_string;
|
|
364
|
+
const newString = params.new_string;
|
|
365
|
+
if (oldString === newString) {
|
|
366
|
+
throw new Error("No changes to make: old_string and new_string are exactly the same.");
|
|
367
|
+
}
|
|
368
|
+
// 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
|
|
369
|
+
if (oldString === "") {
|
|
370
|
+
let exists = true;
|
|
371
|
+
try {
|
|
372
|
+
const value = await stat(filePath);
|
|
373
|
+
if (value.isFile()) {
|
|
374
|
+
const content = await readFile(filePath, "utf8");
|
|
375
|
+
if (content.trim() !== "") {
|
|
376
|
+
throw new Error("Cannot create new file - file already exists.");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
381
|
+
exists = false;
|
|
382
|
+
} else {
|
|
383
|
+
throw error;
|
|
366
384
|
}
|
|
367
385
|
}
|
|
386
|
+
if (!exists) await mkdir(dirname(filePath), { recursive: true });
|
|
387
|
+
await writeFile(filePath, newString, "utf8");
|
|
388
|
+
const snapshot = snapshotOf(newString);
|
|
389
|
+
const key = await readStateKey(filePath);
|
|
390
|
+
state.reads.set(key, snapshot);
|
|
391
|
+
return [
|
|
392
|
+
`The file ${filePath} has been updated successfully.`,
|
|
393
|
+
{ reads: { [key]: snapshot } },
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
const replaceAll = params.replace_all ?? false;
|
|
397
|
+
// 防止 OOM 的大文件检查(对齐 Claude Code)
|
|
398
|
+
try {
|
|
399
|
+
const { size } = await stat(filePath);
|
|
400
|
+
if (size > MAX_EDIT_FILE_SIZE) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
368
405
|
} catch (error) {
|
|
369
|
-
if (error instanceof Error
|
|
370
|
-
exists = false;
|
|
371
|
-
} else {
|
|
406
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
372
407
|
throw error;
|
|
373
408
|
}
|
|
374
409
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
try {
|
|
390
|
-
const { size } = await stat(filePath);
|
|
391
|
-
if (size > MAX_EDIT_FILE_SIZE) {
|
|
410
|
+
let content: Buffer;
|
|
411
|
+
try {
|
|
412
|
+
content = await readFile(filePath);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
415
|
+
const suggestion = await didYouMean(filePath, ctx.cwd);
|
|
416
|
+
throw new Error(
|
|
417
|
+
`File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
|
|
418
|
+
{ cause: error },
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
if (extname(filePath).toLowerCase() === ".ipynb") {
|
|
392
424
|
throw new Error(
|
|
393
|
-
|
|
425
|
+
"File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
|
|
394
426
|
);
|
|
395
427
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
428
|
+
const key = await readStateKey(filePath);
|
|
429
|
+
requireCurrentRead(state, key, filePath, content);
|
|
430
|
+
await access(filePath, constants.R_OK | constants.W_OK);
|
|
431
|
+
throwIfAborted(signal);
|
|
432
|
+
const original = content.toString("utf8");
|
|
433
|
+
|
|
434
|
+
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
435
|
+
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
436
|
+
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
437
|
+
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
438
|
+
const normalized = original.replaceAll("\r\n", "\n");
|
|
439
|
+
const actualOldString = findActualString(normalized, oldString) ?? oldString;
|
|
440
|
+
const matches = normalized.split(actualOldString).length - 1;
|
|
441
|
+
if (matches === 0) {
|
|
442
|
+
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
399
443
|
}
|
|
400
|
-
|
|
401
|
-
let content: Buffer;
|
|
402
|
-
try {
|
|
403
|
-
content = await readFile(filePath);
|
|
404
|
-
} catch (error) {
|
|
405
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
406
|
-
const suggestion = await didYouMean(filePath, ctx.cwd);
|
|
444
|
+
if (!replaceAll && matches > 1) {
|
|
407
445
|
throw new Error(
|
|
408
|
-
`
|
|
409
|
-
{ cause: error },
|
|
446
|
+
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
|
|
410
447
|
);
|
|
411
448
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
449
|
+
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
450
|
+
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
451
|
+
const updated = replaceAll
|
|
452
|
+
? normalized.split(actualOldString).join(actualNewString)
|
|
453
|
+
: normalized.replace(actualOldString, () => actualNewString);
|
|
454
|
+
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
455
|
+
await writeFile(filePath, restored, "utf8");
|
|
456
|
+
const snapshot = snapshotOf(restored);
|
|
457
|
+
state.reads.set(key, snapshot);
|
|
458
|
+
const diff = generateDiffString(original, restored);
|
|
459
|
+
const text = replaceAll
|
|
460
|
+
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
461
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
462
|
+
return [
|
|
463
|
+
text,
|
|
464
|
+
{
|
|
465
|
+
diff: diff.diff,
|
|
466
|
+
patch: generateUnifiedPatch(filePath, original, restored),
|
|
467
|
+
firstChangedLine: diff.firstChangedLine,
|
|
468
|
+
reads: { [key]: snapshot },
|
|
469
|
+
},
|
|
470
|
+
];
|
|
471
|
+
},
|
|
472
|
+
);
|
|
424
473
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (matches === 0) {
|
|
433
|
-
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
434
|
-
}
|
|
435
|
-
if (!replaceAll && matches > 1) {
|
|
436
|
-
throw new Error(
|
|
437
|
-
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
441
|
-
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
442
|
-
const updated = replaceAll
|
|
443
|
-
? normalized.split(actualOldString).join(actualNewString)
|
|
444
|
-
: normalized.replace(actualOldString, () => actualNewString);
|
|
445
|
-
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
446
|
-
await writeFile(filePath, restored, "utf8");
|
|
447
|
-
const snapshot = snapshotOf(restored);
|
|
448
|
-
state.reads.set(key, snapshot);
|
|
449
|
-
const diff = generateDiffString(original, restored);
|
|
450
|
-
const text = replaceAll
|
|
451
|
-
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
452
|
-
: `The file ${filePath} has been updated successfully.`;
|
|
453
|
-
return {
|
|
454
|
-
content: [{ type: "text", text }],
|
|
455
|
-
details: {
|
|
456
|
-
diff: diff.diff,
|
|
457
|
-
patch: generateUnifiedPatch(filePath, original, restored),
|
|
458
|
-
firstChangedLine: diff.firstChangedLine,
|
|
459
|
-
reads: { [key]: snapshot },
|
|
460
|
-
} satisfies FileToolDetails,
|
|
461
|
-
};
|
|
462
|
-
});
|
|
474
|
+
throwIfAborted(signal);
|
|
475
|
+
// 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
|
|
476
|
+
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
|
|
477
|
+
const text = diagnosticText
|
|
478
|
+
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
479
|
+
: message;
|
|
480
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
463
481
|
},
|
|
464
482
|
});
|
|
465
483
|
|
|
@@ -490,44 +508,55 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
|
|
|
490
508
|
absolutePath: filePath,
|
|
491
509
|
change: { oldText: "", newText: params.content },
|
|
492
510
|
});
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
511
|
+
const [message, details] = await withFileMutationQueue<[string, FileToolDetails]>(
|
|
512
|
+
filePath,
|
|
513
|
+
async () => {
|
|
514
|
+
let original: string | undefined;
|
|
515
|
+
let key: string | undefined;
|
|
516
|
+
try {
|
|
517
|
+
const value = await stat(filePath);
|
|
518
|
+
if (value.isFile()) {
|
|
519
|
+
const content = await readFile(filePath);
|
|
520
|
+
key = await readStateKey(filePath);
|
|
521
|
+
requireCurrentRead(state, key, filePath, content);
|
|
522
|
+
original = content.toString("utf8");
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
507
528
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
529
|
+
throwIfAborted(signal);
|
|
530
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
531
|
+
await writeFile(filePath, params.content, "utf8");
|
|
532
|
+
const snapshot = snapshotOf(params.content);
|
|
533
|
+
// 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
|
|
534
|
+
const resolvedKey = key ?? (await readStateKey(filePath));
|
|
535
|
+
state.reads.set(resolvedKey, snapshot);
|
|
536
|
+
const diff = generateDiffString(original ?? "", params.content);
|
|
537
|
+
const text =
|
|
538
|
+
original === undefined
|
|
539
|
+
? `File created successfully at: ${filePath}`
|
|
540
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
541
|
+
return [
|
|
542
|
+
text,
|
|
543
|
+
{
|
|
544
|
+
diff: diff.diff,
|
|
545
|
+
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
|
546
|
+
firstChangedLine: diff.firstChangedLine,
|
|
547
|
+
reads: { [resolvedKey]: snapshot },
|
|
548
|
+
},
|
|
549
|
+
];
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
throwIfAborted(signal);
|
|
554
|
+
// 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
|
|
555
|
+
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
|
|
556
|
+
const text = diagnosticText
|
|
557
|
+
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
558
|
+
: message;
|
|
559
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
531
560
|
},
|
|
532
561
|
});
|
|
533
562
|
}
|
|
@@ -562,6 +591,8 @@ function restoreFileReads(
|
|
|
562
591
|
* 与主进程 index.ts 聚合加载时的行为一致。
|
|
563
592
|
*/
|
|
564
593
|
export default function claudeCodeFileTools(pi: ExtensionAPI): void {
|
|
594
|
+
const service = createLspService();
|
|
595
|
+
initLsp(pi, service);
|
|
565
596
|
const state = createClaudeCodeState();
|
|
566
597
|
|
|
567
598
|
// 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的
|
|
@@ -579,5 +610,5 @@ export default function claudeCodeFileTools(pi: ExtensionAPI): void {
|
|
|
579
610
|
restoreFileReads(state, ctx.sessionManager);
|
|
580
611
|
});
|
|
581
612
|
|
|
582
|
-
registerFileTools(pi, state);
|
|
613
|
+
registerFileTools(pi, state, service);
|
|
583
614
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP 服务器插件契约:每个语言服务器一个 adapter class,向管理器提供统一接口。
|
|
3
|
+
*
|
|
4
|
+
* - findRoot 返回 undefined 表示该文件不应启用此服务器(目录外 / 无项目标记);
|
|
5
|
+
* - spawn 返回 undefined 表示服务器不可用(二进制未安装)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { exists, walkUp } from "./bin.js";
|
|
12
|
+
|
|
13
|
+
export interface LspServerHandle {
|
|
14
|
+
process: ChildProcessWithoutNullStreams;
|
|
15
|
+
initialization?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LspServerAdapter {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** 关联的文件扩展名(含点,小写);空数组表示匹配所有文件。 */
|
|
21
|
+
readonly extensions: readonly string[];
|
|
22
|
+
findRoot(file: string, cwd: string): Promise<string | undefined>;
|
|
23
|
+
spawn(root: string, cwd: string): Promise<LspServerHandle | undefined>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从文件所在目录向上(到 cwd)找项目标记文件;找不到时返回 cwd(opencode 的
|
|
28
|
+
* NearestRoot 宽松语义,保证项目内文件至少有一个 root)。
|
|
29
|
+
*/
|
|
30
|
+
export function nearestRoot(
|
|
31
|
+
markers: readonly string[],
|
|
32
|
+
file: string,
|
|
33
|
+
cwd: string,
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
for (const dir of walkUp(dirname(file), cwd)) {
|
|
36
|
+
for (const marker of markers) {
|
|
37
|
+
if (exists(join(dir, marker))) return Promise.resolve(dir);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return Promise.resolve(cwd);
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C/C++ LSP 服务器(clangd,随 LLVM/clang 工具链安装,走 PATH)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { type LspServerAdapter, type LspServerHandle, nearestRoot } from "../adapter.js";
|
|
6
|
+
import { which } from "../bin.js";
|
|
7
|
+
import { spawnProcess } from "../launch.js";
|
|
8
|
+
|
|
9
|
+
const PROJECT_MARKERS = ["compile_commands.json", "compile_flags.txt", ".clangd"];
|
|
10
|
+
|
|
11
|
+
export class ClangdAdapter implements LspServerAdapter {
|
|
12
|
+
readonly id = "clangd";
|
|
13
|
+
readonly extensions = [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"];
|
|
14
|
+
|
|
15
|
+
findRoot(file: string, cwd: string): Promise<string> {
|
|
16
|
+
return nearestRoot(PROJECT_MARKERS, file, cwd);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
spawn(root: string): Promise<LspServerHandle | undefined> {
|
|
20
|
+
const bin = which("clangd");
|
|
21
|
+
if (!bin) return Promise.resolve(undefined);
|
|
22
|
+
return Promise.resolve({
|
|
23
|
+
process: spawnProcess(bin, ["--background-index", "--clang-tidy"], { cwd: root }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ClangdAdapter } from "./clangd.js";
|
|
2
|
+
import { PyrightAdapter } from "./pyright.js";
|
|
3
|
+
import { RuffAdapter } from "./ruff.js";
|
|
4
|
+
import { TypescriptAdapter } from "./typescript.js";
|
|
5
|
+
|
|
6
|
+
export type { LspServerAdapter, LspServerHandle } from "../adapter.js";
|
|
7
|
+
|
|
8
|
+
/** 组装启用的服务器列表(新增语言:实现 adapter 后在这里注册)。 */
|
|
9
|
+
export function createAdapters() {
|
|
10
|
+
return [new TypescriptAdapter(), new PyrightAdapter(), new RuffAdapter(), new ClangdAdapter()];
|
|
11
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python 类型检查 LSP 服务器(pyright-langserver)。
|
|
3
|
+
* pythonPath 优先取 VIRTUAL_ENV,其次项目 .venv / venv 里的解释器。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { type LspServerAdapter, nearestRoot } from "../adapter.js";
|
|
9
|
+
import { exists, findBinaryInWorkspace, which } from "../bin.js";
|
|
10
|
+
import { spawnProcess } from "../launch.js";
|
|
11
|
+
|
|
12
|
+
const PROJECT_MARKERS = [
|
|
13
|
+
"pyproject.toml",
|
|
14
|
+
"setup.py",
|
|
15
|
+
"setup.cfg",
|
|
16
|
+
"requirements.txt",
|
|
17
|
+
"Pipfile",
|
|
18
|
+
"pyrightconfig.json",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export class PyrightAdapter implements LspServerAdapter {
|
|
22
|
+
readonly id = "pyright";
|
|
23
|
+
readonly extensions = [".py", ".pyi"];
|
|
24
|
+
|
|
25
|
+
findRoot(file: string, cwd: string): Promise<string> {
|
|
26
|
+
return nearestRoot(PROJECT_MARKERS, file, cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async spawn(root: string, cwd: string) {
|
|
30
|
+
const bin =
|
|
31
|
+
(await findBinaryInWorkspace("pyright-langserver", root, cwd)) ?? which("pyright-langserver");
|
|
32
|
+
if (!bin) return;
|
|
33
|
+
|
|
34
|
+
const initialization: Record<string, string> = {};
|
|
35
|
+
const venvs = [process.env.VIRTUAL_ENV, join(root, ".venv"), join(root, "venv")];
|
|
36
|
+
for (const venv of venvs) {
|
|
37
|
+
if (!venv) continue;
|
|
38
|
+
const python = join(venv, process.platform === "win32" ? "Scripts" : "bin", "python");
|
|
39
|
+
if (exists(python)) {
|
|
40
|
+
initialization.pythonPath = python;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
process: spawnProcess(bin, ["--stdio"], { cwd: root }),
|
|
47
|
+
initialization,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python lint LSP 服务器(ruff 内置的 `ruff server`)。
|
|
3
|
+
* 二进制优先项目 .venv / venv,其次 PATH。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type LspServerAdapter, nearestRoot } from "../adapter.js";
|
|
7
|
+
import { findBinaryInWorkspace, which } from "../bin.js";
|
|
8
|
+
import { spawnProcess } from "../launch.js";
|
|
9
|
+
|
|
10
|
+
const PROJECT_MARKERS = ["pyproject.toml", "ruff.toml", ".ruff.toml"];
|
|
11
|
+
|
|
12
|
+
export class RuffAdapter implements LspServerAdapter {
|
|
13
|
+
readonly id = "ruff";
|
|
14
|
+
readonly extensions = [".py", ".pyi"];
|
|
15
|
+
|
|
16
|
+
findRoot(file: string, cwd: string): Promise<string> {
|
|
17
|
+
return nearestRoot(PROJECT_MARKERS, file, cwd);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async spawn(root: string, cwd: string) {
|
|
21
|
+
const bin = (await findBinaryInWorkspace("ruff", root, cwd)) ?? which("ruff");
|
|
22
|
+
if (!bin) return;
|
|
23
|
+
return {
|
|
24
|
+
process: spawnProcess(bin, ["server"], { cwd: root }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|