@flotiarenor/dsh-tool-text-editor 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/lib/editor.mjs ADDED
@@ -0,0 +1,301 @@
1
+ // SPDX-FileCopyrightText: 2026 Flotiarenor
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * editor.mjs —— dsh 模型工具插件:`edit_text` / `write_text`。
5
+ *
6
+ * 为什么存在:dsh 原生的 `write` / `edit` 在 Windows 上会丢掉 UTF-8 BOM,原生 `write` 还会把
7
+ * CRLF 文件改写成 LF(实测确认;`@deepseek-ai/dsh-fs-local` 全文没有 BOM 处理,Node 的
8
+ * `TextDecoder` 默认吞掉前导 BOM 字节)。本插件用**进程内 Node 实现**(`lib/core.mjs`)做同样
9
+ * 的事,但把 BOM 与行尾保真、dry-run diff、自动备份、编辑台账、grep/lines 锚点一起带上。
10
+ *
11
+ * 与原生工具的关系:注册的是两个**不与原生重名**的工具,原生 `edit`/`write` 原样保留,
12
+ * 因此没有同名注册冲突,回退就是给 preset 行加 `disabled: true`。
13
+ *
14
+ * 依赖:**零**。只用 `node:` 内置模块 —— 不启动解释器或外部命令,不 import 任何包,没有构建
15
+ * 步骤、没有第三方依赖、没有外部运行时。因此本文件既能被 preset 行用绝对路径加载,也能被 link
16
+ * 进 profile 后按包名加载(preset 行的裸包名走宿主基址解析,模块内部的裸 import 走文件真实路径
17
+ * 解析;零依赖让两种挂载方式都成立)。内嵌的 JSON Schema 是用 dsh 自己的
18
+ * `parameterSchemaSpecToJsonSchema` / `valueSchemaSpecToJsonSchema` 生成的(生成脚本在仓库里,见
19
+ * `tools/gen-schema.mjs`;它不随包发布)。
20
+ *
21
+ * 有意为之的取舍:
22
+ * * 写盘**不经过** `ctx.fs`:绕过 fs 观察策略(先读后写 / 版本新鲜度)、沙箱与
23
+ * `sandbox_permissions` 审批升权、原生原子写与 Windows DACL 保留,也不产生 Web UI 的
24
+ * diff 卡片(模型仍能看到文本 diff)。
25
+ * * `lines` / `before <行号>` 是**盲锚点**:行号错了不会报错,会改在别的地方。
26
+ * * 同目标串行只覆盖**本进程内**;跨进程(另一个 dsh 实例、你手边的编辑器)仍可能互相覆盖。
27
+ *
28
+ * 配置(本插件没有 Config schema,preset 行的 `config:` 字段原样透传):
29
+ * backup: boolean 默认 true(落盘前备份到 artifactsDir/backups)
30
+ * ledger: boolean 默认 true(追加 artifactsDir/edits.log)
31
+ * artifactsDir: string 默认 <工作区>/.dsh
32
+ * newFileBom: boolean 默认 false(新建文件是否写 BOM)
33
+ * context: number diff 上下文行数,默认 3
34
+ * root: string 没有 agent 会话时的回退工作区
35
+ * 新建文件的行尾推断可用环境变量 `DSH_TEXT_EDITOR_EOL` = lf | crlf 覆盖(见 lib/core.mjs)。
36
+ */
37
+
38
+ import { UsageError, applyPlan, toLf } from './core.mjs'
39
+
40
+ export const name = 'tool-text-editor'
41
+
42
+ /** 只消费宿主服务(工具注册表 / 系统提示词);不发布任何服务。 */
43
+ export const inject = ['tools', 'systemPrompt']
44
+
45
+ const EDIT_TOOL = 'edit_text'
46
+ const WRITE_TOOL = 'write_text'
47
+ const TIMEOUT_MS = 60_000
48
+ const EDIT_MODES = ['replace', 'after', 'before', 'append', 'prepend']
49
+
50
+ /** 工具引导(order 116,落在工具引导区间 100–199)。文本里不能出现双花括号(会被当提示词变量)。 */
51
+ const GUIDANCE =
52
+ 'Prefer `edit_text` and `write_text` for text changes in this workspace: they preserve a UTF-8 BOM and '
53
+ + 'the file existing CRLF/LF style, print a unified diff, back the previous content up, and accept `grep` '
54
+ + 'or `lines` anchors so the old text never has to be copied by hand. The built-in `write` tool drops the '
55
+ + 'BOM and rewrites a CRLF file as LF, and the built-in `edit` tool drops the BOM; use the built-ins '
56
+ + 'only when a `_text` call reports that it cannot run.'
57
+
58
+ const EDIT_DESCRIPTION =
59
+ 'Edit one existing text file. Preserves the UTF-8 BOM and the file line-ending style, prints a unified '
60
+ + 'diff, and backs the previous content up before writing. Give exactly ONE anchor: `old_text` (literal, '
61
+ + 'copied from `read`), `grep` (a regular expression whose matching line/block becomes the anchor), or '
62
+ + '`lines` (e.g. "263:270"). `mode` defaults to `replace`; use `after`/`before` to insert beside a '
63
+ + '`grep`/`lines` anchor, `append`/`prepend` for the file ends. Prefer `old_text`/`grep`: a wrong line '
64
+ + 'number does not fail, it edits the wrong place. A literal that occurs more than once is refused unless '
65
+ + '`nth` or `count` says which/how many. Set `dry_run` to preview without writing.'
66
+
67
+ const WRITE_DESCRIPTION =
68
+ 'Create or completely replace one text file. Preserves the UTF-8 BOM and the file line-ending style, '
69
+ + 'prints a unified diff, and backs the previous content up before overwriting. Creation needs no flag: '
70
+ + 'the tool detects whether the target exists, and a brand-new file follows the line-ending style of its '
71
+ + 'sibling files. Set `dry_run` to preview without writing.'
72
+
73
+ /** 参数 schema(等价于 defineTool 对 `tools/gen-schema.mjs` 里 DSL 的产物;那里会校验二者一致)。 */
74
+ export const EDIT_PARAMETERS = {
75
+ type: 'object',
76
+ properties: {
77
+ file_path: { type: 'string', description: 'Target file, resolved against the session working directory when relative.' },
78
+ new_text: { type: 'string', description: 'Replacement / inserted text.' },
79
+ old_text: { type: 'string', description: 'Literal anchor text to replace (exactly one anchor source).' },
80
+ grep: { type: 'string', description: 'Regular-expression anchor: the matching line or line block is replaced.' },
81
+ lines: { type: 'string', description: 'Line anchor, e.g. "263:270" or "120".' },
82
+ mode: { type: 'string', description: 'Edit kind. Default replace.', enum: EDIT_MODES },
83
+ count: { type: 'number', description: 'Require exactly N occurrences and replace all of them.' },
84
+ nth: { type: 'number', description: 'Replace the k-th occurrence only (1-based).' },
85
+ strict: { type: 'boolean', description: 'Disable relaxed matching.' },
86
+ dry_run: { type: 'boolean', description: 'Print the diff without writing.' },
87
+ note: { type: 'string', description: 'One-line reason recorded in the edit ledger.' },
88
+ },
89
+ required: ['file_path', 'new_text'],
90
+ }
91
+
92
+ export const WRITE_PARAMETERS = {
93
+ type: 'object',
94
+ properties: {
95
+ file_path: { type: 'string', description: 'Target file, resolved against the session working directory when relative.' },
96
+ content: { type: 'string', description: 'Complete new file content.' },
97
+ dry_run: { type: 'boolean', description: 'Print the diff without writing.' },
98
+ note: { type: 'string', description: 'One-line reason recorded in the edit ledger.' },
99
+ },
100
+ required: ['file_path', 'content'],
101
+ }
102
+
103
+ /** 两个工具共用的规范返回值。一切都在进程内完成,所以只有结果,没有退出码或后端标记。 */
104
+ export const OUTPUT_SCHEMA = {
105
+ type: 'object',
106
+ additionalProperties: false,
107
+ properties: {
108
+ path: { type: 'string' },
109
+ ok: { type: 'boolean' },
110
+ wrote: { type: 'boolean' },
111
+ dryRun: { type: 'boolean' },
112
+ stdout: { type: 'string' },
113
+ stderr: { type: 'string' },
114
+ },
115
+ required: ['path', 'ok', 'wrote', 'dryRun', 'stdout', 'stderr'],
116
+ }
117
+
118
+ /** 模型可见的结果文本:失败给 stderr + stdout,成功给头部 + unified diff。 */
119
+ function renderResult(_args, value) {
120
+ const where = value.path === '' ? '' : ' ' + value.path
121
+ if (!value.ok) {
122
+ const detail = [value.stderr.trim(), value.stdout.trim()].filter((part) => part !== '').join('\n')
123
+ return [{ type: 'text', text: 'FAIL' + where + '\n' + (detail === '' ? '(no output)' : detail) }]
124
+ }
125
+ const head = value.dryRun ? 'DRY RUN' + where + ' (nothing written)' : 'WROTE' + where
126
+ const body = value.stdout.trim()
127
+ return [{ type: 'text', text: body === '' ? head : head + '\n' + body }]
128
+ }
129
+
130
+ function stringArg(value, label, { required = false } = {}) {
131
+ if (value === undefined) {
132
+ if (required) throw new UsageError(`${label} is required`)
133
+ return undefined
134
+ }
135
+ if (typeof value !== 'string') throw new UsageError(`${label} must be a string`)
136
+ if (required && value.trim() === '') throw new UsageError(`${label} must be a non-empty string`)
137
+ return value
138
+ }
139
+
140
+ function integerArg(value, label, minimum) {
141
+ if (value === undefined) return undefined
142
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum) {
143
+ throw new UsageError(`${label} must be an integer >= ${minimum}`)
144
+ }
145
+ return value
146
+ }
147
+
148
+ function booleanArg(value, label) {
149
+ if (value === undefined) return undefined
150
+ if (typeof value !== 'boolean') throw new UsageError(`${label} must be a boolean`)
151
+ return value
152
+ }
153
+
154
+ /**
155
+ * 校验并归一 `edit_text` 入参。
156
+ * @throws {UsageError} 参数不合法(不会落盘)。
157
+ */
158
+ export function planEdit(args) {
159
+ const filePath = stringArg(args.file_path, 'file_path', { required: true })
160
+ if (typeof args.new_text !== 'string') throw new UsageError('new_text must be a string')
161
+ const mode = args.mode === undefined ? 'replace' : args.mode
162
+ if (typeof mode !== 'string' || !EDIT_MODES.includes(mode)) {
163
+ throw new UsageError('mode must be one of ' + EDIT_MODES.join(' / '))
164
+ }
165
+ const oldText = stringArg(args.old_text, 'old_text')
166
+ const grep = stringArg(args.grep, 'grep')
167
+ const lines = args.lines === undefined ? undefined : String(args.lines)
168
+ const anchors = []
169
+ if (oldText !== undefined) anchors.push('old_text')
170
+ if (grep !== undefined) anchors.push('grep')
171
+ if (lines !== undefined) anchors.push('lines')
172
+ if (mode === 'replace') {
173
+ if (anchors.length === 0) throw new UsageError('replace requires exactly one anchor: old_text, grep or lines')
174
+ if (anchors.length > 1) throw new UsageError('give exactly one anchor, got ' + anchors.join(' + '))
175
+ } else if (mode === 'after' || mode === 'before') {
176
+ if (oldText !== undefined) throw new UsageError(mode + ' takes grep or lines, not old_text')
177
+ if (anchors.length !== 1) throw new UsageError(mode + ' requires exactly one of grep / lines')
178
+ } else if (anchors.length > 0) {
179
+ throw new UsageError(mode + ' takes no anchor, got ' + anchors.join(' + '))
180
+ }
181
+ const count = integerArg(args.count, 'count', 1)
182
+ const nth = integerArg(args.nth, 'nth', 1)
183
+ if (count !== undefined && nth !== undefined) throw new UsageError('count and nth are mutually exclusive')
184
+ return {
185
+ kind: 'edit',
186
+ filePath,
187
+ mode,
188
+ newText: toLf(args.new_text),
189
+ oldText: oldText === undefined ? null : toLf(oldText),
190
+ anchor: grep !== undefined ? { value: grep } : lines !== undefined ? { value: lines } : null,
191
+ count,
192
+ nth,
193
+ strict: booleanArg(args.strict, 'strict') === true,
194
+ dryRun: booleanArg(args.dry_run, 'dry_run') === true,
195
+ note: stringArg(args.note, 'note') ?? '',
196
+ }
197
+ }
198
+
199
+ /** 校验并归一 `write_text` 入参。 */
200
+ export function planWrite(args) {
201
+ const filePath = stringArg(args.file_path, 'file_path', { required: true })
202
+ if (typeof args.content !== 'string') throw new UsageError('content must be a string')
203
+ return {
204
+ kind: 'write',
205
+ filePath,
206
+ content: toLf(args.content),
207
+ dryRun: booleanArg(args.dry_run, 'dry_run') === true,
208
+ note: stringArg(args.note, 'note') ?? '',
209
+ }
210
+ }
211
+
212
+ /** 会话工作区;没有 agent 时退化为给出的 fallback。 */
213
+ function workspaceRoot(exec, fallback) {
214
+ const session = exec && exec.agent && exec.agent.session
215
+ const cwd = session && session.header ? session.header.cwd : undefined
216
+ if (typeof cwd === 'string' && cwd.trim() !== '') return cwd
217
+ return fallback
218
+ }
219
+
220
+ function fail(path, message) {
221
+ return {
222
+ path: typeof path === 'string' ? path : '',
223
+ ok: false,
224
+ wrote: false,
225
+ dryRun: false,
226
+ stdout: '',
227
+ stderr: message,
228
+ }
229
+ }
230
+
231
+ /**
232
+ * 注册 `edit_text` / `write_text` 与工具引导段。
233
+ * @param ctx - 插件上下文(注册随其 fiber 释放)。
234
+ * @param config - preset 行配置(见文件头注释)。
235
+ */
236
+ export function apply(ctx, config) {
237
+ const settings = config === undefined || config === null ? {} : config
238
+ const fallbackRoot = typeof settings.root === 'string' && settings.root !== '' ? settings.root : process.cwd()
239
+ const artifactsDir = typeof settings.artifactsDir === 'string' && settings.artifactsDir !== ''
240
+ ? settings.artifactsDir
241
+ : undefined
242
+ const planOptions = {
243
+ backup: settings.backup !== false,
244
+ log: settings.ledger !== false,
245
+ context: Number.isFinite(settings.context) ? settings.context : undefined,
246
+ newFileBom: settings.newFileBom === true,
247
+ }
248
+
249
+ ctx.systemPrompt.section({ name: 'tool:edit_text', order: 116, text: GUIDANCE })
250
+
251
+ /**
252
+ * 组装上下文并执行。
253
+ * @param plan - `planEdit` / `planWrite` 的结果。
254
+ * @param exec - 工具执行上下文(提供会话工作区)。
255
+ */
256
+ async function run(plan, exec) {
257
+ const root = workspaceRoot(exec, fallbackRoot)
258
+ return await applyPlan(plan, {
259
+ root,
260
+ ...(artifactsDir === undefined ? {} : { artifactsDir }),
261
+ ...planOptions,
262
+ tool: plan.kind === 'write' ? WRITE_TOOL : EDIT_TOOL,
263
+ })
264
+ }
265
+
266
+ ctx.tools.register({
267
+ name: EDIT_TOOL,
268
+ description: EDIT_DESCRIPTION,
269
+ parameters: EDIT_PARAMETERS,
270
+ output: { schema: OUTPUT_SCHEMA, render: renderResult },
271
+ timeoutMs: TIMEOUT_MS,
272
+ async execute(args, exec) {
273
+ let plan
274
+ try {
275
+ plan = planEdit(args)
276
+ } catch (error) {
277
+ if (error instanceof UsageError) return fail(args.file_path, error.message)
278
+ throw error
279
+ }
280
+ return await run(plan, exec)
281
+ },
282
+ })
283
+
284
+ ctx.tools.register({
285
+ name: WRITE_TOOL,
286
+ description: WRITE_DESCRIPTION,
287
+ parameters: WRITE_PARAMETERS,
288
+ output: { schema: OUTPUT_SCHEMA, render: renderResult },
289
+ timeoutMs: TIMEOUT_MS,
290
+ async execute(args, exec) {
291
+ let plan
292
+ try {
293
+ plan = planWrite(args)
294
+ } catch (error) {
295
+ if (error instanceof UsageError) return fail(args.file_path, error.message)
296
+ throw error
297
+ }
298
+ return await run(plan, exec)
299
+ },
300
+ })
301
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@flotiarenor/dsh-tool-text-editor",
3
+ "version": "1.0.0",
4
+ "description": "Byte-faithful text editing tools (edit_text / write_text) for DeepSeek Harness: they preserve a UTF-8 BOM and the file's own CRLF/LF style (the built-in write/edit tools do not), print a unified diff, back the previous content up, and accept grep/lines anchors. Pure Node and in-process: no external runtime, no child process, no third-party package, no build step, no imports beyond node: builtins.",
5
+ "type": "module",
6
+ "main": "lib/editor.mjs",
7
+ "exports": {
8
+ ".": "./lib/editor.mjs",
9
+ "./core": "./lib/core.mjs",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ }
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "preset",
20
+ "scripts",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "README.zh.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "dsh",
28
+ "dsh-plugin",
29
+ "deepseek",
30
+ "deepseek-harness",
31
+ "cordis",
32
+ "cordis-plugin",
33
+ "tool",
34
+ "editor",
35
+ "bom",
36
+ "crlf"
37
+ ],
38
+ "engines": {
39
+ "node": "^22.19.0 || >=24.0.0"
40
+ },
41
+ "license": "Apache-2.0",
42
+ "author": {
43
+ "name": "Flotiarenor",
44
+ "url": "https://github.com/Flotiarenor"
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "registry": "https://registry.npmjs.org"
52
+ },
53
+ "homepage": "https://github.com/Flotiarenor/dsh-tool-text-editor#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/Flotiarenor/dsh-tool-text-editor/issues"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "https://github.com/Flotiarenor/dsh-tool-text-editor.git"
60
+ },
61
+ "scripts": {
62
+ "test": "node tools/check-license.mjs && node tools/selftest.mjs",
63
+ "check": "node tools/check-license.mjs",
64
+ "check:license": "node tools/check-license.mjs",
65
+ "check:schema": "node tools/gen-schema.mjs",
66
+ "selftest": "node tools/selftest.mjs",
67
+ "install:preset": "node scripts/install-preset.mjs"
68
+ }
69
+ }
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026 Flotiarenor
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ name: 字节保真编辑器(BOM/CRLF)
4
+ description: standard 的副本,额外挂载 edit_text / write_text 两个文本编辑工具:保 UTF-8 BOM 与文件自身 CRLF/LF 风格、出 unified diff、落盘前备份、支持 grep/lines 锚点。纯 Node 进程内实现(零依赖、不启动任何外部进程);原生 edit/write 保持不变。
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: 2026 Flotiarenor
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ /**
5
+ * install-preset.mjs —— 把 `edit_text` / `write_text` 装成一个**用户 preset**。
6
+ *
7
+ * 做法:读**本机 dsh 自带的 preset 组合**(默认 `standard`),把 `tool-text-editor` 那一行插进去,
8
+ * 再连同本仓库的 `preset/preset.yml` 写到 `<DSH_HOME>/.agent-presets/<id>/`。
9
+ *
10
+ * 为什么不在仓库里放一份 preset 组合的拷贝:
11
+ * * dsh 自带的组合是**别人(MIT, Copyright (c) 2026 DeepSeek)的作品**,随包分发它就要连带履行
12
+ * 它的署名义务,而这份拷贝与本插件的功能无关;
13
+ * * 从用户自己的 dsh 里取,preset 自然跟着他装的 dsh 版本走 —— 不会像拷贝那样随 dsh 升级而过期。
14
+ *
15
+ * 用法:
16
+ * node scripts/install-preset.mjs # 默认 --id texteditor --base standard
17
+ * node scripts/install-preset.mjs --id my-edit --base code
18
+ * node scripts/install-preset.mjs --from <path-to-agent.cordis.yml> # 自己指定源组合
19
+ * node scripts/install-preset.mjs --force # 覆盖已存在的 preset(只覆盖两个文件)
20
+ * node scripts/install-preset.mjs --dry-run # 只打印会做什么,不落盘
21
+ *
22
+ * 退出码:0 成功,1 失败,2 用法错误 / 找不到 dsh 自带的 preset 组合。
23
+ */
24
+
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { dirname, join, resolve } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url))
31
+ const REPO = resolve(HERE, '..')
32
+ const PLUGIN = join(REPO, 'lib', 'editor.mjs').replace(/\\/g, '/')
33
+ const META = join(REPO, 'preset', 'preset.yml')
34
+ const SHIPPED_PRESET_DIR = ['config', 'agent-presets']
35
+ const COMPOSITION = 'agent.cordis.yml'
36
+
37
+ function flagValue(name) {
38
+ const index = process.argv.indexOf(name)
39
+ if (index < 0) return undefined
40
+ const value = process.argv[index + 1]
41
+ if (value === undefined || value.startsWith('--')) {
42
+ console.error('FAIL ' + name + ' 缺少取值')
43
+ process.exit(2)
44
+ }
45
+ return value
46
+ }
47
+
48
+ /**
49
+ * dsh 可能装在任何位置,所以按布局枚举"自带 preset 组合"的候选路径(不写死本机路径):
50
+ * 1. `--from` / `DSH_PRESET_SOURCE` —— 显式指定,任何布局都能用;
51
+ * 2. dsh profile 的 node_modules(`<DSH_HOME|~/.dsh>/profiles/node_modules`);
52
+ * 3. npm 全局前缀下的 node_modules(Windows `%APPDATA%\npm`;POSIX `/usr/local/lib`、
53
+ * `/usr/lib`、`~/.npm-global/lib`)。
54
+ * @param base - 自带 preset 的 id(standard / code / cordis / minimal)。
55
+ * @returns 候选绝对路径(按优先级)。
56
+ */
57
+ function findCompositions(base) {
58
+ const candidates = []
59
+ const explicit = flagValue('--from') ?? process.env.DSH_PRESET_SOURCE
60
+ if (typeof explicit === 'string' && explicit !== '') candidates.push(resolve(explicit))
61
+ const add = (nodeModules) => {
62
+ if (typeof nodeModules !== 'string' || nodeModules === '') return
63
+ candidates.push(join(nodeModules, '@deepseek-ai', 'dsh', ...SHIPPED_PRESET_DIR, base, COMPOSITION))
64
+ }
65
+ add(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', 'node_modules'))
66
+ const globalRoots = process.platform === 'win32'
67
+ ? [process.env.APPDATA === undefined ? '' : join(process.env.APPDATA, 'npm', 'node_modules')]
68
+ : ['/usr/local/lib/node_modules', '/usr/lib/node_modules', join(homedir(), '.npm-global', 'lib', 'node_modules')]
69
+ for (const root of globalRoots) add(root)
70
+ return candidates
71
+ }
72
+
73
+ /** 我们插进组合里的那一段(只有这一段是我们自己的文字 + 行)。 */
74
+ function pluginBlock(sourcePath) {
75
+ return [
76
+ '# ── 字节保真的文本编辑工具(edit_text / write_text)─────────────────────────',
77
+ '#',
78
+ '# 存在理由:原生 `write` 会丢掉 UTF-8 BOM 并把 CRLF 文件改写成 LF,原生 `edit` 也会丢掉 BOM;',
79
+ '# 这两个工具把 BOM 与行尾都保住,另外带上 dry-run diff、落盘前备份、编辑台账、grep/lines 锚点',
80
+ '# 与"匹配失败给最接近候选"。实现是**进程内 Node**:零依赖、零外部运行时、每次调用没有进程',
81
+ '# 启动开销(不启动任何解释器或外部命令)。',
82
+ '#',
83
+ '# 原生 `edit`/`write` **保留不动**:本行注册的是两个**不同名**工具,同一层不会同名冲突,',
84
+ '# 想回退只需给这一行加 `disabled: true`(或整行删掉)。',
85
+ '#',
86
+ '# 本文件由 `scripts/install-preset.mjs` 生成:源 = 本机 dsh 自带的 preset 组合',
87
+ `# ${sourcePath}`,
88
+ '# 行名写的是本仓库 `lib/editor.mjs` 的绝对路径 —— preset 行的**裸包名**会从宿主组装基址解析,',
89
+ '# 但模块**内部的**裸 import 由 Node 按文件真实路径解析,而 preset 目录下没有 node_modules,',
90
+ '# 所以该插件刻意零依赖(只用 node: 内置模块),可以放在任何位置。',
91
+ '#',
92
+ '# 该插件消费宿主服务(tools / systemPrompt),不发布任何服务,因此不需要 isolate realm。',
93
+ '#',
94
+ '# 可选 config(插件没有 Config schema,字段原样透传):',
95
+ '# backup / ledger: boolean 默认都 true(备份到 artifactsDir/backups,台账 artifactsDir/edits.log)',
96
+ '# artifactsDir: <路径> 默认 <会话工作区>/.dsh',
97
+ '# newFileBom: boolean 默认 false(新建文件是否写 BOM)',
98
+ '# context: number diff 上下文行数,默认 3',
99
+ '# root: <路径> 没有 agent 会话时的回退工作区',
100
+ '- id: tool-text-editor',
101
+ ` name: '${PLUGIN}'`,
102
+ '',
103
+ ].join('\n')
104
+ }
105
+
106
+ /** 插入位置:dsh 自带组合里"文件系统"之后、"后台任务"之前;找不到锚点就追加到末尾。 */
107
+ const ANCHORS = [
108
+ { pattern: /^# ── background jobs/m, label: 'background jobs 段之前' },
109
+ { pattern: /^- id: tool-jobs$/m, label: 'tool-jobs 行之前' },
110
+ ]
111
+
112
+ /**
113
+ * 把插件段插进源组合。
114
+ * @returns `{ text, anchor }`
115
+ * @throws {Error} 源组合看起来已经打过补丁时。
116
+ */
117
+ function inject(source, sourcePath) {
118
+ if (/^- id: tool-text-editor$/m.test(source)) {
119
+ throw new Error('源组合里已经有 tool-text-editor 行了 —— 请指向 dsh 自带的原始组合')
120
+ }
121
+ const block = pluginBlock(sourcePath)
122
+ for (const { pattern, label } of ANCHORS) {
123
+ const match = pattern.exec(source)
124
+ if (match !== null) {
125
+ const at = match.index
126
+ return { text: source.slice(0, at) + block + '\n' + source.slice(at), anchor: label }
127
+ }
128
+ }
129
+ const separator = source.endsWith('\n') ? '\n' : '\n\n'
130
+ return { text: source + separator + block, anchor: '文件末尾' }
131
+ }
132
+
133
+ // ── 参数与前置检查 ──────────────────────────────────────────────────────────
134
+
135
+ const id = flagValue('--id') ?? 'texteditor'
136
+ const base = flagValue('--base') ?? 'standard'
137
+ const force = process.argv.includes('--force')
138
+ const dryRun = process.argv.includes('--dry-run')
139
+ const fromFlag = flagValue('--from')
140
+
141
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
142
+ console.error('FAIL preset id 必须是 [a-z0-9][a-z0-9-]*(会作为目录名),收到:' + id)
143
+ process.exit(2)
144
+ }
145
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(base)) {
146
+ console.error('FAIL --base 必须是 dsh 自带 preset 的 id(如 standard / code / minimal),收到:' + base)
147
+ process.exit(2)
148
+ }
149
+ if (!existsSync(PLUGIN)) {
150
+ console.error('FAIL 找不到插件文件:' + PLUGIN)
151
+ process.exit(1)
152
+ }
153
+ if (!existsSync(META)) {
154
+ console.error('FAIL 找不到 preset 元数据:' + META)
155
+ process.exit(1)
156
+ }
157
+
158
+ const candidates = findCompositions(base)
159
+ const sourcePath = candidates.find((candidate) => existsSync(candidate))
160
+ if (sourcePath === undefined) {
161
+ console.error(`FAIL 找不到本机 dsh 自带的 preset 组合(--base ${base});试过:`)
162
+ for (const candidate of candidates) console.error(' ' + candidate)
163
+ console.error(' 装了 dsh 就有;也可以用 --from <agent.cordis.yml 路径> 或 DSH_PRESET_SOURCE 指定。')
164
+ process.exit(2)
165
+ }
166
+
167
+ const source = readFileSync(sourcePath, 'utf8')
168
+ let injected
169
+ try {
170
+ injected = inject(source, sourcePath)
171
+ } catch (error) {
172
+ console.error('FAIL ' + error.message)
173
+ process.exit(2)
174
+ }
175
+
176
+ const dshHome = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ''
177
+ ? process.env.DSH_HOME.trim()
178
+ : join(homedir(), '.dsh')
179
+ const targetDir = join(dshHome, '.agent-presets', id)
180
+ const targetComposition = join(targetDir, COMPOSITION)
181
+ const targetMeta = join(targetDir, 'preset.yml')
182
+
183
+ console.log('仓库 : ' + REPO)
184
+ console.log('插件 : ' + PLUGIN)
185
+ console.log('源组合 : ' + sourcePath + (fromFlag === undefined && process.env.DSH_PRESET_SOURCE === undefined ? `(--base ${base})` : ''))
186
+ console.log('插入位置 : ' + injected.anchor)
187
+ console.log('DSH_HOME : ' + dshHome)
188
+ console.log('目标 preset : ' + targetDir)
189
+
190
+ const exists = existsSync(targetComposition)
191
+
192
+ if (dryRun) {
193
+ console.log('')
194
+ console.log('[dry-run] 会写入:')
195
+ console.log(' ' + targetComposition + `(源组合 ${source.split('\n').length} 行 + 插件段)`)
196
+ console.log(' ' + targetMeta)
197
+ if (exists && !force) {
198
+ console.error('')
199
+ console.error('[dry-run] 但目标已存在,真跑会被拒绝:' + targetComposition)
200
+ console.error(' 要覆盖请加 --force(只覆盖 agent.cordis.yml 与 preset.yml,同目录其它文件不动)。')
201
+ process.exit(1)
202
+ }
203
+ process.exit(0)
204
+ }
205
+
206
+ if (exists && !force) {
207
+ console.error('')
208
+ console.error('FAIL 该 preset 已存在:' + targetComposition)
209
+ console.error(' 要覆盖请加 --force(只覆盖 agent.cordis.yml 与 preset.yml,同目录其它文件不动)。')
210
+ process.exit(1)
211
+ }
212
+ if (exists) {
213
+ console.log('注意 : --force 将覆盖 ' + targetComposition)
214
+ }
215
+
216
+ mkdirSync(targetDir, { recursive: true })
217
+ writeFileSync(targetComposition, injected.text, 'utf8')
218
+ writeFileSync(targetMeta, readFileSync(META, 'utf8'), 'utf8')
219
+
220
+ console.log('')
221
+ console.log('OK 已安装 preset "' + id + '"')
222
+ console.log('下一步:')
223
+ console.log(' 1. 重启 dsh web(preset 名单在启动时读取;运行中的会话不会换 preset)')
224
+ console.log(' 2. 新建一个会话,preset 选 "' + id + '"')
225
+ console.log(' 3. 会话里直接用 edit_text / write_text(纯 Node 进程内实现:零依赖、零外部运行时)')
226
+ console.log('升级 dsh 后重跑本脚本(加 --force)即可让 preset 跟上新版自带组合。')
227
+ console.log('回退:给 ' + targetComposition + ' 里的 tool-text-editor 行加 disabled: true,或删掉 ' + targetDir)