@trim21/personal-pi-extensions 0.0.299 → 0.0.300

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.
@@ -1,45 +1,47 @@
1
1
  /**
2
- * Enhanced Read Tool Extension
2
+ * Opencode File Tools —— read / edit / write 统一构建点。
3
3
  *
4
- * Aligned with opencode commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
5
- * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/read.ts
6
- * Aligned behaviours: per-line `N: ` line-number prefix, single-line 2000-char
7
- * truncation, 1-based offset (0 treated as 1), out-of-range offset error,
8
- * cut/more/end truncation messages, `localeCompare` directory sorting.
9
- * Known gaps (intentionally not implemented): PDF attachment support,
10
- * instruction (AGENTS.md) loading and LSP warm-up.
11
- * BMP sniffing is kept but `image/bmp` is NOT in SUPPORTED_IMAGE_MIMES, so a
12
- * .bmp file falls through to binary detection — matching opencode, which only
13
- * serves jpeg/png/gif/webp as attachments.
4
+ * 三个工具在同一个 registerFileTools(pi, service) 里注册,共享同一个 LSP
5
+ * service 实例(createLspService 的闭包变量),与 claude-code/files.ts 共享
6
+ * read-snapshot state 的方式一致;不再用模块级全局缓存。
14
7
  *
15
- * Overrides the built-in `read` tool with additional features inspired by
16
- * opencode's read implementation:
8
+ * 各工具行为对齐 opencode commit 999be62662(v1.2.25-1672-g999be62662,
9
+ * 2026-08-12):
10
+ * - read:每行 `N: ` 行号前缀、单行 2000 字符截断、1 起始 offset、目录
11
+ * 排序;读取后后台 LSP warm-up(fire-and-forget)。
12
+ * - edit:opencode 匹配引擎(9 个 replacer、0.65 相似度阈值、0.25 行差);
13
+ * 写后等待文档诊断,ERROR 级追加到输出。
14
+ * - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。
17
15
  *
18
- * - Directory listing: When the path is a directory, lists its entries
19
- * with "/" suffix for directories.
20
- * - "Did you mean?" suggestions: When a file is not found, searches the
21
- * parent directory for similarly-named files.
22
- * - Binary file detection: Rejects binary files by extension and content
23
- * sampling before handing them to the LLM.
24
- * - Structured output: Uses <path>, <type>, <content>/<entries> XML tags
25
- * to help the LLM parse output.
26
- * - Image support: Detects and serves images as base64 attachments.
27
- *
28
- * Install:
29
- * cp enhanced-read.ts ~/.pi/agent/extensions/
30
- *
31
- * Or for project-local:
32
- * cp enhanced-read.ts .pi/extensions/
16
+ * 匹配引擎(replacers + replace())在 edit-engine.ts,也被 lib/write-guard
17
+ * 复用。spawn-agent 子代理按工具名加载本文件(`--tools` allowlist 过滤)。
33
18
  */
34
19
 
35
20
  import { constants } from "node:fs";
36
- import { access, open, readdir, readFile, stat } from "node:fs/promises";
21
+ import { access, mkdir, open, readdir, readFile, stat, writeFile } from "node:fs/promises";
37
22
  import { basename, dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
38
23
 
39
24
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
40
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import {
26
+ type ExtensionAPI,
27
+ generateDiffString,
28
+ generateUnifiedPatch,
29
+ withFileMutationQueue,
30
+ } from "@earendil-works/pi-coding-agent";
41
31
  import { Type } from "typebox";
42
32
 
33
+ import { createLspService, initLsp, type LspService } from "../lib/lsp/lsp.js";
34
+ import { guardWriteAccess } from "../lib/write-guard.js";
35
+ import {
36
+ detectLineEnding,
37
+ normalizeToLF,
38
+ replace,
39
+ restoreLineEndings,
40
+ stripBom,
41
+ } from "./edit-engine.js";
42
+
43
+ // ── read 工具 ────────────────────────────────────────────────────────────────
44
+
43
45
  const DEFAULT_MAX_LINES = 2000;
44
46
  const DEFAULT_MAX_BYTES = 50 * 1024;
45
47
  const MAX_LINE_LENGTH = 2000;
@@ -283,7 +285,7 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
283
285
  return results;
284
286
  }
