@trim21/personal-pi-extensions 0.0.403 → 0.0.407
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/README.md +1 -1
- package/package.json +1 -1
- package/src/aft/tools.ts +2 -12
- package/src/bwrap/approval-rules.ts +38 -6
- package/src/claude-code/files.ts +59 -19
- package/src/lib/lsp/lsp.ts +16 -17
- package/src/lib/lsp/server-config.ts +5 -87
- package/src/lib/path.ts +12 -1
- package/src/opencode/files.ts +47 -17
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
|
|
|
94
94
|
}
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
`dangerouslyDisableSandbox: true` 的审批流程:先按 `approvalRules` 匹配(含嵌套 `$(...)` 内的命令,规则后写优先),命中 allow/deny
|
|
97
|
+
`dangerouslyDisableSandbox: true` 的审批流程:先按 `approvalRules` 匹配(含嵌套 `$(...)` 内的命令,规则后写优先),命中 allow/deny 直接放行/拒绝,未命中才弹确认框。含文件输出重定向(`>` / `>>` / `&>` 等)的命令即使命令规则全匹配也不会自动放行,避免 `echo *` 把 `echo '' > file` 带过;管道(`echo | tail`)和 fd 复制(`2>&1`)不受影响。
|
|
98
98
|
|
|
99
99
|
### 使用
|
|
100
100
|
|
package/package.json
CHANGED
package/src/aft/tools.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { stat } from "node:fs/promises";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import {
|
|
11
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
14
|
import {
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { Type } from "typebox";
|
|
25
25
|
|
|
26
|
-
import { formatDisplayPath } from "../lib/path.js";
|
|
26
|
+
import { formatDisplayPath, formatSubtitlePath } from "../lib/path.js";
|
|
27
27
|
import { type ToolPendant } from "../lib/pendant.js";
|
|
28
28
|
import { callAftTool, SEMANTIC_INDEX_WAIT_TIMEOUT_MS } from "./bridge.js";
|
|
29
29
|
|
|
@@ -51,16 +51,6 @@ export function resolvePathArg(cwd: string, input: string): string {
|
|
|
51
51
|
return isAbsolute(input) ? input : resolve(cwd, input);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** subtitle 中路径的最大显示长度,超过时退化为仅文件名。 */
|
|
55
|
-
export const MAX_SUBTITLE_PATH_LENGTH = 20;
|
|
56
|
-
|
|
57
|
-
/** subtitle 用的显示路径:优先 `./…` / `~/…` / 绝对路径,结果过长时只显示文件名。 */
|
|
58
|
-
export function formatSubtitlePath(cwd: string, filePath: string): string {
|
|
59
|
-
const display = formatDisplayPath(cwd, filePath);
|
|
60
|
-
if (display.length <= MAX_SUBTITLE_PATH_LENGTH) return display;
|
|
61
|
-
return basename(filePath);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
54
|
export interface AftToolContext {
|
|
65
55
|
cwd: string;
|
|
66
56
|
pool: AftTransportPool;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* 用 tree-sitter 解析命令(含嵌套 `$(...)`),按 BashArity 生成命令模式
|
|
4
4
|
* (`git checkout main` → `git checkout *`),再用通配匹配对 allow/deny
|
|
5
5
|
* 规则求值。接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
|
|
6
|
-
*
|
|
6
|
+
* 放行/拒绝,未命中才弹审批对话框。文件输出重定向(`>` / `>>` / `&>`
|
|
7
|
+
* 等)不会因命令规则自动放行,避免 `echo *` 把 `echo '' > file` 带过。
|
|
7
8
|
*
|
|
8
9
|
* 参考实现:
|
|
9
10
|
* - opencode packages/opencode/src/permission/arity.ts(BashArity 表)
|
|
@@ -27,6 +28,8 @@ export interface ParsedBash {
|
|
|
27
28
|
commands: BashCommand[];
|
|
28
29
|
/** 语法错误时的提示(解析失败不抛错,退化为无法匹配)。 */
|
|
29
30
|
error?: string;
|
|
31
|
+
/** 含写入文件的重定向(`>` / `>>` / `&>` / `<>` 等);fd 复制与纯输入除外。 */
|
|
32
|
+
hasFileOutputRedirect: boolean;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export type ApprovalAction = "allow" | "deny";
|
|
@@ -108,6 +111,27 @@ function extractParts(node: Node): { name: string; args: string[] } {
|
|
|
108
111
|
return { name: name.join(" "), args };
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
/** 会打开/截断/追加文件的重定向算子;`>&` / `<&` 是 fd 复制,不算。 */
|
|
115
|
+
const FILE_OUTPUT_REDIRECT_OPS = new Set([">", ">>", ">|", "&>", "&>>"]);
|
|
116
|
+
|
|
117
|
+
function fileRedirectWritesToFile(node: Node): boolean {
|
|
118
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
119
|
+
const child = node.child(i);
|
|
120
|
+
if (!child) continue;
|
|
121
|
+
if (FILE_OUTPUT_REDIRECT_OPS.has(child.type)) return true;
|
|
122
|
+
// `<>` 被解析成 `<` + 含 `>` 的 ERROR
|
|
123
|
+
if (child.type === "ERROR" && child.text.includes(">")) return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function treeHasFileOutputRedirect(root: Node): boolean {
|
|
129
|
+
for (const node of root.descendantsOfType("file_redirect")) {
|
|
130
|
+
if (fileRedirectWritesToFile(node)) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
111
135
|
function collectCommand(node: Node, all: Node[]): BashCommand | undefined {
|
|
112
136
|
const { name, args } = extractParts(node);
|
|
113
137
|
if (!name) return undefined;
|
|
@@ -139,10 +163,11 @@ export async function parseBashCommands(command: string): Promise<ParsedBash> {
|
|
|
139
163
|
const parsed = collectCommand(node, all);
|
|
140
164
|
if (parsed) commands.push(parsed);
|
|
141
165
|
}
|
|
142
|
-
return { commands };
|
|
166
|
+
return { commands, hasFileOutputRedirect: treeHasFileOutputRedirect(tree.rootNode) };
|
|
143
167
|
} catch (error) {
|
|
144
168
|
return {
|
|
145
169
|
commands: [],
|
|
170
|
+
hasFileOutputRedirect: false,
|
|
146
171
|
error: error instanceof Error ? error.message : String(error),
|
|
147
172
|
};
|
|
148
173
|
}
|
|
@@ -197,20 +222,25 @@ export function matchRule(input: string, pattern: string): boolean {
|
|
|
197
222
|
* 命令(含所有嵌套命令)的权限模式列表(命令替换里的命令也展开)。
|
|
198
223
|
* 供规则求值与"allow forever"写规则复用。
|
|
199
224
|
*/
|
|
200
|
-
|
|
201
|
-
const parsed = await parseBashCommands(command);
|
|
225
|
+
function patternsFromCommands(commands: BashCommand[]): string[] {
|
|
202
226
|
const flat: string[] = [];
|
|
203
227
|
const visit = (cmd: BashCommand) => {
|
|
204
228
|
flat.push(commandPattern(cmd));
|
|
205
229
|
for (const nested of cmd.nested) visit(nested);
|
|
206
230
|
};
|
|
207
|
-
for (const cmd of
|
|
231
|
+
for (const cmd of commands) visit(cmd);
|
|
208
232
|
return flat;
|
|
209
233
|
}
|
|
210
234
|
|
|
235
|
+
export async function commandPatternsFor(command: string): Promise<string[]> {
|
|
236
|
+
const parsed = await parseBashCommands(command);
|
|
237
|
+
return patternsFromCommands(parsed.commands);
|
|
238
|
+
}
|
|
239
|
+
|
|
211
240
|
/**
|
|
212
241
|
* 对命令(含所有嵌套命令)求值:
|
|
213
242
|
* - deny 优先:任一命令命中 deny 规则即整体拒绝
|
|
243
|
+
* - 文件输出重定向不自动放行:即使命令规则全匹配,也返回 undefined 交人审
|
|
214
244
|
* - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
|
|
215
245
|
* undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
|
|
216
246
|
* 规则内后写优先(findLast,对齐 opencode PermissionV2)。
|
|
@@ -219,7 +249,8 @@ export async function evaluateBashApproval(
|
|
|
219
249
|
command: string,
|
|
220
250
|
rules: readonly ApprovalRule[],
|
|
221
251
|
): Promise<ApprovalAction | undefined> {
|
|
222
|
-
const
|
|
252
|
+
const parsed = await parseBashCommands(command);
|
|
253
|
+
const patterns = patternsFromCommands(parsed.commands);
|
|
223
254
|
if (patterns.length === 0) return;
|
|
224
255
|
let allowed = 0;
|
|
225
256
|
for (const pattern of patterns) {
|
|
@@ -227,5 +258,6 @@ export async function evaluateBashApproval(
|
|
|
227
258
|
if (rule?.action === "deny") return "deny";
|
|
228
259
|
if (rule?.action === "allow") allowed++;
|
|
229
260
|
}
|
|
261
|
+
if (parsed.hasFileOutputRedirect) return;
|
|
230
262
|
return allowed === patterns.length ? "allow" : undefined;
|
|
231
263
|
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
|
|
17
17
|
import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
|
|
18
|
+
import { formatSubtitlePath } from "../lib/path.js";
|
|
18
19
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
19
20
|
import {
|
|
20
21
|
type ClaudeCodeState,
|
|
@@ -295,7 +296,13 @@ export function registerFileTools(
|
|
|
295
296
|
const snapshot = snapshotOf(image, false);
|
|
296
297
|
const key = await readStateKey(filePath);
|
|
297
298
|
state.reads.set(key, snapshot);
|
|
298
|
-
return {
|
|
299
|
+
return {
|
|
300
|
+
content,
|
|
301
|
+
details: {
|
|
302
|
+
reads: { [key]: snapshot },
|
|
303
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath) },
|
|
304
|
+
},
|
|
305
|
+
};
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
const offset = params.offset ?? 1;
|
|
@@ -313,7 +320,10 @@ export function registerFileTools(
|
|
|
313
320
|
previous.limit === limit &&
|
|
314
321
|
snapshotsEqual(previous, snapshotOf(buffer))
|
|
315
322
|
) {
|
|
316
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: "text", text: FILE_UNCHANGED_STUB }],
|
|
325
|
+
details: { pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath) } },
|
|
326
|
+
};
|
|
317
327
|
}
|
|
318
328
|
if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
|
|
319
329
|
throw new Error(`Cannot read binary file: ${filePath}`);
|
|
@@ -340,7 +350,10 @@ export function registerFileTools(
|
|
|
340
350
|
});
|
|
341
351
|
return {
|
|
342
352
|
content: [{ type: "text", text: formatted.text }],
|
|
343
|
-
details: {
|
|
353
|
+
details: {
|
|
354
|
+
reads: { [key]: snapshot },
|
|
355
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath) },
|
|
356
|
+
},
|
|
344
357
|
};
|
|
345
358
|
},
|
|
346
359
|
});
|
|
@@ -381,8 +394,8 @@ export function registerFileTools(
|
|
|
381
394
|
replaceAll: params.replace_all,
|
|
382
395
|
},
|
|
383
396
|
});
|
|
384
|
-
const [message, details, diagnosticText] = await withFileMutationQueue<
|
|
385
|
-
[string, FileToolDetails, string]
|
|
397
|
+
const [message, details, diagnosticText, errorCount] = await withFileMutationQueue<
|
|
398
|
+
[string, FileToolDetails, string, number]
|
|
386
399
|
>(filePath, async () => {
|
|
387
400
|
const oldString = params.old_string;
|
|
388
401
|
const newString = params.new_string;
|
|
@@ -414,10 +427,14 @@ export function registerFileTools(
|
|
|
414
427
|
state.reads.set(key, snapshot);
|
|
415
428
|
const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
|
|
416
429
|
throwIfAborted(signal);
|
|
417
|
-
const diagnosticText = await service.lspDiagnosticsForFile(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
430
|
+
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
431
|
+
filePath,
|
|
432
|
+
ctx.cwd,
|
|
433
|
+
{
|
|
434
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
435
|
+
signal,
|
|
436
|
+
},
|
|
437
|
+
);
|
|
421
438
|
return [
|
|
422
439
|
`The file ${filePath} has been updated successfully.`,
|
|
423
440
|
{
|
|
@@ -427,6 +444,7 @@ export function registerFileTools(
|
|
|
427
444
|
reads: { [key]: snapshot },
|
|
428
445
|
},
|
|
429
446
|
diagnosticText,
|
|
447
|
+
errorCount,
|
|
430
448
|
];
|
|
431
449
|
}
|
|
432
450
|
const replaceAll = params.replace_all ?? false;
|
|
@@ -511,9 +529,13 @@ export function registerFileTools(
|
|
|
511
529
|
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
512
530
|
: `The file ${filePath} has been updated successfully.`;
|
|
513
531
|
throwIfAborted(signal);
|
|
514
|
-
const diagnosticText = await service.lspDiagnosticsForFile(
|
|
515
|
-
|
|
516
|
-
|
|
532
|
+
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
533
|
+
filePath,
|
|
534
|
+
ctx.cwd,
|
|
535
|
+
{
|
|
536
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
537
|
+
},
|
|
538
|
+
);
|
|
517
539
|
return [
|
|
518
540
|
text,
|
|
519
541
|
{
|
|
@@ -527,13 +549,20 @@ export function registerFileTools(
|
|
|
527
549
|
reads: { [key]: snapshot },
|
|
528
550
|
},
|
|
529
551
|
diagnosticText,
|
|
552
|
+
errorCount,
|
|
530
553
|
];
|
|
531
554
|
});
|
|
532
555
|
|
|
533
556
|
const text = diagnosticText
|
|
534
557
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
535
558
|
: message;
|
|
536
|
-
return {
|
|
559
|
+
return {
|
|
560
|
+
content: [{ type: "text" as const, text }],
|
|
561
|
+
details: {
|
|
562
|
+
...details,
|
|
563
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount) },
|
|
564
|
+
},
|
|
565
|
+
};
|
|
537
566
|
},
|
|
538
567
|
});
|
|
539
568
|
|
|
@@ -564,8 +593,8 @@ export function registerFileTools(
|
|
|
564
593
|
absolutePath: filePath,
|
|
565
594
|
change: { oldText: "", newText: params.content },
|
|
566
595
|
});
|
|
567
|
-
const [message, details, diagnosticText] = await withFileMutationQueue<
|
|
568
|
-
[string, FileToolDetails, string]
|
|
596
|
+
const [message, details, diagnosticText, errorCount] = await withFileMutationQueue<
|
|
597
|
+
[string, FileToolDetails, string, number]
|
|
569
598
|
>(filePath, async () => {
|
|
570
599
|
let original: string | undefined;
|
|
571
600
|
let key: string | undefined;
|
|
@@ -595,9 +624,13 @@ export function registerFileTools(
|
|
|
595
624
|
? `File created successfully at: ${filePath}`
|
|
596
625
|
: `The file ${filePath} has been updated successfully.`;
|
|
597
626
|
throwIfAborted(signal);
|
|
598
|
-
const diagnosticText = await service.lspDiagnosticsForFile(
|
|
599
|
-
|
|
600
|
-
|
|
627
|
+
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
628
|
+
filePath,
|
|
629
|
+
ctx.cwd,
|
|
630
|
+
{
|
|
631
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
632
|
+
},
|
|
633
|
+
);
|
|
601
634
|
return [
|
|
602
635
|
text,
|
|
603
636
|
{
|
|
@@ -607,13 +640,20 @@ export function registerFileTools(
|
|
|
607
640
|
reads: { [resolvedKey]: snapshot },
|
|
608
641
|
},
|
|
609
642
|
diagnosticText,
|
|
643
|
+
errorCount,
|
|
610
644
|
];
|
|
611
645
|
});
|
|
612
646
|
|
|
613
647
|
const text = diagnosticText
|
|
614
648
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
615
649
|
: message;
|
|
616
|
-
return {
|
|
650
|
+
return {
|
|
651
|
+
content: [{ type: "text" as const, text }],
|
|
652
|
+
details: {
|
|
653
|
+
...details,
|
|
654
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath, errorCount) },
|
|
655
|
+
},
|
|
656
|
+
};
|
|
617
657
|
},
|
|
618
658
|
});
|
|
619
659
|
}
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - 配置来源:全局 `~/.pi/agent/lsp.json` + 本地 `<cwd>/.pi/lsp.json`
|
|
7
7
|
* (本地覆盖全局):`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局
|
|
8
8
|
* 其余服务器保留),`enabled`/`disabled` 白名单与各超时参数继续生效;
|
|
9
|
+
* 没有内置默认服务器,所有服务器均须在配置里定义;
|
|
9
10
|
* 配置在每个工具的调用 cwd 下惰性读取;enabled/disabled 引用不存在的
|
|
10
11
|
* 服务器 id 是配置错误:全局配置在扩展加载(createLspService)时抛错,
|
|
11
12
|
* 本地配置在 session 开始预加载时通知,工具调用时校验抛错兜底;
|
|
@@ -26,13 +27,7 @@ import { Value } from "typebox/value";
|
|
|
26
27
|
import { type LspServerAdapter } from "./adapter.js";
|
|
27
28
|
import { create, type CreateInput, type Diagnostic, type Info as LspClient } from "./client.js";
|
|
28
29
|
import { report } from "./diagnostic.js";
|
|
29
|
-
import {
|
|
30
|
-
createAdapters,
|
|
31
|
-
defaultServers,
|
|
32
|
-
mergeServerConfigs,
|
|
33
|
-
mergeServerRecords,
|
|
34
|
-
serverConfigSchema,
|
|
35
|
-
} from "./server-config.js";
|
|
30
|
+
import { createAdapters, mergeServerRecords, serverConfigSchema } from "./server-config.js";
|
|
36
31
|
|
|
37
32
|
/** 超时值:number(毫秒,>=1)或字符串("500"、"5s"、"1m"),Parse 后由 toMs 统一换算。 */
|
|
38
33
|
const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
|
|
@@ -41,7 +36,7 @@ const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
|
|
|
41
36
|
const lspConfigSchema = Type.Object({
|
|
42
37
|
/** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝并回退空配置。 */
|
|
43
38
|
version: Type.Optional(Type.Number()),
|
|
44
|
-
/** 配置驱动的语言服务器定义(id →
|
|
39
|
+
/** 配置驱动的语言服务器定义(id → 配置);无内置默认,全部在此定义。 */
|
|
45
40
|
servers: Type.Optional(Type.Record(Type.String(), serverConfigSchema)),
|
|
46
41
|
/** 只启用列出的服务器 id(缺省 = 全部启用)。 */
|
|
47
42
|
enabled: Type.Optional(Type.Array(Type.String())),
|
|
@@ -124,9 +119,7 @@ function readConfigFileSync(filePath: string): LspConfig {
|
|
|
124
119
|
*/
|
|
125
120
|
function validateConfig(config: LspConfig, adapters?: LspServerAdapter[]): void {
|
|
126
121
|
const available = new Set(
|
|
127
|
-
adapters
|
|
128
|
-
? adapters.map((adapter) => adapter.id)
|
|
129
|
-
: Object.keys(mergeServerConfigs(defaultServers, config.servers)),
|
|
122
|
+
adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers ?? {}),
|
|
130
123
|
);
|
|
131
124
|
const unknown = [...(config.enabled ?? []), ...(config.disabled ?? [])].filter(
|
|
132
125
|
(id) => !available.has(id),
|
|
@@ -215,7 +208,11 @@ export interface LspService {
|
|
|
215
208
|
options?: LspRequestOptions,
|
|
216
209
|
): Promise<void>;
|
|
217
210
|
diagnostics(): Promise<Record<string, Diagnostic[]>>;
|
|
218
|
-
lspDiagnosticsForFile(
|
|
211
|
+
lspDiagnosticsForFile(
|
|
212
|
+
file: string,
|
|
213
|
+
cwd: string,
|
|
214
|
+
options?: LspRequestOptions,
|
|
215
|
+
): Promise<{ text: string; errorCount: number }>;
|
|
219
216
|
shutdownAll(): Promise<void>;
|
|
220
217
|
/** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
|
|
221
218
|
attachStatus(render: StatusRenderer | undefined): void;
|
|
@@ -411,19 +408,21 @@ export function createLspService(
|
|
|
411
408
|
}
|
|
412
409
|
|
|
413
410
|
/**
|
|
414
|
-
* edit/write 用:等待文档诊断并返回该文件的 ERROR
|
|
415
|
-
*
|
|
411
|
+
* edit/write 用:等待文档诊断并返回该文件的 ERROR 报告(text 空串表示无错误)
|
|
412
|
+
* 与 ERROR 数量。内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
|
|
416
413
|
*/
|
|
417
414
|
async function lspDiagnosticsForFile(
|
|
418
415
|
file: string,
|
|
419
416
|
cwd: string,
|
|
420
417
|
options?: LspRequestOptions,
|
|
421
|
-
): Promise<string> {
|
|
422
|
-
if (options?.signal?.aborted) return "";
|
|
418
|
+
): Promise<{ text: string; errorCount: number }> {
|
|
419
|
+
if (options?.signal?.aborted) return { text: "", errorCount: 0 };
|
|
423
420
|
await touchFile(file, cwd, "document", options);
|
|
424
421
|
const all = await diagnostics();
|
|
425
422
|
const normalized = normalize(file);
|
|
426
|
-
|
|
423
|
+
const issues = all[normalized] ?? [];
|
|
424
|
+
const errorCount = issues.filter((item) => item.severity === 1).length;
|
|
425
|
+
return { text: report(normalized, issues), errorCount };
|
|
427
426
|
}
|
|
428
427
|
|
|
429
428
|
/** 终止全部服务器进程(session_shutdown 时调用)。 */
|
|
@@ -48,69 +48,6 @@ export const serverConfigSchema = Type.Object({
|
|
|
48
48
|
|
|
49
49
|
export type ServerConfig = Static<typeof serverConfigSchema>;
|
|
50
50
|
|
|
51
|
-
/** 内置默认服务器(id → 配置,可被用户配置按 id 覆盖,全量字段均可配置化表达)。 */
|
|
52
|
-
export const defaultServers: Record<string, ServerConfig> = {
|
|
53
|
-
typescript: {
|
|
54
|
-
include: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],
|
|
55
|
-
rootMarkers: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"],
|
|
56
|
-
bin: "typescript-language-server",
|
|
57
|
-
args: ["--stdio"],
|
|
58
|
-
cwd: "{root}",
|
|
59
|
-
languageIdByExtension: {
|
|
60
|
-
".ts": "typescript",
|
|
61
|
-
".tsx": "typescriptreact",
|
|
62
|
-
".js": "javascript",
|
|
63
|
-
".jsx": "javascriptreact",
|
|
64
|
-
".mjs": "javascript",
|
|
65
|
-
".cjs": "javascript",
|
|
66
|
-
".mts": "typescript",
|
|
67
|
-
".cts": "typescript",
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
pyright: {
|
|
71
|
-
include: ["**/*.py", "**/*.pyi"],
|
|
72
|
-
rootMarkers: [
|
|
73
|
-
"pyproject.toml",
|
|
74
|
-
"setup.py",
|
|
75
|
-
"setup.cfg",
|
|
76
|
-
"requirements.txt",
|
|
77
|
-
"Pipfile",
|
|
78
|
-
"pyrightconfig.json",
|
|
79
|
-
],
|
|
80
|
-
bin: "pyright-langserver",
|
|
81
|
-
args: ["--stdio"],
|
|
82
|
-
cwd: "{root}",
|
|
83
|
-
languageIdByExtension: { ".py": "python", ".pyi": "python" },
|
|
84
|
-
},
|
|
85
|
-
ruff: {
|
|
86
|
-
include: ["**/*.py", "**/*.pyi"],
|
|
87
|
-
rootMarkers: ["pyproject.toml", "ruff.toml", ".ruff.toml"],
|
|
88
|
-
bin: "ruff",
|
|
89
|
-
args: ["server"],
|
|
90
|
-
cwd: "{root}",
|
|
91
|
-
languageIdByExtension: { ".py": "python", ".pyi": "python" },
|
|
92
|
-
},
|
|
93
|
-
clangd: {
|
|
94
|
-
include: ["**/*.{c,h,cpp,hpp,cc,cxx,c++,hh,hxx,h++}"],
|
|
95
|
-
rootMarkers: ["compile_commands.json", "compile_flags.txt", ".clangd"],
|
|
96
|
-
bin: "clangd",
|
|
97
|
-
args: ["--background-index", "--clang-tidy"],
|
|
98
|
-
cwd: "{root}",
|
|
99
|
-
languageIdByExtension: {
|
|
100
|
-
".c": "c",
|
|
101
|
-
".h": "c",
|
|
102
|
-
".cpp": "cpp",
|
|
103
|
-
".hpp": "cpp",
|
|
104
|
-
".cc": "cpp",
|
|
105
|
-
".cxx": "cpp",
|
|
106
|
-
".c++": "cpp",
|
|
107
|
-
".hh": "cpp",
|
|
108
|
-
".hxx": "cpp",
|
|
109
|
-
".h++": "cpp",
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
|
|
114
51
|
/** 按 id 合并 servers record:同名 id 整体覆盖(不做逐字段 merge),其余保留;返回 undefined 表示没有任何 servers 定义。 */
|
|
115
52
|
export function mergeServerRecords(
|
|
116
53
|
...records: (Readonly<Record<string, ServerConfig>> | undefined)[]
|
|
@@ -123,21 +60,11 @@ export function mergeServerRecords(
|
|
|
123
60
|
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
124
61
|
}
|
|
125
62
|
|
|
126
|
-
/**
|
|
127
|
-
export function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
)
|
|
131
|
-
if (!user) return { ...defaults };
|
|
132
|
-
const merged = { ...defaults };
|
|
133
|
-
for (const [id, server] of Object.entries(user)) {
|
|
134
|
-
if (server.enabled === false) {
|
|
135
|
-
delete merged[id];
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
merged[id] = server;
|
|
139
|
-
}
|
|
140
|
-
return merged;
|
|
63
|
+
/** 组装启用的服务器列表:全部来自用户配置 servers(id → 配置),无内置默认。 */
|
|
64
|
+
export function createAdapters(
|
|
65
|
+
userServers?: Readonly<Record<string, ServerConfig>>,
|
|
66
|
+
): LspServerAdapter[] {
|
|
67
|
+
return Object.entries(userServers ?? {}).map(([id, config]) => new ConfigAdapter(id, config));
|
|
141
68
|
}
|
|
142
69
|
|
|
143
70
|
/** {root} / {cwd} 模板替换(bin / cwd 字段均支持)。 */
|
|
@@ -220,12 +147,3 @@ export class ConfigAdapter implements LspServerAdapter {
|
|
|
220
147
|
};
|
|
221
148
|
}
|
|
222
149
|
}
|
|
223
|
-
|
|
224
|
-
/** 组装启用的服务器列表:默认配置 + 用户 servers(id → 配置)合并。 */
|
|
225
|
-
export function createAdapters(
|
|
226
|
-
userServers?: Readonly<Record<string, ServerConfig>>,
|
|
227
|
-
): LspServerAdapter[] {
|
|
228
|
-
return Object.entries(mergeServerConfigs(defaultServers, userServers)).map(
|
|
229
|
-
([id, config]) => new ConfigAdapter(id, config),
|
|
230
|
-
);
|
|
231
|
-
}
|
package/src/lib/path.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { stat } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Expand a leading `~` to the user's home directory.
|
|
@@ -59,3 +59,14 @@ export function formatDisplayPath(cwd: string, filePath: string): string {
|
|
|
59
59
|
}
|
|
60
60
|
return filePath;
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/** subtitle 中路径的最大显示长度,超过时退化为仅文件名。 */
|
|
64
|
+
export const MAX_SUBTITLE_PATH_LENGTH = 20;
|
|
65
|
+
|
|
66
|
+
/** subtitle 用的显示路径:优先 `./…` / `~/…` / 绝对路径,结果过长时只显示文件名。 */
|
|
67
|
+
export function formatSubtitlePath(cwd: string, filePath: string, errorCount?: number): string {
|
|
68
|
+
const display = formatDisplayPath(cwd, filePath);
|
|
69
|
+
const base = display.length <= MAX_SUBTITLE_PATH_LENGTH ? display : basename(filePath);
|
|
70
|
+
if (errorCount === undefined || errorCount === 0) return base;
|
|
71
|
+
return `${base} (ⓧ ${errorCount})`;
|
|
72
|
+
}
|
package/src/opencode/files.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { Type } from "typebox";
|
|
31
31
|
|
|
32
32
|
import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
|
|
33
|
+
import { formatSubtitlePath } from "../lib/path.js";
|
|
33
34
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
34
35
|
import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
|
|
35
36
|
|
|
@@ -356,7 +357,7 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
356
357
|
|
|
357
358
|
return {
|
|
358
359
|
content: [{ type: "text", text: output }],
|
|
359
|
-
details:
|
|
360
|
+
details: { pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) } },
|
|
360
361
|
};
|
|
361
362
|
}
|
|
362
363
|
|
|
@@ -384,7 +385,10 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
384
385
|
{ type: "text", text: "Image read successfully" },
|
|
385
386
|
{ type: "image", data: base64, mimeType },
|
|
386
387
|
];
|
|
387
|
-
return {
|
|
388
|
+
return {
|
|
389
|
+
content,
|
|
390
|
+
details: { pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) } },
|
|
391
|
+
};
|
|
388
392
|
}
|
|
389
393
|
|
|
390
394
|
if (isBinaryExtension(absolutePath) || isBinaryFileBySample(await readSample(absolutePath))) {
|
|
@@ -431,7 +435,13 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
431
435
|
// 后台 warm-up 失败不影响读取
|
|
432
436
|
});
|
|
433
437
|
|
|
434
|
-
return {
|
|
438
|
+
return {
|
|
439
|
+
content,
|
|
440
|
+
details: {
|
|
441
|
+
...details,
|
|
442
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) },
|
|
443
|
+
},
|
|
444
|
+
};
|
|
435
445
|
},
|
|
436
446
|
});
|
|
437
447
|
}
|
|
@@ -482,7 +492,7 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
482
492
|
change: { oldText: oldString, newText: newString, replaceAll },
|
|
483
493
|
});
|
|
484
494
|
|
|
485
|
-
const [message, details, diagnosticText] = await withFileMutationQueue(
|
|
495
|
+
const [message, details, diagnosticText, errorCount] = await withFileMutationQueue(
|
|
486
496
|
absolutePath,
|
|
487
497
|
async () => {
|
|
488
498
|
signal?.throwIfAborted();
|
|
@@ -509,13 +519,16 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
509
519
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
510
520
|
signal?.throwIfAborted();
|
|
511
521
|
await writeFile(absolutePath, newString, "utf8");
|
|
512
|
-
const diagnosticText = await service.lspDiagnosticsForFile(
|
|
513
|
-
|
|
514
|
-
|
|
522
|
+
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
523
|
+
absolutePath,
|
|
524
|
+
ctx.cwd,
|
|
525
|
+
{ signal },
|
|
526
|
+
);
|
|
515
527
|
return [
|
|
516
528
|
"Edit applied successfully.",
|
|
517
529
|
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
518
530
|
diagnosticText,
|
|
531
|
+
errorCount,
|
|
519
532
|
] as const;
|
|
520
533
|
}
|
|
521
534
|
|
|
@@ -547,13 +560,16 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
547
560
|
const diffNew = normalizeToLF(applied.contentNew);
|
|
548
561
|
const diffResult = generateDiffString(diffOld, diffNew);
|
|
549
562
|
const patch = generateUnifiedPatch(filePath, diffOld, diffNew);
|
|
550
|
-
const diagnosticText = await service.lspDiagnosticsForFile(
|
|
551
|
-
|
|
552
|
-
|
|
563
|
+
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
564
|
+
absolutePath,
|
|
565
|
+
ctx.cwd,
|
|
566
|
+
{ signal },
|
|
567
|
+
);
|
|
553
568
|
return [
|
|
554
569
|
"Edit applied successfully.",
|
|
555
570
|
{ diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
556
571
|
diagnosticText,
|
|
572
|
+
errorCount,
|
|
557
573
|
] as const;
|
|
558
574
|
},
|
|
559
575
|
);
|
|
@@ -561,7 +577,13 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
561
577
|
const text = diagnosticText
|
|
562
578
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
563
579
|
: message;
|
|
564
|
-
return {
|
|
580
|
+
return {
|
|
581
|
+
content: [{ type: "text" as const, text }],
|
|
582
|
+
details: {
|
|
583
|
+
...details,
|
|
584
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount) },
|
|
585
|
+
},
|
|
586
|
+
};
|
|
565
587
|
},
|
|
566
588
|
});
|
|
567
589
|
}
|
|
@@ -614,7 +636,7 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
614
636
|
});
|
|
615
637
|
const dir = dirname(absolutePath);
|
|
616
638
|
|
|
617
|
-
const [message, details, diagnosticText] = await withFileMutationQueue(
|
|
639
|
+
const [message, details, diagnosticText, errorCount] = await withFileMutationQueue(
|
|
618
640
|
absolutePath,
|
|
619
641
|
async () => {
|
|
620
642
|
signal?.throwIfAborted();
|
|
@@ -639,18 +661,26 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
639
661
|
await mkdir(dir, { recursive: true });
|
|
640
662
|
signal?.throwIfAborted();
|
|
641
663
|
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
642
|
-
const diagnosticText = await service.lspDiagnosticsForFile(
|
|
643
|
-
|
|
644
|
-
|
|
664
|
+
const { text: diagnosticText, errorCount } = await service.lspDiagnosticsForFile(
|
|
665
|
+
absolutePath,
|
|
666
|
+
ctx.cwd,
|
|
667
|
+
{ signal },
|
|
668
|
+
);
|
|
645
669
|
|
|
646
|
-
return ["Wrote file successfully.",
|
|
670
|
+
return ["Wrote file successfully.", {}, diagnosticText, errorCount] as const;
|
|
647
671
|
},
|
|
648
672
|
);
|
|
649
673
|
|
|
650
674
|
const text = diagnosticText
|
|
651
675
|
? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
|
|
652
676
|
: message;
|
|
653
|
-
return {
|
|
677
|
+
return {
|
|
678
|
+
content: [{ type: "text" as const, text }],
|
|
679
|
+
details: {
|
|
680
|
+
...details,
|
|
681
|
+
pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath, errorCount) },
|
|
682
|
+
},
|
|
683
|
+
};
|
|
654
684
|
},
|
|
655
685
|
});
|
|
656
686
|
}
|