@movemama/opencode-legacy 0.1.0 → 1.0.0

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  OpenCode legacy 文本处理插件,面向 `GB2312` 文本、脚本型 `.txt` 编辑和 legacy 规则路由场景。
4
4
 
5
+ 当前主链路已经改为纯 JS 编码实现,不再依赖系统外部 `iconv.exe`。
6
+
5
7
  ## 安装
6
8
 
7
9
  在 OpenCode 配置中加入:
@@ -17,6 +19,8 @@ OpenCode legacy 文本处理插件,面向 `GB2312` 文本、脚本型 `.txt`
17
19
 
18
20
  首次未安装的用户会在 OpenCode 启动时自动安装当前 `latest` 版本。后续更新可由用户自行处理。
19
21
 
22
+ 如果刚安装后没有生效,重新启动一次 OpenCode 即可让 npm 插件完成加载。
23
+
20
24
  ## 当前提供的工具
21
25
 
22
26
  - `read`
@@ -26,6 +30,7 @@ OpenCode legacy 文本处理插件,面向 `GB2312` 文本、脚本型 `.txt`
26
30
  - `legacy_read`
27
31
  - `legacy_write`
28
32
  - `legacy_edit`
33
+ - `legacy_status`
29
34
 
30
35
  其中同名工具 `read`、`write`、`edit` 会覆盖 OpenCode 内置工具,实现 legacy 路由。
31
36
 
@@ -39,6 +44,85 @@ OpenCode legacy 文本处理插件,面向 `GB2312` 文本、脚本型 `.txt`
39
44
  2. `<worktree>/legacy-rules.json`
40
45
  3. 包内 `legacy-rules.json`
41
46
 
47
+ 规则字段现在除编码外,还支持:
48
+
49
+ - `profile`
50
+ - `editStrategy`
51
+ - `fallbackMode`
52
+ - `scriptMarkers`
53
+
54
+ 这让插件可以按场景决定更合适的编辑策略,而不只是按编码读写。
55
+
56
+ ## 自动策略层
57
+
58
+ 当前 `edit` 已经接入自动策略层。
59
+
60
+ - 普通文本默认走 `exact-first`
61
+ - `classic-tag` 文本可根据 `profile` 和 `scriptMarkers` 判断为更偏 block / line-normalized 的策略
62
+ - `rich-ui-dsl` 文本会优先识别 `<Text|...>` / `<Button|...>` 组件,并在 exact 失败后尝试 widget 字段级更新
63
+ - 失败时会返回当前策略、格式族和 fallback 提示,便于继续排障
64
+
65
+ ## 支持的格式族
66
+
67
+ - `classic-tag`
68
+ - 例如 `[@main]`、`<领取/@领取>`、`<关闭/@exit>`
69
+ - 当前已支持:菜单行宽松匹配、标签块替换、跳转目标校验
70
+ - `rich-ui-dsl`
71
+ - 例如 `<Text|x=...|text=...>`、`<Button|...|link=@...>`
72
+ - 当前已支持:多行 widget 定位、字段级更新、children 字段保留
73
+
74
+ 当前这两类是第一批重点支持对象。
75
+
76
+ - `general-code`
77
+ - 例如 C++ / C# / 强语法代码文本
78
+ - 当前仅做安全识别与保守回退,不做激进结构改写
79
+
80
+ ## 诊断工具
81
+
82
+ 可以使用 `legacy_status` 查看:
83
+
84
+ - 插件版本
85
+ - 规则来源
86
+ - 命中规则
87
+ - 格式族
88
+ - profile
89
+ - 建议策略
90
+ - fallback 模式
91
+ - 是否被判定为脚本型文件
92
+
93
+ ## 如何确认已加载成功
94
+
95
+ 最简单的方法是读取一个命中规则的 `.txt` 文件。
96
+
97
+ 如果插件已接管成功,返回内容中会出现:
98
+
99
+ ```text
100
+ --- Legacy 读取回执 ---
101
+ ```
102
+
103
+ 这说明 `read` 工具已经由插件覆盖并进入 legacy 规则链路。
104
+
105
+ 也可以直接调用 `legacy_status` 检查当前文件会命中哪条规则、建议使用哪种策略。
106
+
107
+ 如果检测为 `general-code`,当前插件会优先保持保守,不会像 rich-ui / classic-tag 那样做特化结构修改;这部分为未来语法感知层预留空间。
108
+
109
+ 如果检测为 `classic-tag`,当前插件已能在 exact 失败后继续尝试:
110
+
111
+ - 菜单行 token 级匹配
112
+ - 标签块局部替换
113
+ - 跳转标签存在性检查
114
+
115
+ 如果检测为 `rich-ui-dsl`,当前插件已能在 exact 失败后继续尝试:
116
+
117
+ - widget 字段级更新
118
+ - 多行 widget 定位
119
+ - 保留 `children={...}` 容器字段
120
+
121
+ ## 兼容说明
122
+
123
+ - `txt-gb2312-tool.mjs` 目前仅保留为兼容遗留文件,不再是主链路依赖
124
+ - 当前主运行时入口以 `.js/.mjs` 文件为准
125
+
42
126
  ## 发布
43
127
 