285
287
 
286
- export default function opencodeRead(pi: ExtensionAPI) {
288
+ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
287
289
  pi.registerTool({
288
290
  name: "read",
289
291
  label: "read",
@@ -444,7 +446,250 @@ export default function opencodeRead(pi: ExtensionAPI) {
444
446
 
445
447
  content = [{ type: "text", text: outputText }];
446
448
 
449
+ // opencode: LSP warm-up 是后台任务,失败不影响读取
450
+ void service.touchFile(absolutePath, ctx.cwd).catch(() => {
451
+ // 后台 warm-up 失败不影响读取
452
+ });
453
+
447
454
  return { content, details };
448
455
  },
449
456
  });
450
457
  }
458
+
459
+ // ── edit 工具 ────────────────────────────────────────────────────────────────
460
+
461
+ const editSchema = Type.Object({
462
+ filePath: Type.String({ description: "The path to the file to modify (relative or absolute)" }),
463
+ oldString: Type.String({ description: "The text to replace" }),
464
+ newString: Type.String({
465
+ description: "The text to replace it with (must be different from oldString)",
466
+ }),
467
+ replaceAll: Type.Optional(
468
+ Type.Boolean({ description: "Replace all occurrences of oldString (default false)" }),
469
+ ),
470
+ });
471
+
472
+ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
473
+ pi.registerTool({
474
+ name: "edit",
475
+ label: "edit",
476
+ description:
477
+ "Performs exact string replacements in an existing file.\n" +
478
+ "The edit will FAIL if oldString is not unique in the file.\n" +
479
+ " * Either provide a larger string with more surrounding context to make it unique, or use replaceAll to change every instance of oldString.",
480
+ promptSnippet:
481
+ "Make targeted string replacements in files using exact oldString/newString matching",
482
+ promptGuidelines: [
483
+ "Prefer editing existing files. Never write new files unless explicitly required.",
484
+ "Use the edit tool for targeted changes. Use oldString/newString with exact matching content.",
485
+ "Keep oldString as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
486
+ "The edit will FAIL if oldString is not found or is found multiple times. Provide more context to make it unique or use replaceAll.",
487
+ "Use replaceAll for renaming variables or replacing all instances of a string.",
488
+ ],
489
+ parameters: editSchema,
490
+
491
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
492
+ const filePath = params.filePath;
493
+ const oldString = params.oldString;
494
+ const newString = params.newString;
495
+ const replaceAll = params.replaceAll ?? false;
496
+
497
+ const absolutePath = isAbsolute(filePath) ? filePath : resolvePath(ctx.cwd, filePath);
498
+
499
+ await guardWriteAccess(ctx, {
500
+ toolName: "edit",
501
+ absolutePath,
502
+ change: { oldText: oldString, newText: newString, replaceAll },
503
+ });
504
+
505
+ const throwIfAborted = (): void => {
506
+ if (signal?.aborted) throw new Error("Operation aborted");
507
+ };
508
+
509
+ const [message, details] = await withFileMutationQueue(absolutePath, async () => {
510
+ throwIfAborted();
511
+
512
+ // opencode: 前置校验,先于空 oldString 分支
513
+ if (oldString === newString) {
514
+ throw new Error("No changes to apply: oldString and newString are identical.");
515
+ }
516
+
517
+ // opencode: 空 oldString + 文件不存在 → 创建新文件;文件存在 → 报错
518
+ if (oldString === "") {
519
+ let exists = true;
520
+ try {
521
+ await access(absolutePath, constants.F_OK);
522
+ } catch {
523
+ exists = false;
524
+ }
525
+ throwIfAborted();
526
+ if (exists) {
527
+ throw new Error(
528
+ "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
529
+ );
530
+ }
531
+ // opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
532
+ await mkdir(dirname(absolutePath), { recursive: true });
533
+ throwIfAborted();
534
+ await writeFile(absolutePath, newString, "utf8");
535
+ throwIfAborted();
536
+ return [
537
+ "Edit applied successfully.",
538
+ { diff: "", patch: "", firstChangedLine: 0 },
539
+ ] as const;
540
+ }
541
+
542
+ try {
543
+ await access(absolutePath, constants.R_OK | constants.W_OK);
544
+ } catch (error: unknown) {
545
+ throwIfAborted();
546
+ const msg =
547
+ error instanceof Error && "code" in error && typeof error.code === "string"
548
+ ? `Error code: ${error.code}`
549
+ : String(error);
550
+ throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
551
+ }
552
+ throwIfAborted();
553
+
554
+ const buffer = await readFile(absolutePath);
555
+ const rawContent = buffer.toString("utf8");
556
+ throwIfAborted();
557
+
558
+ // Strip BOM then normalize line endings to LF.
559
+ // The opencode replacers split on \n and expect only LF.
560
+ const { bom, text: content } = stripBom(rawContent);
561
+ const originalEnding = detectLineEnding(content);
562
+ const normalizedContent = normalizeToLF(content);
563
+
564
+ const newContent = replace(normalizedContent, oldString, newString, replaceAll);
565
+ throwIfAborted();
566
+
567
+ const finalContent = bom + restoreLineEndings(newContent, originalEnding);
568
+ await writeFile(absolutePath, finalContent, "utf8");
569
+ throwIfAborted();
570
+
571
+ const diffResult = generateDiffString(normalizedContent, newContent);
572
+ const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
573
+ return [
574
+ "Edit applied successfully.",
575
+ { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
576
+ ] as const;
577
+ });
578
+
579
+ throwIfAborted();
580
+ // opencode: 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
581
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
582
+ const text = diagnosticText
583
+ ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
584
+ : message;
585
+ return { content: [{ type: "text" as const, text }], details };
586
+ },
587
+ });
588
+ }
589
+
590
+ // ── write 工具 ───────────────────────────────────────────────────────────────
591
+
592
+ /**
593
+ * opencode: desiredBom = source.bom || next.bom —— 优先保留原文件 BOM,
594
+ * 否则用新内容自带的 BOM。
595
+ * @param existing - 旧文件前几个字节(undefined 表示文件不存在)
596
+ * @param content - 要写入的完整内容
597
+ */
598
+ export function resolveBom(
599
+ existing: Buffer | undefined,
600
+ content: string,
601
+ ): { bom: string; text: string } {
602
+ const sourceBom =
603
+ existing !== undefined &&
604
+ existing.length >= 3 &&
605
+ existing[0] === 0xef &&
606
+ existing[1] === 0xbb &&
607
+ existing[2] === 0xbf
608
+ ? "\uFEFF"
609
+ : "";
610
+ const { bom: nextBom, text } = stripBom(content);
611
+ return { bom: sourceBom || nextBom, text };
612
+ }
613
+
614
+ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
615
+ pi.registerTool({
616
+ name: "write",
617
+ label: "write",
618
+ description:
619
+ "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
620
+ promptSnippet: "Create or overwrite files",
621
+ promptGuidelines: ["Use write only for new files or complete rewrites."],
622
+ parameters: Type.Object({
623
+ filePath: Type.String({
624
+ description: "The absolute path to the file to write (must be absolute, not relative)",
625
+ }),
626
+ content: Type.String({ description: "The content to write to the file" }),
627
+ }),
628
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
629
+ const { filePath: rawPath, content } = params;
630
+ const absolutePath = resolvePath(ctx.cwd, rawPath);
631
+ await guardWriteAccess(ctx, {
632
+ toolName: "write",
633
+ absolutePath,
634
+ change: { oldText: "", newText: content },
635
+ });
636
+ const dir = dirname(absolutePath);
637
+
638
+ const throwIfAborted = () => {
639
+ if (signal?.aborted) throw new Error("Operation aborted");
640
+ };
641
+
642
+ const [message, details] = await withFileMutationQueue(absolutePath, async () => {
643
+ throwIfAborted();
644
+
645
+ // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
646
+ // 否则用新内容自带的 BOM
647
+ let existing: Buffer | undefined;
648
+ try {
649
+ const fh = await open(absolutePath, "r");
650
+ try {
651
+ existing = Buffer.alloc(3);
652
+ const { bytesRead } = await fh.read(existing, 0, 3, 0);
653
+ if (bytesRead < 3) existing = undefined;
654
+ } finally {
655
+ await fh.close();
656
+ }
657
+ } catch {
658
+ // 文件不存在:无旧 BOM
659
+ }
660
+ throwIfAborted();
661
+ const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
662
+
663
+ await mkdir(dir, { recursive: true });
664
+ throwIfAborted();
665
+ await writeFile(absolutePath, desiredBom + nextText, "utf8");
666
+ throwIfAborted();
667
+
668
+ return ["Wrote file successfully.", undefined] as const;
669
+ });
670
+
671
+ throwIfAborted();
672
+ // opencode: 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
673
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
674
+ const text = diagnosticText
675
+ ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
676
+ : message;
677
+ return { content: [{ type: "text" as const, text }], details };
678
+ },
679
+ });
680
+ }
681
+
682
+ // ── 入口 ─────────────────────────────────────────────────────────────────────
683
+
684
+ export function registerFileTools(pi: ExtensionAPI, service: LspService): void {
685
+ registerReadTool(pi, service);
686
+ registerEditTool(pi, service);
687
+ registerWriteTool(pi, service);
688
+ }
689
+
690
+ /** 独立入口:创建 LSP service(闭包共享给三个工具)并注册。 */
691
+ export default function opencodeFileTools(pi: ExtensionAPI): void {
692
+ const service = createLspService();
693
+ initLsp(pi, service);
694
+ registerFileTools(pi, service);
695
+ }
@@ -1,28 +1,25 @@
1
1
  /**
2
2
  * opencode —— 统一注册 opencode 风格工具扩展。
3
3
  *
4
- * 聚合 read / edit / write / todo / question / bash 六个工具,一次加载全部注册;
5
- * 各工具的公开 API(匹配引擎、纯函数等)也从这里重新导出,方便
6
- * 测试与其他模块(如 lib/write-guard)引用。
4
+ * 聚合 files(read / edit / write 统一构建,共享 LSP service)、todo /
5
+ * question / bash,一次加载全部注册;各工具的公开 API(匹配引擎、纯函数等)
6
+ * 也从这里重新导出,方便测试与其他模块(如 lib/write-guard)引用。
7
7
  *
8
8
  * Usage:
9
9
  * pi -e ./opencode/index.ts
10
10
  *
11
- * spawn-agent 的子代理按声明工具单独加载 `opencode/{read,edit,write}.ts`,
12
- * 避免把未声明的工具注入子代理工具集。
11
+ * spawn-agent 的子代理按声明工具加载 `opencode/files.ts`,`--tools`
12
+ * allowlist 只暴露声明的子集(与 claude-code 三件套映射同一文件一致)。
13
13
  */
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
 
