opencode-gbk-tools 0.1.10 → 0.1.11

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 CHANGED
@@ -67,6 +67,35 @@ npx opencode-gbk-tools uninstall
67
67
  明确 GBK 文件:gbk_read / gbk_write / gbk_edit / gbk_search
68
68
  ```
69
69
 
70
+ 编辑动作建议:
71
+
72
+ ```text
73
+ 插入到标签前后:用 mode=insertAfter / insertBefore + anchor/content
74
+ 精确替换现有文本:用 oldString/newString
75
+ 文件末尾追加:用 text_write/gbk_write + append=true
76
+ ```
77
+
78
+ ### text_edit — 通用文本编辑
79
+
80
+ 插入模式示例:
81
+
82
+ ```text
83
+ text_edit(filePath="文件路径", mode="insertAfter", anchor="[@SkipCheck_Source]", content="\n你好")
84
+ text_edit(filePath="文件路径", mode="insertBefore", anchor="[@SkipCheck_Source]", content="你好\n")
85
+ text_edit(filePath="文件路径", mode="insertAfter", anchor="[@标签]", content="\n你好", occurrence=2, ifExists="skip")
86
+ ```
87
+
88
+ 替换模式示例:
89
+
90
+ ```text
91
+ text_edit(filePath="文件路径", oldString="原文内容", newString="新内容")
92
+ text_edit(filePath="文件路径", oldString="原文", newString="新文", startLine=100, endLine=200)
93
+ ```
94
+
95
+ - `mode` 默认是 `replace`
96
+ - 插入模式下不需要 `oldString`
97
+ - `ifExists` 默认 `skip`,可避免重复插入
98
+
70
99
  ### gbk_read — 读取文件
71
100
 
72
101
  ```
@@ -92,9 +121,12 @@ gbk_search(filePath="文件路径", pattern="[@标签名]", contextLines=5)
92
121
  ```
93
122
  gbk_edit(filePath="文件路径", oldString="原文内容", newString="新内容")
94
123
  gbk_edit(filePath="文件路径", oldString="原文", newString="新文", startLine=100, endLine=200)
124
+ gbk_edit(filePath="文件路径", mode="insertAfter", anchor="[@SkipCheck_Source]", content="\n你好")
95
125
  ```
96
126
 
97
- **重要:`oldString` 必须是文件的原始内容,不能包含 `gbk_read` 输出的行号前缀。**
127
+ **推荐:如果你的意图是“在标签后插入一行”,优先使用 `mode="insertAfter"` + `anchor/content`,不要再强依赖 `oldString`。**
128
+
129
+ **只有在精确替换已有文本时,`oldString` 才是推荐方案。并且 `oldString` 必须是文件的原始内容,不能包含 `gbk_read` 输出的行号前缀。**
98
130
 
99
131
  错误示范(包含行号前缀,会失败):