44
128
  ```bash
package/index.js CHANGED
@@ -2,6 +2,7 @@ import readTool from './tools/read.js'
2
2
  import writeTool from './tools/write.js'
3
3
  import editTool from './tools/edit.js'
4
4
  import scriptEditTool from './tools/script-edit.js'
5
+ import legacyStatusTool from './tools/legacy-status.js'
5
6
  import { edit as legacyEditTool, read as legacyReadTool, write as legacyWriteTool } from './tools/legacy.js'
6
7
 
7
8
  export const OpenCodeLegacyPlugin = async () => ({
@@ -13,6 +14,7 @@ export const OpenCodeLegacyPlugin = async () => ({
13
14
  legacy_read: legacyReadTool,
14
15
  legacy_write: legacyWriteTool,
15
16
  legacy_edit: legacyEditTool,
17
+ legacy_status: legacyStatusTool,
16
18
  },
17
19
  })
18
20
 
package/legacy-rules.json CHANGED
@@ -6,14 +6,33 @@
6
6
  "encoding": "gb2312",
7
7
  "strict": true,
8
8
  "tool": "txt-gb2312",
9
- "priority": 10
9
+ "priority": 10,
10
+ "profile": "txt-gb2312-safe",
11
+ "editStrategy": "exact-first",
12
+ "fallbackMode": "legacy-safe-replace",
13
+ "scriptMarkers": ["#ACT", "@main", "@全体补偿页面"]
14
+ },
15
+ {
16
+ "glob": "**/*.txt",
17
+ "encoding": "gb2312",
18
+ "strict": true,
19
+ "tool": "txt-gb2312",
20
+ "priority": 20,
21
+ "profile": "rich-ui-dsl",
22
+ "editStrategy": "widget-field",
23
+ "fallbackMode": "widget-field-update",
24
+ "scriptMarkers": ["<Text|", "<Button|", "<Img|", "<Layout|"]
10
25
  },
11
26
  {
12
27
  "glob": "**/*.{ini,cfg,dat}",
13
28
  "encoding": "gbk",
14
29
  "strict": false,
15
30
  "tool": "legacy-text",
16
- "priority": 1
31
+ "priority": 1,
32
+ "profile": "config-gbk",
33
+ "editStrategy": "exact-first",
34
+ "fallbackMode": "legacy-safe-replace",
35
+ "scriptMarkers": []
17
36
  }
18
37
  ]
19
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@movemama/opencode-legacy",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "OpenCode legacy text processing plugin for GB2312 and script-safe editing workflows.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -20,7 +20,8 @@
20
20
  "plugin",
21
21
  "legacy",
22
22
  "gb2312",
23
- "iconv"
23
+ "gbk",
24
+ "script-edit"
24
25
  ],
25
26
  "license": "MIT",
26
27
  "publishConfig": {
package/tools/edit.js CHANGED
@@ -1,27 +1,13 @@
1
1
  import { tool } from '@opencode-ai/plugin'
2
- import { existsSync, readFileSync } from 'node:fs'
2
+ import { existsSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import readTool from './read.js'
5
5
  import writeTool from './write.js'
6
6
  import { applyLegacyEdit } from './legacy-edit-core.mjs'
7
- import { createDefaultLegacyRules, matchLegacyRule } from './legacy-router.mjs'
8
- import { getBundledLegacyRulesPath } from './opencode-paths.mjs'
9
-
10
- function loadLegacyRules(worktree) {
11
- const candidates = [
12
- path.join(worktree, '.opencode', 'legacy-rules.json'),
13
- path.join(worktree, 'legacy-rules.json'),
14
- getBundledLegacyRulesPath(),
15
- ]
16
-
17
- for (const filePath of candidates) {
18
- if (existsSync(filePath)) {
19
- return JSON.parse(readFileSync(filePath, 'utf8')).rules ?? createDefaultLegacyRules()
20
- }
21
- }
22
-
23
- return createDefaultLegacyRules()
24
- }
7
+ import { matchLegacyRule } from './legacy-router.mjs'
8
+ import { loadLegacyRules } from './legacy-rules-loader.js'
9
+ import { chooseEditStrategy } from './legacy-strategy.js'
10
+ import { applyFallbackEditChain } from './legacy-widget-edit.js'
25
11
 
26
12
  function resolvePath(filePath, worktree) {
27
13
  return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
@@ -42,15 +28,28 @@ export default tool({
42
28
  }
43
29
 
44
30
  const rules = loadLegacyRules(context.worktree)
45
- matchLegacyRule(filePath, rules)
46
-
47
31
  const content = await readTool.execute({ filePath, raw: true }, context)
32
+ const matchedRule = matchLegacyRule(filePath, rules)
33
+ const strategy = chooseEditStrategy({ filePath, content, matchedRule })
48
34
  const result = applyLegacyEdit(content, args.oldString, args.newString, Boolean(args.replaceAll))
49
35
 
50
- if (!result.changed) {
51
- throw new Error(result.error)
36
+ if (result.changed) {
37
+ return writeTool.execute({ filePath, content: result.content }, context)
38
+ }
39
+
40
+ const fallbackResult = applyFallbackEditChain({
41
+ content,
42
+ oldString: args.oldString,
43
+ newString: args.newString,
44
+ detectedFamily: strategy.detectedFamily,
45
+ })
46
+
47
+ if (fallbackResult.changed) {
48
+ return writeTool.execute({ filePath, content: fallbackResult.content }, context)
52
49
  }
53
50
 
54
- return writeTool.execute({ filePath, content: result.content }, context)
51
+ throw new Error(
52
+ `${result.error} | strategy=${strategy.strategy} | detected=${strategy.detectedFamily} | fallback=${strategy.fallbackMode}`,
53
+ )
55
54
  },
56
55
  })
@@ -0,0 +1,21 @@
1
+ export function detectLegacyFormat(content) {
2
+ const normalized = content.replace(/\r\n/g, '\n')
3
+
4
+ if (/<(?:Text|Button|Img|Layout|COUNTDOWN)\|/i.test(normalized)) {
5
+ return { family: 'rich-ui-dsl' }
6
+ }
7
+
8
+ if (
9
+ /(using\s+[A-Z][\w.]*;|namespace\s+\w+|#include\s+[<"]|class\s+\w+\s*[{:]|public\s+(class|struct|interface))/m.test(
10
+ normalized,
11
+ )
12
+ ) {
13
+ return { family: 'general-code' }
14
+ }
15
+
16
+ if (/^\[@.+\]$/m.test(normalized) || /<[^<>]+\/@[^<>]+>/.test(normalized)) {
17
+ return { family: 'classic-tag' }
18
+ }
19
+
20
+ return { family: 'plain-text' }
21
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { createDefaultLegacyRules } from './legacy-router.mjs'
5
+ import { getBundledLegacyRulesPath } from './opencode-paths.mjs'
6
+
7
+ export function loadLegacyRules(worktree) {
8
+ return loadLegacyRulesWithMeta(worktree).rules
9
+ }
10
+
11
+ export function loadLegacyRulesWithMeta(worktree) {
12
+ const candidates = [
13
+ { filePath: path.join(worktree, '.opencode', 'legacy-rules.json'), source: 'project:.opencode' },
14
+ { filePath: path.join(worktree, 'legacy-rules.json'), source: 'project:root' },
15
+ { filePath: getBundledLegacyRulesPath(), source: 'bundled:default' },
16
+ ]
17
+
18
+ for (const candidate of candidates) {
19
+ const { filePath, source } = candidate
20
+ if (!existsSync(filePath)) {
21
+ continue
22
+ }
23
+
24
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'))
25
+ return {
26
+ rules: parsed.rules ?? createDefaultLegacyRules(),
27
+ source,
28
+ }
29
+ }
30
+
31
+ return {
32
+ rules: createDefaultLegacyRules(),
33
+ source: 'generated:fallback',
34
+ }
35
+ }
@@ -0,0 +1,51 @@
1
+ import { tool } from '@opencode-ai/plugin'
2
+ import { existsSync } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import readTool from './read.js'
6
+ import { matchLegacyRule } from './legacy-router.mjs'
7
+ import { loadLegacyRulesWithMeta } from './legacy-rules-loader.js'
8
+ import { chooseEditStrategy } from './legacy-strategy.js'
9
+ import pkg from '../package.json' with { type: 'json' }
10
+
11
+ function resolvePath(filePath, worktree) {
12
+ return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
13
+ }
14
+
15
+ export default tool({
16
+ description: '输出当前 legacy 规则来源、命中规则与策略建议',
17
+ args: {
18
+ filePath: tool.schema.string().describe('目标文件路径'),
19
+ },
20
+ async execute(args, context) {
21
+ const filePath = resolvePath(args.filePath, context.worktree)
22
+ const { rules, source } = loadLegacyRulesWithMeta(context.worktree)
23
+ const matchedRule = matchLegacyRule(filePath, rules)
24
+
25
+ let content = ''
26
+ if (existsSync(filePath)) {
27
+ content = await readTool.execute({ filePath, raw: true }, context)
28
+ }
29
+
30
+ const strategy = chooseEditStrategy({
31
+ filePath,
32
+ content,
33
+ matchedRule,
34
+ })
35
+
36
+ return [
37
+ `legacy 插件版本:${pkg.version}`,
38
+ `文件路径:${filePath}`,
39
+ `规则来源:${source}`,
40
+ `命中规则:${matchedRule ? matchedRule.glob : '未命中'}`,
41
+ `格式族:${strategy.detectedFamily}`,
42
+ `profile:${strategy.profile}`,
43
+ `建议策略:${strategy.strategy}`,
44
+ `fallback:${strategy.fallbackMode}`,
45
+ `脚本文件:${strategy.isScriptLike ? '是' : '否'}`,
46
+ strategy.detectedFamily === 'classic-tag' ? 'classic-tag 能力:菜单行宽松匹配、标签块替换、跳转目标校验。' : '',
47
+ strategy.detectedFamily === 'rich-ui-dsl' ? 'rich-ui-dsl 能力:widget 字段级编辑、多行 widget 定位、children 字段保留。' : '',
48
+ strategy.detectedFamily === 'general-code' ? '提示:general-code 当前仅提供保守兼容,后续建议接入语法感知层。' : '提示:当前格式已接入分层编辑策略。',
49
+ ].join('\n')
50
+ },
51
+ })
@@ -0,0 +1,22 @@
1
+ import { detectLegacyFormat } from './legacy-format-detector.js'
2
+
3
+ export function chooseEditStrategy({ filePath, content, matchedRule }) {
4
+ const detected = detectLegacyFormat(content)
5
+ const markers = matchedRule?.scriptMarkers ?? []
6
+ const hasMarker = markers.some((marker) => content.includes(marker))
7
+ const looksLikeScript = hasMarker || /(^|\n)@\w+/.test(content) || content.includes('#ACT')
8
+ const profile = matchedRule?.profile ?? (detected.family === 'rich-ui-dsl' ? 'rich-ui-dsl' : detected.family === 'classic-tag' ? 'classic-tag' : 'default')
9
+ const strategy = matchedRule?.editStrategy
10
+ ?? (detected.family === 'rich-ui-dsl' ? 'widget-field'
11
+ : looksLikeScript ? 'block-first'
12
+ : 'exact-first')
13
+
14
+ return {
15
+ filePath,
16
+ profile,
17
+ detectedFamily: detected.family,
18
+ strategy,
19
+ fallbackMode: matchedRule?.fallbackMode ?? (looksLikeScript ? 'suggest-script-edit' : 'legacy-safe-replace'),
20
+ isScriptLike: looksLikeScript,
21
+ }
22
+ }
@@ -0,0 +1,227 @@
1
+ function splitWidgetLine(line) {
2
+ if (!line.startsWith('<') || !line.endsWith('>') || !line.includes('|')) {
3
+ return null
4
+ }
5
+
6
+ const body = line.slice(1, -1)
7
+ const parts = body.split('|')
8
+ const kind = parts.shift()
9
+ return { kind, parts }
10
+ }
11
+
12
+ function partsToFields(parts) {
13
+ const fields = {}
14
+ for (const part of parts) {
15
+ const separatorIndex = part.indexOf('=')
16
+ if (separatorIndex === -1) {
17
+ continue
18
+ }
19
+ const key = part.slice(0, separatorIndex)
20
+ const value = part.slice(separatorIndex + 1)
21
+ fields[key] = value
22
+ }
23
+ return fields
24
+ }
25
+
26
+ function splitClassicMenuTokens(line) {
27
+ const tokens = line.match(/<[^<>]+\/@[^<>]+>/g)
28
+ return tokens ?? []
29
+ }
30
+
31
+ function findClassicTagBlocks(content) {
32
+ const lines = content.split('\n')
33
+ const blocks = []
34
+
35
+ for (let index = 0; index < lines.length; index += 1) {
36
+ if (!/^\[@.+\]$/.test(lines[index].trim())) {
37
+ continue
38
+ }
39
+
40
+ let end = lines.length - 1
41
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
42
+ if (/^\[@.+\]$/.test(lines[cursor].trim())) {
43
+ end = cursor - 1
44
+ break
45
+ }
46
+ }
47
+
48
+ blocks.push({ start: index, end, label: lines[index].trim() })
49
+ }
50
+
51
+ return { lines, blocks }
52
+ }
53
+
54
+ export function validateClassicTagLinks(content) {
55
+ const labels = new Set((content.match(/^\[@.+\]$/gm) ?? []).map((item) => item.trim()))
56
+ const links = content.match(/@[^>\s\]}|]+/g) ?? []
57
+ const missing = [...new Set(links.filter((link) => link !== '@exit' && !labels.has(`[${link}]`)))]
58
+ return {
59
+ valid: missing.length === 0,
60
+ missing,
61
+ }
62
+ }
63
+
64
+ export function updateWidgetField(line, updates) {
65
+ const parsed = splitWidgetLine(line)
66
+ if (!parsed) {
67
+ throw new Error('不是可识别的 widget 行')
68
+ }
69
+
70
+ const nextParts = parsed.parts.map((part) => {
71
+ const separatorIndex = part.indexOf('=')
72
+ if (separatorIndex === -1) {
73
+ return part
74
+ }
75
+
76
+ const key = part.slice(0, separatorIndex)
77
+ if (!(key in updates)) {
78
+ return part
79
+ }
80
+
81
+ return `${key}=${updates[key]}`
82
+ })
83
+
84
+ return `<${parsed.kind}|${nextParts.join('|')}>`
85
+ }
86
+
87
+ function updateWidgetBySignature(content, oldWidgetLine, newText) {
88
+ const target = splitWidgetLine(oldWidgetLine)
89
+ if (!target) {
90
+ return null
91
+ }
92
+
93
+ const targetFields = partsToFields(target.parts)
94
+ const lines = content.split('\n')
95
+ const hitIndex = lines.findIndex((line) => {
96
+ const parsed = splitWidgetLine(line)
97
+ if (!parsed || parsed.kind !== target.kind) {
98
+ return false
99
+ }
100
+
101
+ const fields = partsToFields(parsed.parts)
102
+ return ['x', 'y', 'text', 'link'].every((key) => !targetFields[key] || fields[key] === targetFields[key])
103
+ })
104
+
105
+ if (hitIndex === -1) {
106
+ return null
107
+ }
108
+
109
+ lines[hitIndex] = updateWidgetField(lines[hitIndex], { text: newText })
110
+ return lines.join('\n')
111
+ }
112
+
113
+ function updateClassicMenuLine(content, oldString, newString) {
114
+ const oldTokens = splitClassicMenuTokens(oldString)
115
+ const newTokens = splitClassicMenuTokens(newString)
116
+ if (oldTokens.length === 0 || oldTokens.length !== newTokens.length) {
117
+ return null
118
+ }
119
+
120
+ const lines = content.split('\n')
121
+ const hitIndex = lines.findIndex((line) => {
122
+ const lineTokens = splitClassicMenuTokens(line)
123
+ if (lineTokens.length !== oldTokens.length) {
124
+ return false
125
+ }
126
+ return oldTokens.every((token, index) => lineTokens[index] === token)
127
+ || oldTokens.every((token) => lineTokens.includes(token))
128
+ })
129
+
130
+ if (hitIndex === -1) {
131
+ return null
132
+ }
133
+
134
+ const lineTokens = splitClassicMenuTokens(lines[hitIndex])
135
+ let nextLine = lines[hitIndex]
136
+ for (let index = 0; index < oldTokens.length; index += 1) {
137
+ if (lineTokens.includes(oldTokens[index])) {
138
+ nextLine = nextLine.replace(oldTokens[index], newTokens[index])
139
+ }
140
+ }
141
+ lines[hitIndex] = nextLine
142
+ return lines.join('\n')
143
+ }
144
+
145
+ function updateClassicTagBlock(content, oldString, newString) {
146
+ const oldLabelMatch = oldString.match(/^\[@.+\]$/m)
147
+ if (!oldLabelMatch) {
148
+ return null
149
+ }
150
+
151
+ const { lines, blocks } = findClassicTagBlocks(content)
152
+ const target = blocks.find((block) => block.label === oldLabelMatch[0].trim())
153
+ if (!target) {
154
+ return null
155
+ }
156
+
157
+ const nextLines = [...lines]
158
+ const replacementStart = target.start + 1
159
+ const replacementEnd = target.end
160
+ nextLines.splice(replacementStart, replacementEnd - replacementStart + 1, newString)
161
+ return nextLines.join('\n')
162
+ }
163
+
164
+ export function applyFallbackEditChain({ content, oldString, newString, detectedFamily }) {
165
+ if (content.includes(oldString)) {
166
+ return {
167
+ changed: true,
168
+ content: content.replace(oldString, newString),
169
+ strategy: 'exact-first',
170
+ }
171
+ }
172
+
173
+ if (detectedFamily === 'rich-ui-dsl') {
174
+ const updated = updateWidgetBySignature(content, oldString, newString)
175
+ if (updated) {
176
+ return {
177
+ changed: true,
178
+ content: updated,
179
+ strategy: 'widget-field',
180
+ }
181
+ }
182
+
183
+ if (/<Text\|/.test(content) && content.includes(`text=${oldString}`)) {
184
+ return {
185
+ changed: true,
186
+ content: updateWidgetField(content, { text: newString }),
187
+ strategy: 'widget-field',
188
+ }
189
+ }
190
+ }
191
+
192
+ if (detectedFamily === 'classic-tag') {
193
+ const blockUpdated = updateClassicTagBlock(content, oldString, newString)
194
+ if (blockUpdated) {
195
+ return {
196
+ changed: true,
197
+ content: blockUpdated,
198
+ strategy: 'classic-tag-block',
199
+ }
200
+ }
201
+
202
+ const updated = updateClassicMenuLine(content, oldString, newString)
203
+ if (updated) {
204
+ return {
205
+ changed: true,
206
+ content: updated,
207
+ strategy: 'classic-tag-line-normalized',
208
+ }
209
+ }
210
+
211
+ const normalizedContent = content.replace(/[ \t]+/g, ' ')
212
+ const normalizedOld = oldString.replace(/[ \t]+/g, ' ')
213
+ if (normalizedContent.includes(normalizedOld)) {
214
+ return {
215
+ changed: true,
216
+ content: normalizedContent.replace(normalizedOld, newString),
217
+ strategy: 'classic-tag-line-normalized',
218
+ }
219
+ }
220
+ }
221
+
222
+ return {
223
+ changed: false,
224
+ content,
225
+ strategy: 'no-match',
226
+ }
227
+ }
package/tools/read.js CHANGED
@@ -1,26 +1,9 @@
1
1
  import { tool } from '@opencode-ai/plugin'
2
2
  import { readFile } from 'node:fs/promises'
3
- import { existsSync, readFileSync } from 'node:fs'
4
3
  import path from 'node:path'
5
- import { createDefaultLegacyRules, matchLegacyRule } from './legacy-router.mjs'
6
- import { getBundledLegacyRulesPath } from './opencode-paths.mjs'
4
+ import { matchLegacyRule } from './legacy-router.mjs'
7
5
  import { decodeLegacyBuffer } from './legacy-codec.js'
8
-
9
- function loadLegacyRules(worktree) {
10
- const candidates = [
11
- path.join(worktree, '.opencode', 'legacy-rules.json'),
12
- path.join(worktree, 'legacy-rules.json'),
13
- getBundledLegacyRulesPath(),
14
- ]
15
-
16
- for (const filePath of candidates) {
17
- if (existsSync(filePath)) {
18
- return JSON.parse(readFileSync(filePath, 'utf8')).rules ?? createDefaultLegacyRules()
19
- }
20
- }
21
-
22
- return createDefaultLegacyRules()
23
- }
6
+ import { loadLegacyRules } from './legacy-rules-loader.js'
24
7
 
25
8
  function resolvePath(filePath, worktree) {
26
9
  return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
package/tools/write.js CHANGED
@@ -1,26 +1,9 @@
1
1
  import { tool } from '@opencode-ai/plugin'
2
2
  import { writeFile } from 'node:fs/promises'
3
- import { existsSync, readFileSync } from 'node:fs'
4
3
  import path from 'node:path'
5
- import { createDefaultLegacyRules, matchLegacyRule } from './legacy-router.mjs'
6
- import { getBundledLegacyRulesPath } from './opencode-paths.mjs'
4
+ import { matchLegacyRule } from './legacy-router.mjs'
7
5
  import { encodeLegacyText } from './legacy-codec.js'
8
-
9
- function loadLegacyRules(worktree) {
10
- const candidates = [
11
- path.join(worktree, '.opencode', 'legacy-rules.json'),
12
- path.join(worktree, 'legacy-rules.json'),
13
- getBundledLegacyRulesPath(),
14
- ]
15
-
16
- for (const filePath of candidates) {
17
- if (existsSync(filePath)) {
18
- return JSON.parse(readFileSync(filePath, 'utf8')).rules ?? createDefaultLegacyRules()
19
- }
20
- }
21
-
22
- return createDefaultLegacyRules()
23
- }
6
+ import { loadLegacyRules } from './legacy-rules-loader.js'
24
7
 
25
8
  function resolvePath(filePath, worktree) {
26
9
  return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
package/tools/edit.ts DELETED
@@ -1,64 +0,0 @@
1
- import { tool } from '@opencode-ai/plugin'
2
- import { existsSync, readFileSync } from 'node:fs'
3
- import path from 'node:path'
4
- import readTool from './read.ts'
5
- import writeTool from './write.ts'
6
- import { applyLegacyEdit } from './legacy-edit-core.mjs'
7
- import { createDefaultLegacyRules, matchLegacyRule } from './legacy-router.mjs'
8
-
9
- function loadLegacyRules(worktree: string) {
10
- const projectConfigPath = path.join(worktree, '.opencode', 'legacy-rules.json')
11
- const globalConfigPath = 'C:/Users/Administrator/.config/opencode/legacy-rules.json'
12
-
13
- if (existsSync(projectConfigPath)) {
14
- return JSON.parse(readFileSync(projectConfigPath, 'utf8')).rules ?? createDefaultLegacyRules()
15
- }
16
-
17
- if (existsSync(globalConfigPath)) {
18
- return JSON.parse(readFileSync(globalConfigPath, 'utf8')).rules ?? createDefaultLegacyRules()
19
- }
20
-
21
- return createDefaultLegacyRules()
22
- }
23
-
24
- function resolvePath(filePath: string, worktree: string) {
25
- return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
26
- }
27
-
28
- export default tool({
29
- description: '编辑文件,命中 legacy 规则时自动按对应编码处理',
30
- args: {
31
- filePath: tool.schema.string().describe('文件路径'),
32
- oldString: tool.schema.string().describe('要替换的旧文本'),
33
- newString: tool.schema.string().describe('要写入的新文本'),
34
- replaceAll: tool.schema.boolean().optional().describe('是否全部替换'),
35
- },
36
- async execute(args, context) {
37
- const filePath = resolvePath(args.filePath, context.worktree)
38
- if (!existsSync(filePath)) {
39
- throw new Error(`文件不存在: ${filePath}`)
40
- }
41
-
42
- const rules = loadLegacyRules(context.worktree)
43
- const matched = matchLegacyRule(filePath, rules)
44
- if (matched?.tool === 'txt-gb2312') {
45
- const content = await readTool.execute({ filePath, raw: true }, context)
46
- const result = applyLegacyEdit(content, args.oldString, args.newString, Boolean(args.replaceAll))
47
-
48
- if (!result.changed) {
49
- throw new Error(result.error)
50
- }
51
-
52
- return writeTool.execute({ filePath, content: result.content }, context)
53
- }
54
-
55
- const content = await readTool.execute({ filePath, raw: true }, context)
56
- const result = applyLegacyEdit(content, args.oldString, args.newString, Boolean(args.replaceAll))
57
-
58
- if (!result.changed) {
59
- throw new Error(result.error)
60
- }
61
-
62
- return writeTool.execute({ filePath, content: result.content }, context)
63
- },
64
- })
package/tools/legacy.ts DELETED
@@ -1,230 +0,0 @@
1
- import { tool } from '@opencode-ai/plugin'
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
- import { existsSync } from 'node:fs'
4
- import path from 'node:path'
5
- import { tmpdir } from 'node:os'
6
- import { spawnSync } from 'node:child_process'
7
- import { applyLegacyEdit } from './legacy-edit-core.mjs'
8
- import { getGlobalToolPath, getKnownIconvCandidates } from './opencode-paths.mjs'
9
-
10
- let cachedNodeCommand = null
11
- let cachedIconvPath = null
12
-
13
- function normalizeWindowsPath(filePath) {
14
- return filePath.replace(/\\/g, '/')
15
- }
16
-
17
- function getDerivedIconvCandidates() {
18
- const pathValue = process.env.Path || process.env.PATH || ''
19
- const parts = pathValue.split(';').map((item) => item.trim()).filter(Boolean)
20
- const derived = []
21
-
22
- for (const part of parts) {
23
- const normalized = normalizeWindowsPath(part)
24
- if (normalized.toLowerCase().endsWith('/git/cmd')) {
25
- derived.push(normalized.replace(/\/cmd$/i, '/usr/bin/iconv.exe'))
26
- }
27
- }
28
-
29
- return derived
30
- }
31
-
32
- function resolveTxtToolPath(context) {
33
- const projectToolPath = path.join(context.worktree, 'tools', 'txt-gb2312-tool.mjs')
34
- if (existsSync(projectToolPath)) {
35
- return projectToolPath
36
- }
37
-
38
- return getGlobalToolPath('txt-gb2312-tool.mjs')
39
- }
40
-
41
- function resolveNodeCommand() {
42
- if (cachedNodeCommand) {
43
- return cachedNodeCommand
44
- }
45
-
46
- const candidates = [process.env.OPENCODE_NODE_PATH, process.execPath, 'node'].filter(Boolean)
47
-
48
- for (const candidate of candidates) {
49
- const probe = spawnSync(candidate, ['-e', 'process.stdout.write(process.release?.name === "node" ? "node-ok" : "not-node")'], {
50
- encoding: 'utf8',
51
- })
52
- const output = `${probe.stdout || ''}${probe.stderr || ''}`.trim()
53
- if (probe.status === 0 && output === 'node-ok') {
54
- cachedNodeCommand = candidate
55
- return cachedNodeCommand
56
- }
57
- }
58
-
59
- throw new Error('未找到可用的 Node.js 命令,无法执行 txt-gb2312-tool.mjs')
60
- }
61
-
62
- function resolveIconvPath() {
63
- if (cachedIconvPath) {
64
- return cachedIconvPath
65
- }
66
-
67
- const candidates = [
68
- 'iconv',
69
- process.env.OPENCODE_ICONV_PATH,
70
- process.env.ICONV_PATH,
71
- ...getKnownIconvCandidates(),
72
- ...getDerivedIconvCandidates(),
73
- ].filter(Boolean)
74
-
75
- for (const candidate of candidates) {
76
- const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8' })
77
- if (probe.status === 0) {
78
- cachedIconvPath = candidate
79
- return cachedIconvPath
80
- }
81
- }
82
-
83
- throw new Error('iconv 不可用,无法处理 legacy 文件')
84
- }
85
-
86
- function runNode(scriptArgs, cwd, extraEnv = {}) {
87
- const nodeCommand = resolveNodeCommand()
88
- const result = spawnSync(nodeCommand, scriptArgs, {
89
- cwd,
90
- encoding: 'utf8',
91
- env: { ...process.env, ...extraEnv },
92
- })
93
-
94
- if (result.status !== 0) {
95
- const stderr = (result.stderr || '').trim()
96
- const stdout = (result.stdout || '').trim()
97
- throw new Error(
98
- [
99
- 'legacy 工具执行失败',
100
- `nodeCommand=${nodeCommand}`,
101
- `cwd=${cwd}`,
102
- `status=${result.status}`,
103
- `stderr=${stderr || '<empty>'}`,
104
- `stdout=${stdout || '<empty>'}`,
105
- ].join(' | '),
106
- )
107
- }
108
-
109
- return result.stdout
110
- }
111
-
112
- function runIconv(iconvPath, args) {
113
- const result = spawnSync(iconvPath, args, { encoding: 'buffer' })
114
- if (result.status !== 0) {
115
- throw new Error(result.stderr?.toString('utf8') || 'iconv 执行失败')
116
- }
117
- return result.stdout
118
- }
119
-
120
- function readGb2312File(filePath) {
121
- const iconvPath = resolveIconvPath()
122
- const output = runIconv(iconvPath, ['-f', 'GB2312', '-t', 'UTF-8', filePath])
123
- return output.toString('utf8')
124
- }
125
-
126
- export const read = tool({
127
- description: '按 legacy 编码读取文件',
128
- args: {
129
- filePath: tool.schema.string().describe('文件路径'),
130
- encoding: tool.schema.string().describe('legacy 编码'),
131
- strict: tool.schema.boolean().describe('是否严格模式'),
132
- },
133
- async execute(args, context) {
134
- const filePath = path.isAbsolute(args.filePath)
135
- ? args.filePath
136
- : path.join(context.worktree, args.filePath)
137
-
138
- if (args.encoding.toLowerCase() === 'gb2312') {
139
- return readGb2312File(filePath)
140
- }
141
-
142
- const iconvPath = resolveIconvPath()
143
- const output = runIconv(iconvPath, ['-f', args.encoding, '-t', 'UTF-8', filePath])
144
- return output.toString('utf8')
145
- },
146
- })
147
-
148
- export const write = tool({
149
- description: '按 legacy 编码写入文件',
150
- args: {
151
- filePath: tool.schema.string().describe('文件路径'),
152
- content: tool.schema.string().describe('UTF-8 内容'),
153
- encoding: tool.schema.string().describe('legacy 编码'),
154
- strict: tool.schema.boolean().describe('是否严格模式'),
155
- },
156
- async execute(args, context) {
157
- const filePath = path.isAbsolute(args.filePath)
158
- ? args.filePath
159
- : path.join(context.worktree, args.filePath)
160
-
161
- const tempDir = await mkdtemp(path.join(tmpdir(), 'legacy-write-'))
162
- const tempPath = path.join(tempDir, 'content.utf8')
163
-
164
- try {
165
- await writeFile(tempPath, args.content, 'utf8')
166
-
167
- if (args.encoding.toLowerCase() === 'gb2312') {
168
- const iconvPath = resolveIconvPath()
169
- return runNode([resolveTxtToolPath(context), 'write', filePath, tempPath], context.worktree, {
170
- OPENCODE_ICONV_PATH: iconvPath,
171
- ICONV_PATH: iconvPath,
172
- })
173
- }
174
-
175
- const iconvPath = resolveIconvPath()
176
- const converted = runIconv(iconvPath, ['-f', 'UTF-8', '-t', args.encoding, tempPath])
177
- await writeFile(filePath, converted)
178
- return `已按 ${args.encoding} 编码写入 ${filePath}`
179
- } finally {
180
- await rm(tempDir, { recursive: true, force: true })
181
- }
182
- },
183
- })
184
-
185
- export const edit = tool({
186
- description: '按 legacy 编码编辑文件',
187
- args: {
188
- filePath: tool.schema.string().describe('文件路径'),
189
- oldString: tool.schema.string().describe('要替换的旧文本'),
190
- newString: tool.schema.string().describe('要写入的新文本'),
191
- replaceAll: tool.schema.boolean().describe('是否全部替换'),
192
- encoding: tool.schema.string().describe('legacy 编码'),
193
- strict: tool.schema.boolean().describe('是否严格模式'),
194
- },
195
- async execute(args, context) {
196
- const filePath = path.isAbsolute(args.filePath)
197
- ? args.filePath
198
- : path.join(context.worktree, args.filePath)
199
-
200
- if (!existsSync(filePath)) {
201
- throw new Error(`文件不存在: ${filePath}`)
202
- }
203
-
204
- const content = await read.execute(
205
- {
206
- filePath,
207
- encoding: args.encoding,
208
- strict: args.strict,
209
- raw: true,
210
- },
211
- context,
212
- )
213
-
214
- const result = applyLegacyEdit(content, args.oldString, args.newString, Boolean(args.replaceAll))
215
-
216
- if (!result.changed) {
217
- throw new Error(result.error)
218
- }
219
-
220
- return write.execute(
221
- {
222
- filePath,
223
- content: result.content,
224
- encoding: args.encoding,
225
- strict: args.strict,
226
- },
227
- context,
228
- )
229
- },
230
- })
package/tools/read.ts DELETED
@@ -1,213 +0,0 @@
1
- import { tool } from '@opencode-ai/plugin'
2
- import { readFile } from 'node:fs/promises'
3
- import { existsSync, readFileSync } from 'node:fs'
4
- import path from 'node:path'
5
- import { spawnSync } from 'node:child_process'
6
- import { createDefaultLegacyRules, matchLegacyRule } from './legacy-router.mjs'
7
- import { getGlobalLegacyRulesPath, getKnownIconvCandidates } from './opencode-paths.mjs'
8
-
9
- let cachedIconvPath = null
10
-
11
- function loadLegacyRules(worktree) {
12
- const projectConfigPath = path.join(worktree, '.opencode', 'legacy-rules.json')
13
- const globalConfigPath = getGlobalLegacyRulesPath()
14
-
15
- if (existsSync(projectConfigPath)) {
16
- return JSON.parse(readFileSync(projectConfigPath, 'utf8')).rules ?? createDefaultLegacyRules()
17
- }
18
-
19
- if (existsSync(globalConfigPath)) {
20
- return JSON.parse(readFileSync(globalConfigPath, 'utf8')).rules ?? createDefaultLegacyRules()
21
- }
22
-
23
- return createDefaultLegacyRules()
24
- }
25
-
26
- function resolvePath(filePath, worktree) {
27
- return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
28
- }
29
-
30
- function normalizeWindowsPath(filePath) {
31
- return filePath.replace(/\\/g, '/')
32
- }
33
-
34
- function getDerivedIconvCandidates() {
35
- const pathValue = process.env.Path || process.env.PATH || ''
36
- const parts = pathValue.split(';').map((item) => item.trim()).filter(Boolean)
37
- const derived = []
38
-
39
- for (const part of parts) {
40
- const normalized = normalizeWindowsPath(part)
41
- if (normalized.toLowerCase().endsWith('/git/cmd')) {
42
- derived.push(normalized.replace(/\/cmd$/i, '/usr/bin/iconv.exe'))
43
- }
44
- }
45
-
46
- return derived
47
- }
48
-
49
- function resolveIconvPath() {
50
- if (cachedIconvPath) {
51
- return cachedIconvPath
52
- }
53
-
54
- const candidates = [
55
- 'iconv',
56
- process.env.OPENCODE_ICONV_PATH,
57
- process.env.ICONV_PATH,
58
- ...getKnownIconvCandidates(),
59
- ...getDerivedIconvCandidates(),
60
- ].filter(Boolean)
61
-
62
- for (const candidate of candidates) {
63
- const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8' })
64
- if (probe.status === 0) {
65
- cachedIconvPath = candidate
66
- return cachedIconvPath
67
- }
68
- }
69
-
70
- throw new Error('iconv 不可用,无法处理 legacy 文件读取')
71
- }
72
-
73
- function applyOffsetLimit(content, offset, limit) {
74
- if (offset === undefined && limit === undefined) {
75
- return content
76
- }
77
-
78
- if (offset !== undefined && offset < 1) {
79
- throw new Error('offset must be greater than or equal to 1')
80
- }
81
-
82
- const lines = content.split(/\r?\n/)
83
- const start = offset ? offset - 1 : 0
84
- const end = limit ? start + limit : lines.length
85
- return lines.slice(start, end).join('\n')
86
- }
87
-
88
- function applyOffsetLimitFast(content, offset, limit) {
89
- if (offset === undefined && limit === undefined) {
90
- return content
91
- }
92
-
93
- if (offset !== undefined && offset < 1) {
94
- throw new Error('offset must be greater than or equal to 1')
95
- }
96
-
97
- const targetStartLine = offset || 1
98
- const targetLineCount = limit || Number.MAX_SAFE_INTEGER
99
- const targetEndLine = targetStartLine + targetLineCount - 1
100
-
101
- let lineNumber = 1
102
- let currentStart = 0
103
- let sliceStart = -1
104
- let sliceEnd = content.length
105
-
106
- for (let index = 0; index < content.length; index += 1) {
107
- const ch = content[index]
108
- if (ch !== '\n') {
109
- continue
110
- }
111
-
112
- if (lineNumber === targetStartLine) {
113
- sliceStart = currentStart
114
- }
115
-
116
- if (lineNumber === targetEndLine) {
117
- sliceEnd = index
118
- break
119
- }
120
-
121
- lineNumber += 1
122
- currentStart = index + 1
123
- }
124
-
125
- if (sliceStart === -1) {
126
- if (lineNumber === targetStartLine) {
127
- sliceStart = currentStart
128
- } else {
129
- return ''
130
- }
131
- }
132
-
133
- const result = content.slice(sliceStart, sliceEnd)
134
- return result.endsWith('\r') ? result.slice(0, -1) : result
135
- }
136
-
137
- function readGb2312File(filePath, offset, limit) {
138
- const iconvPath = resolveIconvPath()
139
- const result = spawnSync(iconvPath, ['-f', 'GB2312', '-t', 'UTF-8', filePath], {
140
- encoding: 'utf8',
141
- })
142
-
143
- if (result.status !== 0) {
144
- throw new Error(result.stderr || 'legacy txt 读取失败')
145
- }
146
-
147
- return applyOffsetLimitFast(result.stdout, offset, limit)
148
- }
149
-
150
- function withLargeFileNotice(content, offset, limit) {
151
- if (offset === undefined && limit === undefined) {
152
- return content
153
- }
154
-
155
- return [
156
- '--- 读取回执 ---',
157
- `读取方式:分段读取`,
158
- `起始行号:${offset ?? 1}`,
159
- `读取行数:${limit ?? 'all'}`,
160
- '说明:如需继续读取剩余内容,请继续增大 offset。',
161
- content,
162
- ].join('\n')
163
- }
164
-
165
- function withLegacyReadNotice(content, matched, offset, limit, raw = false) {
166
- if (raw) {
167
- return content
168
- }
169
-
170
- const lines = [
171
- '--- Legacy 读取回执 ---',
172
- `编码:${matched?.encoding || 'unknown'}`,
173
- `处理器:${matched?.tool || 'legacy-text'}`,
174
- `严格模式:${matched?.strict === true ? 'true' : 'false'}`,
175
- ]
176
-
177
- if (offset !== undefined || limit !== undefined) {
178
- lines.push(`读取方式:分段读取`)
179
- lines.push(`起始行号:${offset ?? 1}`)
180
- lines.push(`读取行数:${limit ?? 'all'}`)
181
- lines.push('说明:如需继续读取剩余内容,请继续增大 offset。')
182
- } else {
183
- lines.push('读取方式:完整读取')
184
- }
185
-
186
- lines.push(content)
187
- return lines.join('\n')
188
- }
189
-
190
- export default tool({
191
- description: '读取文件,命中 legacy 规则时自动按对应编码处理',
192
- args: {
193
- filePath: tool.schema.string().describe('文件路径'),
194
- offset: tool.schema.number().optional().describe('起始行号,1 开始'),
195
- limit: tool.schema.number().optional().describe('最多读取多少行'),
196
- raw: tool.schema.boolean().optional().describe('内部调用时返回原始内容'),
197
- },
198
- async execute(args, context) {
199
- const filePath = resolvePath(args.filePath, context.worktree)
200
- const rules = loadLegacyRules(context.worktree)
201
- const matched = matchLegacyRule(filePath, rules)
202
-
203
- if (matched?.tool === 'txt-gb2312' || matched?.encoding?.toLowerCase() === 'gb2312') {
204
- return withLegacyReadNotice(readGb2312File(filePath, args.offset, args.limit), matched, args.offset, args.limit, Boolean(args.raw))
205
- }
206
-
207
- const content = await readFile(filePath, 'utf8')
208
- if (args.raw) {
209
- return applyOffsetLimitFast(content, args.offset, args.limit)
210
- }
211
- return withLargeFileNotice(applyOffsetLimitFast(content, args.offset, args.limit), args.offset, args.limit)
212
- },
213
- })
@@ -1,59 +0,0 @@
1
- import { tool } from '@opencode-ai/plugin'
2
- import { existsSync } from 'node:fs'
3
- import path from 'node:path'
4
- import readTool from './read.ts'
5
- import writeTool from './write.ts'
6
- import {
7
- insertAfterAnchor,
8
- insertIntoLabelAfterMarker,
9
- replaceLabelBlock,
10
- } from './script-edit-core.mjs'
11
-
12
- function resolvePath(filePath, worktree) {
13
- return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
14
- }
15
-
16
- export default tool({
17
- description: '按标签块或锚点编辑脚本类文本,适合 legacy 脚本文件',
18
- args: {
19
- filePath: tool.schema.string().describe('文件路径'),
20
- mode: tool.schema.enum(['insert_after_anchor', 'insert_into_label_after_marker', 'replace_label_block']).describe('编辑模式'),
21
- label: tool.schema.string().optional().describe('目标标签名,例如 @全体补偿页面'),
22
- marker: tool.schema.string().optional().describe('标签块内锚点行,例如 #ACT'),
23
- anchor: tool.schema.string().optional().describe('普通锚点文本'),
24
- content: tool.schema.string().describe('要插入或替换的内容'),
25
- },
26
- async execute(args, context) {
27
- const filePath = resolvePath(args.filePath, context.worktree)
28
-
29
- if (!existsSync(filePath)) {
30
- throw new Error(`文件不存在: ${filePath}`)
31
- }
32
-
33
- const original = await readTool.execute({ filePath, raw: true }, context)
34
- let next = original
35
-
36
- if (args.mode === 'insert_after_anchor') {
37
- if (!args.anchor) {
38
- throw new Error('insert_after_anchor 模式缺少 anchor')
39
- }
40
- next = insertAfterAnchor(original, args.anchor, args.content)
41
- }
42
-
43
- if (args.mode === 'insert_into_label_after_marker') {
44
- if (!args.label || !args.marker) {
45
- throw new Error('insert_into_label_after_marker 模式缺少 label 或 marker')
46
- }
47
- next = insertIntoLabelAfterMarker(original, args.label, args.marker, args.content)
48
- }
49
-
50
- if (args.mode === 'replace_label_block') {
51
- if (!args.label) {
52
- throw new Error('replace_label_block 模式缺少 label')
53
- }
54
- next = replaceLabelBlock(original, args.label, args.content)
55
- }
56
-
57
- return writeTool.execute({ filePath, content: next }, context)
58
- },
59
- })
package/tools/write.ts DELETED
@@ -1,67 +0,0 @@
1
- import { tool } from '@opencode-ai/plugin'
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
- import { existsSync, readFileSync } from 'node:fs'
4
- import path from 'node:path'
5
- import { tmpdir } from 'node:os'
6
- import { spawnSync } from 'node:child_process'
7
- import { createDefaultLegacyRules, matchLegacyRule } from './legacy-router.mjs'
8
- import { getGlobalLegacyRulesPath, getGlobalToolPath } from './opencode-paths.mjs'
9
-
10
- function loadLegacyRules(worktree: string) {
11
- const projectConfigPath = path.join(worktree, '.opencode', 'legacy-rules.json')
12
- const globalConfigPath = getGlobalLegacyRulesPath()
13
-
14
- if (existsSync(projectConfigPath)) {
15
- return JSON.parse(readFileSync(projectConfigPath, 'utf8')).rules ?? createDefaultLegacyRules()
16
- }
17
-
18
- if (existsSync(globalConfigPath)) {
19
- return JSON.parse(readFileSync(globalConfigPath, 'utf8')).rules ?? createDefaultLegacyRules()
20
- }
21
-
22
- return createDefaultLegacyRules()
23
- }
24
-
25
- function resolvePath(filePath: string, worktree: string) {
26
- return path.isAbsolute(filePath) ? filePath : path.join(worktree, filePath)
27
- }
28
-
29
- function writeViaTxtTool(targetPath: string, content: string, worktree: string) {
30
- const toolPath = getGlobalToolPath('txt-gb2312-tool.mjs')
31
- return mkdtemp(path.join(tmpdir(), 'global-write-')).then(async (tempDir) => {
32
- const tempPath = path.join(tempDir, 'content.utf8')
33
- try {
34
- await writeFile(tempPath, content, 'utf8')
35
- const result = spawnSync(process.execPath, [toolPath, 'write', targetPath, tempPath], {
36
- cwd: worktree,
37
- encoding: 'utf8',
38
- })
39
- if (result.status !== 0) {
40
- throw new Error(result.stderr || 'legacy txt 写入失败')
41
- }
42
- return result.stdout
43
- } finally {
44
- await rm(tempDir, { recursive: true, force: true })
45
- }
46
- })
47
- }
48
-
49
- export default tool({
50
- description: '写入文件,命中 legacy 规则时自动按对应编码处理',
51
- args: {
52
- filePath: tool.schema.string().describe('文件路径'),
53
- content: tool.schema.string().describe('文件内容'),
54
- },
55
- async execute(args, context) {
56
- const filePath = resolvePath(args.filePath, context.worktree)
57
- const rules = loadLegacyRules(context.worktree)
58
- const matched = matchLegacyRule(filePath, rules)
59
-
60
- if (matched?.tool === 'txt-gb2312' || matched?.encoding?.toLowerCase() === 'gb2312') {
61
- return writeViaTxtTool(filePath, args.content, context.worktree)
62
- }
63
-
64
- await writeFile(filePath, args.content, 'utf8')
65
- return `已写入 ${filePath}`
66
- },
67
- })