17
17
  import opencodeBash from "./bash.js";
18
- import opencodeEdit from "./edit.js";
18
+ import opencodeFileTools from "./files.js";
19
19
  import opencodeQuestion from "./question.js";
20
- import opencodeRead from "./read.js";
21
20
  import opencodeTodo from "./todo.js";
22
- import opencodeWrite from "./write.js";
23
21
 
24
22
  export { default as opencodeBash } from "./bash.js";
25
- export { default as opencodeEdit } from "./edit.js";
26
23
  export {
27
24
  detectLineEnding,
28
25
  normalizeForEdit,
@@ -31,15 +28,17 @@ export {
31
28
  restoreLineEndings,
32
29
  stripBom,
33
30
  } from "./edit-engine.js";
31
+ export {
32
+ default as opencodeFileTools,
33
+ resolveBom,
34
+ truncateHead,
35
+ type TruncationResult,
36
+ } from "./files.js";
34
37
  export { default as opencodeQuestion } from "./question.js";
35
- export { default as opencodeRead, truncateHead, type TruncationResult } from "./read.js";
36
38
  export { default as opencodeTodo } from "./todo.js";
37
- export { default as opencodeWrite, resolveBom } from "./write.js";
38
39
 
39
40
  export default function opencode(pi: ExtensionAPI) {
40
- opencodeRead(pi);
41
- opencodeEdit(pi);
42
- opencodeWrite(pi);
41
+ opencodeFileTools(pi);
43
42
  opencodeTodo(pi);
44
43
  opencodeQuestion(pi);
45
44
  opencodeBash(pi);
@@ -71,12 +71,14 @@ const MAX_PROGRESS_CHARS_PER_LINE = 21;
71
71
  * files, so a subagent can enable exactly the tools it declares — e.g. `Grep`
72
72
  * without `Glob`. The stateful file tools (`Read`/`Edit`/`Write`) share one
73
73
  * implementation file (they share a read-snapshot state); the `--tools`
74
- * allowlist still exposes only the declared subset.
74
+ * allowlist still exposes only the declared subset. The opencode file tools
75
+ * (read/edit/write) likewise share opencode/files.ts (they share the LSP
76
+ * service instance).
75
77
  */
76
78
  const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
77
- read: "opencode/read.ts",
78
- edit: "opencode/edit.ts",
79
- write: "opencode/write.ts",
79
+ read: "opencode/files.ts",
80
+ edit: "opencode/files.ts",
81
+ write: "opencode/files.ts",
80
82
  bash: "opencode/bash.ts",
81
83
  Grep: "claude-code/grep.ts",
82
84
  Glob: "claude-code/glob.ts",
@@ -1,169 +0,0 @@
1
- /**
2
- * Opencode Edit Extension — Replaces the built-in edit tool with opencode's
3
- * schema and matching engine.
4
- *
5
- * Aligned with opencode commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
6
- * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/edit.ts
7
- * The matching engine (replacers + replace()) is byte-for-byte aligned with
8
- * opencode (9 replacers, 0.65 similarity threshold, 0.25 line-delta, identical
9
- * error messages), and empty-oldString file creation is implemented.
10
- * Known gaps (intentionally not implemented): LSP diagnostics in the result,
11
- * formatter run.
12
- *
13
- * The matching engine (replacers + replace()) lives in opencode/edit-engine.ts
14
- * and is also used by lib/write-guard for the diff preview.
15
- *
16
- * Usage:
17
- * pi -e ./opencode-edit.ts
18
- */
19
-
20
- import { constants } from "node:fs";
21
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
22
- import { dirname, isAbsolute, resolve } from "node:path";
23
-
24
- import {
25
- type ExtensionAPI,
26
- generateDiffString,
27
- generateUnifiedPatch,
28
- withFileMutationQueue,
29
- } from "@earendil-works/pi-coding-agent";
30
- import { Type } from "typebox";
31
-
32
- import { guardWriteAccess } from "../lib/write-guard.js";
33
- import {
34
- detectLineEnding,
35
- normalizeToLF,
36
- replace,
37
- restoreLineEndings,
38
- stripBom,
39
- } from "./edit-engine.js";
40
-
41
- // ── schema ────────────────────────────────────────────────────────────────────
42
-
43
- const editSchema = Type.Object({
44
- filePath: Type.String({ description: "The path to the file to modify (relative or absolute)" }),
45
- oldString: Type.String({ description: "The text to replace" }),
46
- newString: Type.String({
47
- description: "The text to replace it with (must be different from oldString)",
48
- }),
49
- replaceAll: Type.Optional(
50
- Type.Boolean({ description: "Replace all occurrences of oldString (default false)" }),
51
- ),
52
- });
53
-
54
- // ── extension ─────────────────────────────────────────────────────────────────
55
-
56
- export default function opencodeEdit(pi: ExtensionAPI) {
57
- pi.registerTool({
58
- name: "edit",
59
- label: "edit",
60
- description:
61
- "Performs exact string replacements in an existing file.\n" +
62
- "The edit will FAIL if oldString is not unique in the file.\n" +
63
- " * Either provide a larger string with more surrounding context to make it unique, or use replaceAll to change every instance of oldString.",
64
- promptSnippet:
65
- "Make targeted string replacements in files using exact oldString/newString matching",
66
- promptGuidelines: [
67
- "Prefer editing existing files. Never write new files unless explicitly required.",
68
- "Use the edit tool for targeted changes. Use oldString/newString with exact matching content.",
69
- "Keep oldString as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
70
- "The edit will FAIL if oldString is not found or is found multiple times. Provide more context to make it unique or use replaceAll.",
71
- "Use replaceAll for renaming variables or replacing all instances of a string.",
72
- ],
73
- parameters: editSchema,
74
-
75
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
76
- const filePath = params.filePath;
77
- const oldString = params.oldString;
78
- const newString = params.newString;
79
- const replaceAll = params.replaceAll ?? false;
80
-
81
- const absolutePath = isAbsolute(filePath) ? filePath : resolve(ctx.cwd, filePath);
82
-
83
- await guardWriteAccess(ctx, {
84
- toolName: "edit",
85
- absolutePath,
86
- change: { oldText: oldString, newText: newString, replaceAll },
87
- });
88
-
89
- const throwIfAborted = (): void => {
90
- if (signal?.aborted) throw new Error("Operation aborted");
91
- };
92
-
93
- return withFileMutationQueue(absolutePath, async () => {
94
- throwIfAborted();
95
-
96
- // opencode: 前置校验,先于空 oldString 分支
97
- if (oldString === newString) {
98
- throw new Error("No changes to apply: oldString and newString are identical.");
99
- }
100
-
101
- // opencode: 空 oldString + 文件不存在 → 创建新文件;文件存在 → 报错
102
- if (oldString === "") {
103
- let exists = true;
104
- try {
105
- await access(absolutePath, constants.F_OK);
106
- } catch {
107
- exists = false;
108
- }
109
- throwIfAborted();
110
- if (exists) {
111
- throw new Error(
112
- "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
113
- );
114
- }
115
- // opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
116
- await mkdir(dirname(absolutePath), { recursive: true });
117
- throwIfAborted();
118
- await writeFile(absolutePath, newString, "utf8");
119
- throwIfAborted();
120
- return {
121
- content: [{ type: "text" as const, text: "Edit applied successfully." }],
122
- details: { diff: "", patch: "", firstChangedLine: 0 },
123
- };
124
- }
125
-
126
- try {
127
- await access(absolutePath, constants.R_OK | constants.W_OK);
128
- } catch (error: unknown) {
129
- throwIfAborted();
130
- const msg =
131
- error instanceof Error && "code" in error && typeof error.code === "string"
132
- ? `Error code: ${error.code}`
133
- : String(error);
134
- throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
135
- }
136
- throwIfAborted();
137
-
138
- const buffer = await readFile(absolutePath);
139
- const rawContent = buffer.toString("utf8");
140
- throwIfAborted();
141
-
142
- // Strip BOM then normalize line endings to LF.
143
- // The opencode replacers split on \n and expect only LF.
144
- const { bom, text: content } = stripBom(rawContent);
145
- const originalEnding = detectLineEnding(content);
146
- const normalizedContent = normalizeToLF(content);
147
-
148
- const newContent = replace(normalizedContent, oldString, newString, replaceAll);
149
- throwIfAborted();
150
-
151
- const finalContent = bom + restoreLineEndings(newContent, originalEnding);
152
- await writeFile(absolutePath, finalContent, "utf8");
153
- throwIfAborted();
154
-
155
- const diffResult = generateDiffString(normalizedContent, newContent);
156
- const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
157
- return {
158
- content: [
159
- {
160
- type: "text" as const,
161
- text: "Edit applied successfully.",
162
- },
163
- ],
164
- details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
165
- };
166
- });
167
- },
168
- });
169
- }