dsh-plugin-multi-edit 0.0.0-stage → 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.
Files changed (3) hide show
  1. package/README.md +55 -2
  2. package/lib/index.js +237 -0
  3. package/package.json +34 -4
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-multi-edit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 DSH agent 的 `multi_edit` 工具:**一次调用改多文件/多处**的字面替换。
4
+ 内置 `edit` 一次只能改一处——批量改值、批量改名时代价很高。
5
+
6
+ ## 为什么用它
7
+
8
+ - 内置 `edit` 一次一处:改 7 处说法要 7~10 次调用
9
+ - **两阶段提交**:先把所有编辑在内存里预检并试应用,全部通过才写盘(不会改一半)
10
+ - 支持 `dryRun`(只报会替换几处)、BOM 保留、同文件多条顺序生效
11
+
12
+ ## 安装
13
+
14
+ ### 方式 A:Junction(推荐,改源码立即生效)
15
+
16
+ ```powershell
17
+ $nm = "$env:USERPROFILE\.dsh\profiles\desktop
18
+ ode_modules"
19
+ $src = "C:\path o\dsh-plugins\packages\multi-edit"
20
+ cmd /c mklink /J "$nm\dsh-plugin-multi-edit" $src
21
+ ```
22
+
23
+ 然后在 profile 的 `cordis.patch.yml` 追加:
24
+
25
+ ```yaml
26
+ - insert:
27
+ - id: multi-edit
28
+ name: 'dsh-plugin-multi-edit'
29
+ ```
30
+
31
+ ### 方式 B:npm
32
+
33
+ ```bash
34
+ pnpm dsh plugin --profile desktop add dsh-plugin-multi-edit
35
+ ```
36
+
37
+ 改完 **重启 DSH**(host 侧插件)。
38
+
39
+ ## 参数
40
+
41
+ | 字段 | 说明 |
42
+ |---|---|
43
+ | `edits` | 数组,每项 `{file_path, old_string, new_string, replace_all?}`,按顺序应用 |
44
+ | `onError` | `stop`(默认:任一预检失败则一份都不写)/ `continue` |
45
+ | `dryRun` | true 时只报告每条会替换几处,不落盘 |
46
+
47
+ > `old_string` 必须字面精确匹配;命中多处时必须显式 `replace_all: true`(防误伤)。
48
+
49
+ ## 适配
50
+
51
+ - DSH **0.2.2-rc.1+**(`ctx.tools.register(def)` 单参数契约)
52
+ - 零运行时依赖 · host-only
53
+
54
+ ## License
55
+
56
+ MIT
package/lib/index.js ADDED
@@ -0,0 +1,237 @@
1
+ // dsh-plugin-multi-edit -- host 侧
2
+ //
3
+ // 一个工具:multi_edit —— 一次调用完成【多处 / 多文件】编辑。
4
+ //
5
+ // ★ 为什么需要(2026-10-14 实证):内置 edit 一次只能改一个文件的一处。
6
+ // 当晚把库里 7 处「14 个插件」改成 16,我调用了 10 次 edit;每改一份文档又要单独一次。
7
+ // 每次往返都得重新读文件、重述 old_string、等结果 —— 纯浪费。
8
+ //
9
+ // ★ 它比 dsh-ops 的 ops_replace 强在哪(同一件事的不同做法):
10
+ // ① 两阶段提交:先把【所有】编辑在内存里预检 + 试应用,全部通过才写盘
11
+ // ⇒ 不会出现「前面文件改了、后面失败」的半成品;ops_replace 是逐文件改。
12
+ // ② 同一文件的多条编辑按顺序在内存副本上生效(后一条能看到前一条的结果)
13
+ // ⇒ 一个文件里改多处,也只写盘一次。
14
+ // ③ 逐项结果 + 汇总:哪条失败、为什么失败,一眼看清。
15
+ //
16
+ // ★ 与内置 edit 保持一致的字段名(file_path / old_string / new_string / replace_all),
17
+ // 模型不用学新词。
18
+ //
19
+ // ★ 踩坑点(写的时候注意,改的时候别丢):
20
+ // · 裸 ctx.tools.register() 注册的工具【不做参数校验】(官方原话)⇒ 这里必须自校验
21
+ // · 保留原文件的 BOM(我们的 .ps1 / .cmd 有 BOM 需求,写回时不能丢)
22
+ // · 读进来什么样写回去什么样(不做换行/编码转换)
23
+ // · isConcurrencySafe 必须 false(写文件)
24
+
25
+ import { existsSync, readFileSync, writeFileSync, statSync } from 'node:fs';
26
+
27
+ export const name = 'multi-edit';
28
+ export const inject = ['tools'];
29
+
30
+ const BOM_CHAR = String.fromCharCode(0xFEFF);
31
+ const KNOWN_ARGS = ['edits', 'dryRun', 'onError'];
32
+ const ITEM_ARGS = ['file_path', 'old_string', 'new_string', 'replace_all'];
33
+
34
+ /** 数一个子串出现几次(不做正则,字面匹配,与内置 edit 一致)。 */
35
+ export function countOccurrences(hay, needle) {
36
+ if (!needle) return 0;
37
+ let n = 0;
38
+ let i = hay.indexOf(needle);
39
+ while (i >= 0) { n++; i = hay.indexOf(needle, i + needle.length); }
40
+ return n;
41
+ }
42
+
43
+ /** 严格自校验(裸 register 不校验 ⇒ 未知键必须拒绝,不能静默丢)。 */
44
+ export function validateArgs(args) {
45
+ if (!args || typeof args !== 'object') throw new Error('invalid args: expected an object');
46
+ for (const k of Object.keys(args)) {
47
+ if (KNOWN_ARGS.indexOf(k) < 0) throw new Error('invalid arg "' + k + '": known args are ' + KNOWN_ARGS.join(', '));
48
+ }
49
+ if (!Array.isArray(args.edits) || args.edits.length === 0) throw new Error('invalid edits: expected a non-empty array');
50
+ if (args.edits.length > 200) throw new Error('invalid edits: at most 200 entries per call');
51
+ const edits = args.edits.map((raw, i) => {
52
+ if (!raw || typeof raw !== 'object') throw new Error('invalid edits[' + i + ']: expected an object');
53
+ for (const k of Object.keys(raw)) {
54
+ if (ITEM_ARGS.indexOf(k) < 0) throw new Error('invalid edits[' + i + '].' + k + ': known keys are ' + ITEM_ARGS.join(', '));
55
+ }
56
+ for (const k of ['file_path', 'old_string', 'new_string']) {
57
+ if (typeof raw[k] !== 'string') throw new Error('invalid edits[' + i + '].' + k + ': expected a string');
58
+ }
59
+ if (raw.file_path.trim() === '') throw new Error('invalid edits[' + i + '].file_path: empty');
60
+ if (raw.old_string === '') throw new Error('invalid edits[' + i + '].old_string: empty(空串会到处命中)');
61
+ return {
62
+ file_path: raw.file_path,
63
+ old_string: raw.old_string,
64
+ new_string: raw.new_string,
65
+ replace_all: raw.replace_all === true,
66
+ };
67
+ });
68
+ let onError = 'stop';
69
+ if (args.onError !== undefined) {
70
+ if (args.onError !== 'stop' && args.onError !== 'continue') throw new Error("invalid onError: expected 'stop' or 'continue'");
71
+ onError = args.onError;
72
+ }
73
+ return { edits: edits, dryRun: args.dryRun === true, onError: onError };
74
+ }
75
+
76
+ /**
77
+ * 两阶段执行。
78
+ * @returns {{ dryRun: boolean, onError: string, results: Array, written: string[], aborted: boolean }}
79
+ */
80
+ export function planAndApply(edits, opts) {
81
+ const o = opts || {};
82
+ const files = new Map(); // path -> { original, text, hasBom }
83
+ const results = [];
84
+ let aborted = false;
85
+
86
+ // 阶段 1:预检 + 在内存副本上顺序试应用
87
+ for (let i = 0; i < edits.length; i++) {
88
+ const it = edits[i];
89
+ const rec = { index: i, file_path: it.file_path, ok: false, replaced: 0 };
90
+ results.push(rec);
91
+ try {
92
+ let f = files.get(it.file_path);
93
+ if (!f) {
94
+ if (!existsSync(it.file_path)) throw new Error('文件不存在');
95
+ if (!statSync(it.file_path).isFile()) throw new Error('不是普通文件');
96
+ const buf = readFileSync(it.file_path);
97
+ const hasBom = buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF;
98
+ const text = buf.toString('utf8');
99
+ f = { original: text, text: hasBom ? text.slice(1) : text, hasBom: hasBom };
100
+ files.set(it.file_path, f);
101
+ }
102
+ const occ = countOccurrences(f.text, it.old_string);
103
+ if (occ === 0) throw new Error('old_string 未命中(文件内容与预期不一致?)');
104
+ if (occ > 1 && !it.replace_all) throw new Error('old_string 命中 ' + occ + ' 处,需显式 replace_all: true');
105
+ f.text = it.replace_all
106
+ ? f.text.split(it.old_string).join(it.new_string)
107
+ : f.text.replace(it.old_string, it.new_string);
108
+ rec.ok = true;
109
+ rec.replaced = it.replace_all ? occ : 1;
110
+ } catch (e) {
111
+ rec.error = String((e && e.message) || e);
112
+ if (o.onError === 'stop') { aborted = true; break; } // 停止后续预检
113
+ }
114
+ }
115
+
116
+ // 阶段 2:写盘(stop 模式下有任何失败 ⇒ 一份都不写)
117
+ const written = [];
118
+ const anyFail = results.some((r) => !r.ok);
119
+ const mayWrite = !(o.onError === 'stop' && anyFail);
120
+ if (mayWrite && !o.dryRun) {
121
+ for (const [path, f] of files) {
122
+ if (f.text === f.original) continue; // 内容没变就别动 mtime
123
+ try {
124
+ writeFileSync(path, f.hasBom ? (BOM_CHAR + f.text) : f.text, 'utf8');
125
+ written.push(path);
126
+ } catch (e) {
127
+ // 写盘失败要如实反映到结果里(把该文件的成功项改成失败)
128
+ for (const r of results) {
129
+ if (r.ok && r.file_path === path) { r.ok = false; r.error = '写入失败: ' + ((e && e.message) || e); }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return { dryRun: o.dryRun === true, onError: o.onError || 'stop', results: results, written: written, aborted: aborted };
135
+ }
136
+
137
+ /** 渲染给模型看的汇总。 */
138
+ export function renderSummary(plan) {
139
+ const okCount = plan.results.filter((r) => r.ok).length;
140
+ const failCount = plan.results.length - okCount;
141
+ const totalReplaced = plan.results.reduce((s, r) => s + (r.ok ? r.replaced : 0), 0);
142
+ const head = (plan.dryRun ? '[dryRun] ' : '') + plan.results.length + ' 条编辑 · 成功 ' + okCount + ' · 失败 ' + failCount
143
+ + ' · 共替换 ' + totalReplaced + ' 处' + (plan.dryRun ? '(未落盘)' : (' · 写入 ' + plan.written.length + ' 个文件'));
144
+ const lines = [head];
145
+ if (plan.aborted) lines.push('(onError=stop:有一条失败 ⇒ 已中止,且【一份都没写】)');
146
+ for (const r of plan.results) {
147
+ lines.push((r.ok ? ' OK ' : ' FAIL ') + r.file_path + (r.ok ? (' ' + r.replaced + ' 处') : (' ' + r.error)));
148
+ }
149
+ if (!plan.dryRun && plan.written.length) lines.push('写入:' + plan.written.join(' , '));
150
+ return lines.join('\n');
151
+ }
152
+
153
+ export const MULTI_EDIT_DESCRIPTION = [
154
+ '一次调用完成【多处 / 多文件】编辑(内置 edit 一次只能改一个文件的一处)。',
155
+ 'edits 是数组,每项 { file_path, old_string, new_string, replace_all? },按顺序应用 —— 同一文件的多条会依次生效(后一条看到前一条的结果),该文件只写盘一次。',
156
+ '★ 两阶段:先把所有编辑在内存里预检并试应用,再统一写盘 ⇒ 不会出现「改了一半」的半成品。',
157
+ "onError='stop'(默认):任何一条预检失败 ⇒ 一份都不写;onError='continue':写成功的那部分,失败的逐条报出来。",
158
+ 'dryRun=true:只报告每条会替换几处,不落盘(改重要文件前先用它看一眼)。',
159
+ '★ 适用:批量改值/批量重命名(同一说法散落在 N 个文件)、一个文件里改多处。',
160
+ '★ 不适用:需要先看内容再决定怎么改的 —— 那要先 read / grep,别盲改。',
161
+ 'old_string 必须字面精确匹配;命中多处时必须显式 replace_all: true(防止误伤)。',
162
+ ].join(' ');
163
+
164
+ export const MULTI_EDIT_PARAMETERS = {
165
+ type: 'object',
166
+ properties: {
167
+ edits: {
168
+ type: 'array',
169
+ minItems: 1,
170
+ description: '编辑列表,按顺序应用。',
171
+ items: {
172
+ type: 'object',
173
+ properties: {
174
+ file_path: { type: 'string', description: '要改的文件路径。' },
175
+ old_string: { type: 'string', description: '要被替换的字面文本(必须精确匹配、非空)。' },
176
+ new_string: { type: 'string', description: '替换成什么(可为空串 = 删除)。' },
177
+ replace_all: { type: 'boolean', description: 'old_string 命中多处时是否全部替换(默认 false,多处会报错)。' },
178
+ },
179
+ required: ['file_path', 'old_string', 'new_string'],
180
+ additionalProperties: false,
181
+ },
182
+ },
183
+ dryRun: { type: 'boolean', description: '只预演不落盘(默认 false)。' },
184
+ onError: { type: 'string', enum: ['stop', 'continue'], description: "失败策略:'stop' 全部不写(默认),'continue' 写成功部分。" },
185
+ },
186
+ required: ['edits'],
187
+ additionalProperties: false,
188
+ };
189
+
190
+ export function multiEditDefinition() {
191
+ return {
192
+ name: 'multi_edit',
193
+ description: MULTI_EDIT_DESCRIPTION,
194
+ parameters: MULTI_EDIT_PARAMETERS,
195
+ isConcurrencySafe: () => false, // 写文件 ⇒ 不能并发
196
+ execute: async (args) => {
197
+ const v = validateArgs(args);
198
+ const plan = planAndApply(v.edits, { dryRun: v.dryRun, onError: v.onError });
199
+ const text = renderSummary(plan);
200
+ const okCount = plan.results.filter((r) => r.ok).length;
201
+ return {
202
+ text: text,
203
+ content: [{ type: 'text', text: text }], // 兼容:render 用 text,老路径用 content
204
+ ok: okCount,
205
+ failed: plan.results.length - okCount,
206
+ written: plan.written.length,
207
+ dryRun: plan.dryRun,
208
+ };
209
+ },
210
+ // ★ 必须声明(裸 register 要求 output;漏了会直接抛错)
211
+ output: {
212
+ schema: {
213
+ type: 'object',
214
+ additionalProperties: true,
215
+ properties: {
216
+ text: { type: 'string' },
217
+ ok: { type: 'number' },
218
+ failed: { type: 'number' },
219
+ written: { type: 'number' },
220
+ dryRun: { type: 'boolean' },
221
+ },
222
+ required: ['text'],
223
+ },
224
+ render: (_args, value) => [{ type: 'text', text: String((value && value.text) || '') }],
225
+ },
226
+ };
227
+ }
228
+
229
+ export function apply(ctx) {
230
+ try {
231
+ const dispose = ctx.tools.register(multiEditDefinition());
232
+ ctx.effect(() => () => { try { dispose?.(); } catch (e) { /* 忽略 */ } });
233
+ try { ctx.logger?.info?.('[multi-edit] 已注册 multi_edit 工具'); } catch (e) { /* 静默 */ }
234
+ } catch (e) {
235
+ try { ctx.logger?.error?.('[multi-edit] 注册失败: ' + ((e && e.message) || e)); } catch (e2) { /* 静默 */ }
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "dsh-plugin-multi-edit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "给 DSH agent 的 multi_edit 工具:一次调用改多文件/多处,两阶段预检 + dryRun",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/index.js"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Fanlr1/dsh-plugins.git",
20
+ "directory": "packages/multi-edit"
21
+ },
22
+ "homepage": "https://github.com/Fanlr1/dsh-plugins/tree/main/packages/multi-edit#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/Fanlr1/dsh-plugins/issues"
25
+ },
26
+ "keywords": [
27
+ "dsh",
28
+ "dsh-plugin",
29
+ "tools",
30
+ "multi-edit",
31
+ "batch-edit"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ }
36
+ }