100
132
  ```
@@ -153,12 +185,16 @@ A:全局安装目录为 `C:\Users\你的用户名\.config\opencode\tools\`
153
185
  **Q:gbk_edit 提示"未找到需要替换的文本"?**
154
186
  A:检查 `oldString` 是否包含了行号前缀(如 `"3787: "`),去掉行号前缀后重试。若要在文件末尾追加内容,请使用 `gbk_write [append=true]`。
155
187
 
188
+ **Q:我只是想在某个标签后插入一行,还要不要传 `oldString`?**
189
+ A:不要。现在优先用 `mode="insertAfter"` 或 `mode="insertBefore"`,并传 `anchor/content`。
190
+
156
191
  ---
157
192
 
158
193
  ## 版本历史
159
194
 
160
195
  | 版本 | 说明 |
161
196
  |------|------|
197
+ | 0.1.11 | `text_edit` / `gbk_edit` 新增 `insertAfter` / `insertBefore`,插入内容不再依赖 `oldString`;README 与全局提示同步改为“插入优先用 anchor/content” |
162
198
  | 0.1.10 | 新增 `text_read` / `text_write` / `text_edit`,自动识别并保持原编码、BOM 与换行风格;同时通过 plugin 规则让所有 agents 默认优先使用 `text_*` |
163
199
  | 0.1.9 | `gbk_edit` 修复精确匹配路径写入 CRLF 文件时换行风格变 mixed 的 bug |
164
200
  | 0.1.8 | `gbk_write` 新增 `append=true` 追加模式 |
@@ -16305,6 +16305,82 @@ async function assertPathAllowed(filePath, context, allowExternal = false) {
16305
16305
 
16306
16306
  // src/lib/gbk-file.ts
16307
16307
  var STREAMING_FILE_SIZE_THRESHOLD_BYTES = 1024 * 1024;
16308
+ function assertStringArgument(value, name) {
16309
+ if (typeof value !== "string") {
16310
+ throw createGbkError("GBK_INVALID_ARGUMENT", `${name} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`);
16311
+ }
16312
+ }
16313
+ function assertReplaceArguments(input) {
16314
+ assertStringArgument(input.oldString, "oldString");
16315
+ assertStringArgument(input.newString, "newString");
16316
+ }
16317
+ function assertInsertArguments(input) {
16318
+ assertStringArgument(input.anchor, "anchor");
16319
+ assertStringArgument(input.content, "content");
16320
+ if (input.content.length === 0) {
16321
+ throw createGbkError("GBK_INVALID_ARGUMENT", "content \u4E0D\u80FD\u4E3A\u7A7A");
16322
+ }
16323
+ if (input.replaceAll !== void 0) {
16324
+ throw createGbkError("GBK_INVALID_ARGUMENT", "\u63D2\u5165\u6A21\u5F0F\u4E0D\u652F\u6301 replaceAll");
16325
+ }
16326
+ if (input.startLine !== void 0 || input.endLine !== void 0 || input.startAnchor !== void 0 || input.endAnchor !== void 0) {
16327
+ throw createGbkError("GBK_INVALID_ARGUMENT", "\u63D2\u5165\u6A21\u5F0F\u4E0D\u652F\u6301 startLine/endLine/startAnchor/endAnchor");
16328
+ }
16329
+ }
16330
+ function findOccurrenceIndex(text, token, occurrence) {
16331
+ assertStringArgument(text, "text");
16332
+ assertStringArgument(token, "anchor");
16333
+ assertPositiveInteger(occurrence, "occurrence");
16334
+ if (token.length === 0) {
16335
+ throw createGbkError("GBK_INVALID_ARGUMENT", "anchor \u4E0D\u80FD\u4E3A\u7A7A");
16336
+ }
16337
+ let count = 0;
16338
+ let searchFrom = 0;
16339
+ while (true) {
16340
+ const index = text.indexOf(token, searchFrom);
16341
+ if (index === -1) {
16342
+ break;
16343
+ }
16344
+ count += 1;
16345
+ if (count === occurrence) {
16346
+ return { index, total: countOccurrences(text, token) };
16347
+ }
16348
+ searchFrom = index + token.length;
16349
+ }
16350
+ if (count === 0) {
16351
+ throw createGbkError("GBK_NO_MATCH", `\u672A\u627E\u5230\u951A\u70B9: ${token}`);
16352
+ }
16353
+ throw createGbkError("GBK_NO_MATCH", `\u951A\u70B9 ${token} \u53EA\u627E\u5230 ${count} \u5904\uFF0C\u65E0\u6CD5\u4F7F\u7528\u7B2C ${occurrence} \u5904`);
16354
+ }
16355
+ function insertByAnchor(text, mode, anchor, content, occurrence, ifExists, newlineStyle) {
16356
+ const alignedContent = newlineStyle === "crlf" ? content.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n") : newlineStyle === "lf" ? content.replace(/\r\n/g, "\n") : content;
16357
+ const located = findOccurrenceIndex(text, anchor, occurrence);
16358
+ const insertionPoint = mode === "insertAfter" ? located.index + anchor.length : located.index;
16359
+ const alreadyExists = mode === "insertAfter" ? text.slice(insertionPoint, insertionPoint + alignedContent.length) === alignedContent : text.slice(Math.max(0, insertionPoint - alignedContent.length), insertionPoint) === alignedContent;
16360
+ if (alreadyExists) {
16361
+ if (ifExists === "error") {
16362
+ throw createGbkError("GBK_INVALID_ARGUMENT", "\u63D2\u5165\u4F4D\u7F6E\u5DF2\u5B58\u5728\u76F8\u540C\u5185\u5BB9");
16363
+ }
16364
+ if (ifExists === "skip") {
16365
+ return {
16366
+ outputText: text,
16367
+ inserted: false,
16368
+ skipped: true,
16369
+ anchorMatches: located.total,
16370
+ occurrence,
16371
+ anchor
16372
+ };
16373
+ }
16374
+ }
16375
+ return {
16376
+ outputText: `${text.slice(0, insertionPoint)}${alignedContent}${text.slice(insertionPoint)}`,
16377
+ inserted: true,
16378
+ skipped: false,
16379
+ anchorMatches: located.total,
16380
+ occurrence,
16381
+ anchor
16382
+ };
16383
+ }
16308
16384
  function assertEncodingSupported(encoding) {
16309
16385
  if (encoding !== "gbk" && encoding !== "gb18030") {
16310
16386
  throw createGbkError("GBK_INVALID_ENCODING", `\u4E0D\u652F\u6301\u7684\u7F16\u7801: ${encoding}`);
@@ -16423,9 +16499,11 @@ function resolveEditScope(text, input) {
16423
16499
  };
16424
16500
  }
16425
16501
  function normalizeNewlines(text) {
16502
+ assertStringArgument(text, "text");
16426
16503
  return text.replace(/\r\n/g, "\n");
16427
16504
  }
16428
16505
  function trimRightSpaces(text) {
16506
+ assertStringArgument(text, "text");
16429
16507
  return text.replace(/[ \t]+$/g, "");
16430
16508
  }
16431
16509
  function splitNormalizedLines(text) {
@@ -16521,6 +16599,8 @@ function tryLooseBlockReplace(content, oldString, newString) {
16521
16599
  return null;
16522
16600
  }
16523
16601
  function countOccurrences(text, target) {
16602
+ assertStringArgument(text, "text");
16603
+ assertStringArgument(target, "oldString");
16524
16604
  if (target.length === 0) {
16525
16605
  throw createGbkError("GBK_EMPTY_OLD_STRING", "oldString \u4E0D\u80FD\u4E3A\u7A7A");
16526
16606
  }
@@ -16677,11 +16757,56 @@ async function replaceLargeGbkFileText(input) {
16677
16757
  }
16678
16758
  }
16679
16759
  async function replaceGbkFileText(input) {
16760
+ const mode = input.mode ?? "replace";
16680
16761
  const normalizedInput = {
16681
16762
  ...input,
16682
16763
  startLine: normalizeOptionalPositiveInteger(input.startLine, "startLine"),
16683
16764
  endLine: normalizeOptionalPositiveInteger(input.endLine, "endLine")
16684
16765
  };
16766
+ if (mode === "insertAfter" || mode === "insertBefore") {
16767
+ assertInsertArguments(input);
16768
+ const resolved2 = await resolveReadableGbkFile(normalizedInput);
16769
+ const current2 = await readWholeGbkTextFile(resolved2);
16770
+ const occurrence = normalizeOptionalPositiveInteger(input.occurrence, "occurrence") ?? 1;
16771
+ const insertResult = insertByAnchor(
16772
+ current2.content,
16773
+ mode,
16774
+ input.anchor,
16775
+ input.content,
16776
+ occurrence,
16777
+ input.ifExists ?? "skip",
16778
+ detectNewlineStyle(current2.content)
16779
+ );
16780
+ if (insertResult.skipped) {
16781
+ return {
16782
+ mode,
16783
+ filePath: current2.filePath,
16784
+ encoding: current2.encoding,
16785
+ anchor: insertResult.anchor,
16786
+ occurrence: insertResult.occurrence,
16787
+ anchorMatches: insertResult.anchorMatches,
16788
+ inserted: false,
16789
+ skipped: true,
16790
+ bytesRead: current2.bytesRead,
16791
+ bytesWritten: 0
16792
+ };
16793
+ }
16794
+ const buffer2 = import_iconv_lite.default.encode(insertResult.outputText, current2.encoding);
16795
+ await fs2.writeFile(current2.filePath, buffer2);
16796
+ return {
16797
+ mode,
16798
+ filePath: current2.filePath,
16799
+ encoding: current2.encoding,
16800
+ anchor: insertResult.anchor,
16801
+ occurrence: insertResult.occurrence,
16802
+ anchorMatches: insertResult.anchorMatches,
16803
+ inserted: true,
16804
+ skipped: false,
16805
+ bytesRead: current2.bytesRead,
16806
+ bytesWritten: buffer2.byteLength
16807
+ };
16808
+ }
16809
+ assertReplaceArguments(input);
16685
16810
  if (input.oldString.length === 0) {
16686
16811
  throw createGbkError("GBK_EMPTY_OLD_STRING", "oldString \u4E0D\u80FD\u4E3A\u7A7A");
16687
16812
  }
@@ -16698,14 +16823,15 @@ async function replaceGbkFileText(input) {
16698
16823
  }
16699
16824
  const current = await readWholeGbkTextFile(resolved);
16700
16825
  const scope = resolveEditScope(current.content, normalizedInput);
16701
- const occurrencesBefore = countOccurrences(scope.selectedText, normalizedInput.oldString);
16826
+ const occurrencesBefore = countOccurrences(scope.selectedText, input.oldString);
16702
16827
  if (!replaceAll && occurrencesBefore === 0) {
16703
- const loose = tryLooseBlockReplace(scope.selectedText, normalizedInput.oldString, normalizedInput.newString);
16828
+ const loose = tryLooseBlockReplace(scope.selectedText, input.oldString, input.newString);
16704
16829
  if (loose !== null) {
16705
16830
  const outputText2 = `${current.content.slice(0, scope.rangeStart)}${loose.content}${current.content.slice(scope.rangeEnd)}`;
16706
16831
  const buffer2 = import_iconv_lite.default.encode(outputText2, current.encoding);
16707
16832
  await fs2.writeFile(current.filePath, buffer2);
16708
16833
  return {
16834
+ mode: "replace",
16709
16835
  filePath: current.filePath,
16710
16836
  encoding: current.encoding,
16711
16837
  replacements: 1,
@@ -16717,20 +16843,21 @@ async function replaceGbkFileText(input) {
16717
16843
  }
16718
16844
  if (replaceAll) {
16719
16845
  if (occurrencesBefore === 0) {
16720
- throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, normalizedInput.oldString));
16846
+ throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, input.oldString));
16721
16847
  }
16722
16848
  } else if (occurrencesBefore === 0) {
16723
- throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, normalizedInput.oldString));
16849
+ throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, input.oldString));
16724
16850
  } else if (occurrencesBefore > 1) {
16725
- throw createGbkError("GBK_MULTIPLE_MATCHES", `\u627E\u5230\u591A\u4E2A\u5339\u914D\u9879: ${normalizedInput.oldString}`);
16851
+ throw createGbkError("GBK_MULTIPLE_MATCHES", `\u627E\u5230\u591A\u4E2A\u5339\u914D\u9879: ${input.oldString}`);
16726
16852
  }
16727
16853
  const fileNewlineStyle = detectNewlineStyle(current.content);
16728
- const alignedNewString = fileNewlineStyle === "crlf" ? normalizedInput.newString.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n") : fileNewlineStyle === "lf" ? normalizedInput.newString.replace(/\r\n/g, "\n") : normalizedInput.newString;
16729
- const replaced = replaceAll ? scope.selectedText.split(normalizedInput.oldString).join(alignedNewString) : scope.selectedText.replace(normalizedInput.oldString, alignedNewString);
16854
+ const alignedNewString = fileNewlineStyle === "crlf" ? input.newString.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n") : fileNewlineStyle === "lf" ? input.newString.replace(/\r\n/g, "\n") : input.newString;
16855
+ const replaced = replaceAll ? scope.selectedText.split(input.oldString).join(alignedNewString) : scope.selectedText.replace(input.oldString, alignedNewString);
16730
16856
  const outputText = `${current.content.slice(0, scope.rangeStart)}${replaced}${current.content.slice(scope.rangeEnd)}`;
16731
16857
  const buffer = import_iconv_lite.default.encode(outputText, current.encoding);
16732
16858
  await fs2.writeFile(current.filePath, buffer);
16733
16859
  return {
16860
+ mode: "replace",
16734
16861
  filePath: current.filePath,
16735
16862
  encoding: current.encoding,
16736
16863
  replacements: replaceAll ? occurrencesBefore : 1,
@@ -16764,11 +16891,20 @@ Recommended workflow for large files (when gbk_read returned truncated=true):
16764
16891
  3. gbk_edit(oldString=<content without prefixes>, newString=<new content>)
16765
16892
 
16766
16893
  For large files, use 'startLine'/'endLine' or 'startAnchor'/'endAnchor' to narrow the search scope
16767
- and avoid false matches. Scoped edits also improve performance on very large files.`,
16894
+ and avoid false matches. Scoped edits also improve performance on very large files.
16895
+
16896
+ Insert mode:
16897
+ - Use mode=insertAfter or mode=insertBefore with anchor/content
16898
+ - This is recommended when the intent is "insert after label" instead of exact replacement.`,
16768
16899
  args: {
16769
16900
  filePath: tool.schema.string().describe("Target file path"),
16770
- oldString: tool.schema.string().describe("Exact text to replace \u2014 raw file content only, no 'N: ' line number prefixes from gbk_read output"),
16771
- newString: tool.schema.string().describe("Replacement text \u2014 raw content only, no line number prefixes"),
16901
+ mode: tool.schema.enum(["replace", "insertAfter", "insertBefore"]).optional().describe("Edit mode, default replace"),
16902
+ oldString: tool.schema.string().optional().describe("Exact text to replace in replace mode"),
16903
+ newString: tool.schema.string().optional().describe("Replacement text in replace mode"),
16904
+ anchor: tool.schema.string().optional().describe("Anchor text used by insertAfter/insertBefore"),
16905
+ content: tool.schema.string().optional().describe("Inserted content used by insertAfter/insertBefore"),
16906
+ occurrence: tool.schema.number().int().positive().optional().describe("1-based anchor occurrence for insert mode"),
16907
+ ifExists: tool.schema.enum(["allow", "skip", "error"]).optional().describe("What to do when inserted content already exists at target position"),
16772
16908
  replaceAll: tool.schema.boolean().optional().describe("Replace all occurrences (default: false, requires unique match)"),
16773
16909
  startLine: tool.schema.union([tool.schema.number().int().positive(), tool.schema.literal(-1)]).optional().describe("Restrict edit scope to 1-based start line (inclusive)"),
16774
16910
  endLine: tool.schema.union([tool.schema.number().int().positive(), tool.schema.literal(-1)]).optional().describe("Restrict edit scope to 1-based end line (inclusive)"),
@@ -16779,13 +16915,21 @@ and avoid false matches. Scoped edits also improve performance on very large fil
16779
16915
  },
16780
16916
  async execute(args, context) {
16781
16917
  const result = await replaceGbkFileText({ ...args, context });
16918
+ const isReplace = !("mode" in result) || result.mode === "replace";
16919
+ const title = isReplace ? `GBK \u7F16\u8F91 ${result.encoding.toUpperCase()} x${result.replacements}` : result.inserted ? `GBK \u63D2\u5165 ${result.encoding.toUpperCase()} #${result.occurrence}` : `GBK \u8DF3\u8FC7 ${result.encoding.toUpperCase()} #${result.occurrence}`;
16782
16920
  context.metadata({
16783
- title: `GBK \u7F16\u8F91 ${result.encoding.toUpperCase()} x${result.replacements}`,
16921
+ title,
16784
16922
  metadata: {
16785
16923
  filePath: result.filePath,
16786
16924
  encoding: result.encoding,
16787
- replacements: result.replacements,
16788
- occurrencesBefore: result.occurrencesBefore,
16925
+ mode: isReplace ? "replace" : result.mode,
16926
+ replacements: isReplace ? result.replacements : void 0,
16927
+ occurrencesBefore: isReplace ? result.occurrencesBefore : void 0,
16928
+ anchor: isReplace ? void 0 : result.anchor,
16929
+ occurrence: isReplace ? void 0 : result.occurrence,
16930
+ anchorMatches: isReplace ? void 0 : result.anchorMatches,
16931
+ inserted: isReplace ? void 0 : result.inserted,
16932
+ skipped: isReplace ? void 0 : result.skipped,
16789
16933
  bytesRead: result.bytesRead,
16790
16934
  bytesWritten: result.bytesWritten
16791
16935
  }