dsh-godot-dev-shell 2.1.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 ADDED
@@ -0,0 +1,30 @@
1
+ # dsh-godot-dev-shell
2
+
3
+ DSH Profile Bundle(项目无关的最小工具集 + 配置面板)。
4
+
5
+ 设计原则:**插件与项目无关,不绑定任何代码/对象;只提供最小工具并保证鲁棒性。** 命令清单、退出码语义、规则模板全部由各工作区自己声明。
6
+
7
+ - **`ps_script`** — PowerShell 脚本落临时文件执行(pwsh 7 优先,5.1 兜底;UTF-8 BOM + 编码前导),消除多层转义、命令行长度限制、执行策略拦截与中文编码问题;stdout/stderr 分离 + 精确退出码;拒绝 NUL 与超大脚本;受沙箱约束并支持 `sandbox_permissions` 升级。
8
+ - **`run_command`** — 运行当前工作区在 `.dsh/godot-dev-shell.json` 的 `commands` 里声明的固化命令;`.ps1` 走 pwsh 7 `-File`,`.bat/.cmd/.exe` 走调用运算符,其余扩展名明确报错;未声明/文件缺失/旧版配置均给清晰错误。
9
+ - **`add_command`** — 向工作区注册/更新命令(安全写入配置:名字/路径/扩展名校验、存在性预检、JSON 合并保留其他命令);工作流:写脚本 → `add_command` 注册 → 更新规则 md → `run_command` 验证。
10
+ - **设置面板** — 设置 → Plugins → godot-dev-shell 页签:工作区选择(活跃优先)、项目命令表(存在性/超时/描述)、项目规则 md、模板、配置原文。
11
+
12
+ ## 项目级声明(跟项目走)
13
+
14
+ 在工作区放:
15
+
16
+ - `.dsh/godot-dev-shell.json` — 命令清单:
17
+ ```json
18
+ {
19
+ "commands": {
20
+ "check": { "file": ".dsh/scripts/gd_check.ps1", "description": "编译检查(0=通过 1=有错误 2=不可信)", "timeoutMs": 120000 },
21
+ "luban": { "file": "Tools/gen.bat", "workdir": "Tools", "description": "生成配置表" }
22
+ }
23
+ }
24
+ ```
25
+ `file` 相对工作区根或绝对路径;`workdir`/`timeoutMs`/`description` 可选。没有此文件:`ps_script` 完整可用,`run_command` 会报"未声明命令"。
26
+ - `.dsh/godot-dev-shell.md` — 项目规则/模板索引(AI 开工前先读);GDScript 模板放 `.dsh/templates/`。
27
+
28
+ ## 安装/更新(本机)
29
+
30
+ 源码在 `~/.dsh/packages/dsh-godot-dev-shell`。改动后重拷到 `profiles/<p>/node_modules/dsh-godot-dev-shell`(或跑 `dsh plugin --profile <p> install`),重启 Profile 生效。
@@ -0,0 +1,6 @@
1
+ # dsh-godot-dev-shell bundle patch: registers the host plugin for every
2
+ # session on this profile. The row name must match the npm package name
3
+ # and the host half's exported `name`.
4
+ - insert:
5
+ - id: godot-dev-shell
6
+ name: 'dsh-godot-dev-shell'
package/lib/client.js ADDED
@@ -0,0 +1,162 @@
1
+ // dsh-godot-dev-shell client bundle (ModuleLoader format).
2
+ // Settings → Plugins → godot-dev-shell 页签:当前工作区的配置面板。
3
+ // 数据来自 host 的 GET /api/godot-dev-shell/panel?workspace=<path>。
4
+
5
+ window.__ModuleLoader__.load({ id: 'dsh-godot-dev-shell', factory: (require) => {
6
+ var module = { exports: {} }
7
+ var exports = module.exports
8
+ var React = require('react')
9
+
10
+ var LS_KEY = 'godot-dev-shell:workspace'
11
+
12
+ function loadPanel(selected) {
13
+ var url = '/api/godot-dev-shell/panel'
14
+ if (selected) url += '?workspace=' + encodeURIComponent(selected)
15
+ return fetch(url).then(function (res) {
16
+ if (!res.ok) throw new Error('HTTP ' + res.status)
17
+ return res.json()
18
+ })
19
+ }
20
+
21
+ function rememberedWorkspace() {
22
+ try { return window.localStorage.getItem(LS_KEY) || '' } catch (e) { return '' }
23
+ }
24
+
25
+ function rememberWorkspace(path) {
26
+ try { window.localStorage.setItem(LS_KEY, path) } catch (e) { /* ignore */ }
27
+ }
28
+
29
+ function Section(props) {
30
+ return React.createElement('div', { style: { border: '1px solid var(--dsh-border, rgba(128,128,128,0.35))', borderRadius: '8px', padding: '12px 14px', marginBottom: '12px' } }, props.children)
31
+ }
32
+
33
+ function Pre(props) {
34
+ return React.createElement('pre', { style: { margin: '0', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px', lineHeight: '1.5', maxHeight: props.maxH || '320px', overflow: 'auto' } }, props.children)
35
+ }
36
+
37
+ function Row(props) {
38
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' } }, props.children)
39
+ }
40
+
41
+ function Badge(props) {
42
+ var ok = props.ok === true
43
+ return React.createElement('span', { style: { display: 'inline-block', padding: '1px 8px', borderRadius: '10px', fontSize: '11px', border: '1px solid ' + (ok ? 'rgba(70,160,90,0.7)' : 'rgba(190,80,80,0.7)'), color: ok ? 'rgba(70,160,90,1)' : 'rgba(190,80,80,1)' } }, props.children)
44
+ }
45
+
46
+ function Panel() {
47
+ var state = React.useState({ loading: true, error: null, data: null })
48
+ var setState = state[1]
49
+ state = state[0]
50
+ var sel = React.useState(rememberedWorkspace())
51
+ var selected = sel[0]
52
+ var setSelected = sel[1]
53
+ var tick = React.useState(0)
54
+ var refresh = function () { tick[1](tick[0] + 1) }
55
+ var tpl = React.useState(null)
56
+ var openTpl = tpl[0]
57
+ var setOpenTpl = tpl[1]
58
+
59
+ React.useEffect(function () {
60
+ var alive = true
61
+ setState({ loading: true, error: null, data: null })
62
+ loadPanel(selected).then(function (data) {
63
+ if (alive) setState({ loading: false, error: null, data: data })
64
+ }, function (e) {
65
+ if (alive) setState({ loading: false, error: String(e && e.message ? e.message : e), data: null })
66
+ })
67
+ return function () { alive = false }
68
+ }, [selected, tick[0]])
69
+
70
+ var box = { padding: '4px 2px', fontSize: '13px', color: 'inherit' }
71
+ var h2 = { margin: '0 0 10px 0', fontSize: '14px', fontWeight: 600 }
72
+ var h3 = { margin: '0 0 8px 0', fontSize: '12px', fontWeight: 600, opacity: 0.75 }
73
+ var btn = { padding: '3px 12px', fontSize: '12px', borderRadius: '6px', border: '1px solid var(--dsh-border, rgba(128,128,128,0.45))', background: 'transparent', color: 'inherit', cursor: 'pointer' }
74
+
75
+ if (state.loading && state.data === null) {
76
+ return React.createElement('div', { style: box }, '加载中…')
77
+ }
78
+ if (state.error !== null) {
79
+ return React.createElement('div', { style: box },
80
+ React.createElement('div', { style: { color: 'rgba(190,80,80,1)', marginBottom: '8px' } }, '加载失败: ' + state.error),
81
+ React.createElement('button', { style: btn, onClick: refresh }, '重试'))
82
+ }
83
+ var d = state.data || {}
84
+ var current = selected !== '' ? selected : (typeof d.workspace === 'string' ? d.workspace : '')
85
+
86
+ var cmdRows = (d.commands || []).map(function (c) {
87
+ return React.createElement('tr', { key: c.name },
88
+ React.createElement('td', { style: { padding: '3px 10px 3px 0', verticalAlign: 'top' } },
89
+ c.name,
90
+ c.description ? React.createElement('div', { style: { fontSize: '11px', opacity: 0.65, fontWeight: 400 } }, c.description) : null),
91
+ React.createElement('td', { style: { padding: '3px 10px 3px 0', fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px', verticalAlign: 'top' } }, c.file),
92
+ React.createElement('td', { style: { padding: '3px 10px 3px 0', verticalAlign: 'top' } }, c.timeoutMs === null ? '默认' : (c.timeoutMs / 1000 + 's')),
93
+ React.createElement('td', { style: { padding: '3px 0', verticalAlign: 'top' } }, React.createElement(Badge, { ok: c.exists }, c.exists ? '存在' : '缺失')))
94
+ })
95
+
96
+ var tplItems = (d.templates || []).map(function (t) {
97
+ var open = openTpl === t.name
98
+ return React.createElement('div', { key: t.name, style: { marginBottom: '6px' } },
99
+ React.createElement('button', { style: btn, onClick: function () { setOpenTpl(open ? null : t.name) } }, (open ? '▾ ' : '▸ ') + t.name),
100
+ open ? React.createElement('div', { style: { marginTop: '6px' } }, React.createElement(Pre, null, t.content)) : null)
101
+ })
102
+
103
+ return React.createElement('div', { style: box },
104
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' } },
105
+ React.createElement('h2', { style: h2 }, 'godot-dev-shell · 工作区配置'),
106
+ React.createElement('span', { style: { flex: 1 } }),
107
+ React.createElement('button', { style: btn, onClick: refresh }, '刷新')),
108
+ React.createElement(Section, null,
109
+ React.createElement(Row, null,
110
+ React.createElement('span', { style: h3 }, '工作区'),
111
+ React.createElement('select', {
112
+ value: current,
113
+ onChange: function (e) { setSelected(e.target.value); rememberWorkspace(e.target.value) },
114
+ style: { padding: '3px 8px', borderRadius: '6px', border: '1px solid var(--dsh-border, rgba(128,128,128,0.45))', background: 'transparent', color: 'inherit', fontSize: '12px' }
115
+ }, (d.workspaces || []).map(function (w) {
116
+ return React.createElement('option', { key: w.id || w.path, value: w.path }, (w.title || w.path) + (w.path ? ' — ' + w.path : '') + (w.live === true ? ' (活跃)' : ''))
117
+ }))),
118
+ React.createElement(Row, null,
119
+ React.createElement('span', { style: h3 }, 'Shell 执行器'),
120
+ React.createElement('code', { style: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: '12px' } }, String(d.pwshRunner || '?')))),
121
+ React.createElement(Section, null,
122
+ React.createElement('h3', { style: h3 }, '项目命令 (.dsh/godot-dev-shell.json 的 commands)'),
123
+ d.legacy === true
124
+ ? React.createElement('div', { style: { color: 'rgba(200,140,60,1)', marginBottom: '6px', fontSize: '12px' } }, '配置仍是旧版 modes/verifyDir 结构——请迁移到 commands(每条命令: file/description/workdir/timeoutMs)')
125
+ : null,
126
+ cmdRows.length > 0
127
+ ? React.createElement('table', { style: { borderCollapse: 'collapse', fontSize: '12px' } },
128
+ React.createElement('tbody', null, cmdRows))
129
+ : React.createElement('span', { style: { opacity: 0.7 } }, '(未配置——在工作区 .dsh/godot-dev-shell.json 的 commands 里声明,run_command 即可执行)')),
130
+ React.createElement(Section, null,
131
+ React.createElement('h3', { style: h3 }, '项目规则 (.dsh/godot-dev-shell.md)'),
132
+ d.rulesMd
133
+ ? React.createElement(Pre, { maxH: '420px' }, d.rulesMd)
134
+ : React.createElement('span', { style: { opacity: 0.7 } }, '(未创建——放一个即可让 AI 开工前自动读到项目规则/模板索引)')),
135
+ React.createElement(Section, null,
136
+ React.createElement('h3', { style: h3 }, 'GDScript 模板 (.dsh/templates/)'),
137
+ tplItems.length > 0 ? tplItems : React.createElement('span', { style: { opacity: 0.7 } }, '(无模板文件)')),
138
+ React.createElement(Section, null,
139
+ React.createElement('h3', { style: h3 }, '机器配置 (.dsh/godot-dev-shell.json)'),
140
+ d.configError
141
+ ? React.createElement('div', { style: { color: 'rgba(190,80,80,1)', marginBottom: '6px' } }, 'JSON 解析失败: ' + d.configError)
142
+ : null,
143
+ d.configRaw
144
+ ? React.createElement(Pre, null, d.configRaw)
145
+ : React.createElement('span', { style: { opacity: 0.7 } }, '(未创建——ps_script 可用;run_command 需要至少一条 commands 声明)')))
146
+ }
147
+
148
+ function apply(ctx) {
149
+ ctx.slots.inject('settings.plugins.tab', function () {
150
+ return ctx.slots.register(
151
+ { name: 'settings.plugins.tab', id: 'godot-dev-shell', order: 100, label: 'godot-dev-shell' },
152
+ function () { return React.createElement(Panel) }
153
+ )
154
+ })
155
+ }
156
+
157
+ exports.apply = apply
158
+ exports.inject = ['slots']
159
+ return module.exports
160
+ } })
161
+
162
+ // [end of dsh-godot-dev-shell client bundle]
package/lib/index.js ADDED
@@ -0,0 +1,712 @@
1
+ // dsh-godot-dev-shell host half.
2
+ //
3
+ // Minimal, project-neutral tools for every session on this profile:
4
+ // ps_script — execute a PowerShell script via a UTF-8(BOM) temp file
5
+ // (`pwsh 7 -NoProfile -ExecutionPolicy Bypass -File`), killing
6
+ // the multi-layer escaping / argv-limit / execution-policy /
7
+ // encoding problems at the architecture level.
8
+ // run_command — run a fixed command declared by the current workspace in
9
+ // .dsh/godot-dev-shell.json (`commands` map). Zero built-in
10
+ // project semantics: names, scripts, exit-code meanings, and
11
+ // rules all travel with each workspace.
12
+ //
13
+ // Plus the config-panel HTTP API consumed by the client half
14
+ // (Settings → Plugins → godot-dev-shell): GET /api/godot-dev-shell/panel.
15
+ //
16
+ // Machine-level facts (pwsh 7 discovery, sandbox escalation) live here.
17
+ // Project-level facts travel with each workspace instead:
18
+ // <workspace>/.dsh/godot-dev-shell.json — commands: { name: { file, description?, workdir?, timeoutMs? } }
19
+ // <workspace>/.dsh/godot-dev-shell.md — project rules / template index for the AI
20
+
21
+ import { lstatSync } from 'node:fs'
22
+ import { readFile, readdir, stat } from 'node:fs/promises'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import { URL } from 'node:url'
26
+
27
+ /** Cordis plugin name — must match the row name in cordis.patch.yml. */
28
+ export const name = 'dsh-godot-dev-shell'
29
+
30
+ /** Hard dependencies: tool registry, shell executor, filesystem provider. */
31
+ export const inject = ['tools', 'shell', 'fs']
32
+
33
+ const DEFAULT_SCRIPT_TIMEOUT = 180000
34
+ const DEFAULT_COMMAND_TIMEOUT = 120000
35
+ const MAX_SCRIPT_CHARS = 2 * 1024 * 1024
36
+ const ESCALATION_TARGETS = ['workspace-write', 'danger-full-access']
37
+ const WIDER_MODES = {
38
+ 'read-only': ['workspace-write', 'danger-full-access'],
39
+ 'workspace-write': ['danger-full-access'],
40
+ }
41
+ const PROLOGUE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)'
42
+ // NOTE: ps_script 的 execute 里临时脚本内容开头拼了一个字面 U+FEFF BOM 字符(单引号里那个不可见字符),
43
+ // 让 Windows PowerShell 5.1 把文件按 UTF-8 解析;编辑那一行时不要弄丢它。
44
+
45
+ // ───────────────────────── shared helpers ─────────────────────────
46
+
47
+ function renderStream(stream) {
48
+ if (stream === undefined || stream === null) return ''
49
+ if (!stream.truncated) return stream.text
50
+ return stream.text + '\n[output truncated; full output: ' + (stream.spillPath === undefined ? '(unavailable)' : stream.spillPath) + ']'
51
+ }
52
+
53
+ function renderResult(value) {
54
+ let body = renderStream(value.stdout)
55
+ const err = renderStream(value.stderr)
56
+ if (err.length > 0) {
57
+ if (body.length > 0 && !body.endsWith('\n')) body += '\n'
58
+ body += '[stderr]\n' + err
59
+ }
60
+ if (body.length === 0) body = '(no output)'
61
+ const markers = []
62
+ if (value.sandbox !== undefined) {
63
+ if (value.sandbox.runnerFailed === true) markers.push('[sandbox: the sandbox runner itself failed under ' + value.sandbox.mode + ' mode — the command did not run; this is a sandbox problem, not a command failure]')
64
+ else if (value.sandbox.denied === true) markers.push('[sandbox: file access denied under ' + value.sandbox.mode + ' mode]')
65
+ else if (value.sandbox.mode !== 'danger-full-access') {
66
+ const et = value.stderr === undefined ? '' : value.stderr.text
67
+ if (et.indexOf('failed to run') >= 0 || et.indexOf('The operation was canceled') >= 0) {
68
+ markers.push('[sandbox: native output capture is pipe-blocked under confined modes — retry this exact call once with sandbox_permissions="danger-full-access" + justification]')
69
+ }
70
+ }
71
+ }
72
+ if (value.timedOut === true) markers.push('[timed out after ' + value.timeoutMs + 'ms]')
73
+ if (value.signal !== null && value.signal !== undefined) markers.push('[killed by signal: ' + value.signal + ']')
74
+ else if (value.exitCode !== 0) markers.push('[exit code: ' + value.exitCode + ']')
75
+ if (markers.length === 0) return body
76
+ if (!body.endsWith('\n')) body += '\n'
77
+ return body + markers.join('\n')
78
+ }
79
+
80
+ function canonicalResult(result) {
81
+ const stream = (s) => ({ text: s.text, truncated: s.truncated, ...(s.spillPath !== undefined ? { spillPath: s.spillPath } : {}) })
82
+ const out = {
83
+ exitCode: result.exitCode,
84
+ signal: result.signal,
85
+ timedOut: result.timedOut,
86
+ aborted: result.aborted,
87
+ timeoutMs: result.timeoutMs,
88
+ stdout: stream(result.stdout),
89
+ stderr: stream(result.stderr),
90
+ }
91
+ if (result.sandbox !== undefined) {
92
+ out.sandbox = {
93
+ mode: result.sandbox.mode,
94
+ denied: result.sandbox.denied,
95
+ ...(result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}),
96
+ ...(result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}),
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
102
+ function errText(e) {
103
+ if (e !== undefined && e !== null && typeof e.message === 'string') return e.message
104
+ return String(e)
105
+ }
106
+
107
+ function validateEscalationArgs(sandboxPermissions, justification) {
108
+ if (sandboxPermissions !== undefined && justification === undefined) throw new Error('invalid escalation: sandbox_permissions requires a justification')
109
+ if (justification !== undefined && sandboxPermissions === undefined) throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
110
+ if (justification !== undefined && justification.trim().length === 0) throw new Error('invalid justification: expected a non-empty sentence')
111
+ }
112
+
113
+ function writeJson(res, status, body) {
114
+ const payload = JSON.stringify(body)
115
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
116
+ res.end(payload)
117
+ }
118
+
119
+ // ───────────────────── machine-level pwsh discovery ─────────────────────
120
+
121
+ let cachedRunner = null
122
+
123
+ function pwshCandidates() {
124
+ const out = []
125
+ const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files'
126
+ out.push(join(programFiles, 'PowerShell', '7', 'pwsh.exe'))
127
+ for (const entry of String(process.env.PATH ?? '').split(';')) {
128
+ const trimmed = entry.trim().replace(/^"|"$/g, '')
129
+ if (trimmed.length > 0) out.push(join(trimmed, 'pwsh.exe'))
130
+ }
131
+ out.push(join(homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps', 'pwsh.exe'))
132
+ return out
133
+ }
134
+
135
+ /** Preferred shell runner as a PS call-operator operand (`'C:\...\pwsh.exe'` or `powershell`). */
136
+ function shellRunnerQuoted() {
137
+ if (cachedRunner !== null) return cachedRunner
138
+ for (const candidate of pwshCandidates()) {
139
+ if (candidate.indexOf("'") >= 0) continue
140
+ try {
141
+ const st = lstatSync(candidate)
142
+ if (st.isFile() || st.isSymbolicLink()) {
143
+ cachedRunner = "'" + candidate + "'"
144
+ return cachedRunner
145
+ }
146
+ } catch { /* try next candidate */ }
147
+ }
148
+ cachedRunner = 'powershell'
149
+ return cachedRunner
150
+ }
151
+
152
+ // ───────────────────── registry-ready JSON schemas ─────────────────────
153
+
154
+ const OUTPUT_SCHEMA = {
155
+ type: 'object',
156
+ additionalProperties: false,
157
+ required: ['exitCode', 'signal', 'timedOut', 'aborted', 'timeoutMs', 'stdout', 'stderr'],
158
+ properties: {
159
+ exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
160
+ signal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
161
+ timedOut: { type: 'boolean' },
162
+ aborted: { type: 'boolean' },
163
+ timeoutMs: { type: 'number' },
164
+ stdout: {
165
+ type: 'object',
166
+ additionalProperties: false,
167
+ required: ['text', 'truncated'],
168
+ properties: {
169
+ text: { type: 'string' },
170
+ truncated: { type: 'boolean' },
171
+ spillPath: { type: 'string' },
172
+ },
173
+ },
174
+ stderr: {
175
+ type: 'object',
176
+ additionalProperties: false,
177
+ required: ['text', 'truncated'],
178
+ properties: {
179
+ text: { type: 'string' },
180
+ truncated: { type: 'boolean' },
181
+ spillPath: { type: 'string' },
182
+ },
183
+ },
184
+ sandbox: {
185
+ type: 'object',
186
+ additionalProperties: false,
187
+ required: ['mode', 'denied'],
188
+ properties: {
189
+ mode: { type: 'string' },
190
+ denied: { type: 'boolean' },
191
+ enforcement: { type: 'string' },
192
+ runnerFailed: { type: 'boolean' },
193
+ },
194
+ },
195
+ },
196
+ }
197
+
198
+ const ESCAL_PERMISSION_PARAM = {
199
+ type: 'string',
200
+ enum: ESCALATION_TARGETS,
201
+ description: '更宽的沙箱模式。仅在本调用刚因沙箱受限失败(如文件写入被拒、原生输出捕获管道被拦截)时作为一次性重试使用;需要 justification 并由用户审批。',
202
+ }
203
+ const JUSTIFICATION_PARAM = {
204
+ type: 'string',
205
+ description: '配合 sandbox_permissions 必填:一句话向用户说明为何这次调用需要更宽权限。',
206
+ }
207
+
208
+ const PS_SCRIPT_PARAMS = {
209
+ type: 'object',
210
+ properties: {
211
+ script: { type: 'string', description: '完整 PowerShell 脚本源码。原样写入临时文件执行,无需任何转义;多行直接写,换行就是换行。' },
212
+ description: { type: 'string', description: '5-10 个词的简短主动语态描述(显示在 UI),例如 "Run compile check"。' },
213
+ workdir: { type: 'string', description: '工作目录。绝对路径直接用;相对路径按会话工作区根解析。默认为会话工作区根。' },
214
+ timeoutMs: { type: 'number', description: '超时毫秒数,默认 180000;超时会终止整棵进程树并在结果中标记 [timed out ...]。' },
215
+ sandbox_permissions: ESCAL_PERMISSION_PARAM,
216
+ justification: JUSTIFICATION_PARAM,
217
+ },
218
+ required: ['script', 'description'],
219
+ }
220
+
221
+ const RUN_COMMAND_PARAMS = {
222
+ type: 'object',
223
+ properties: {
224
+ name: { type: 'string', description: '要运行的命令名(必须是项目 .dsh/godot-dev-shell.json 的 commands 里声明过的名字)。' },
225
+ description: { type: 'string', description: '5-10 个词的简短主动语态描述(显示在 UI),例如 "Run compile check"。' },
226
+ timeoutMs: { type: 'number', description: '超时毫秒数,覆盖命令声明的 timeoutMs;缺省用声明值或默认 120000。' },
227
+ sandbox_permissions: ESCAL_PERMISSION_PARAM,
228
+ justification: JUSTIFICATION_PARAM,
229
+ },
230
+ required: ['name', 'description'],
231
+ }
232
+
233
+ // ─────────────────────────────── plugin ───────────────────────────────
234
+
235
+ export function apply(ctx) {
236
+ const sandboxPolicy = ctx.get('sandboxPolicy')
237
+ const shellEnv = ctx.get('shellEnv')
238
+ const approval = ctx.get('approval')
239
+ let seq = 0
240
+
241
+ function workspaceRoot(exec) {
242
+ try {
243
+ if (exec !== undefined && exec !== null && exec.agent !== undefined) {
244
+ const cwd = exec.agent.session.header.cwd
245
+ if (typeof cwd === 'string' && cwd.length > 1 && cwd.indexOf("'") < 0) return cwd
246
+ }
247
+ } catch { /* fall through */ }
248
+ try {
249
+ if (sandboxPolicy !== undefined && typeof sandboxPolicy.workspaceRoot === 'string' && sandboxPolicy.workspaceRoot.length > 1 && sandboxPolicy.workspaceRoot.indexOf("'") < 0) {
250
+ return sandboxPolicy.workspaceRoot
251
+ }
252
+ } catch { /* fall through */ }
253
+ return process.cwd()
254
+ }
255
+
256
+ function resolveWorkdir(modelWorkdir, exec) {
257
+ const root = workspaceRoot(exec).replace(/[\\/]+$/, '')
258
+ if (modelWorkdir === undefined) return root
259
+ let w = String(modelWorkdir)
260
+ if (!/^[A-Za-z]:[\\/]/.test(w) && !/^\\\\/.test(w)) w = root + '\\' + w.replace(/^[\\/]+/, '')
261
+ return w
262
+ }
263
+
264
+ function resolveStanding(exec) {
265
+ if (sandboxPolicy === undefined) return undefined
266
+ try {
267
+ return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
268
+ } catch {
269
+ return undefined
270
+ }
271
+ }
272
+
273
+ async function approveEscalation(toolName, mode, justification, exec, standing) {
274
+ const effectiveMode = standing !== undefined ? standing.mode : 'read-only'
275
+ const wider = WIDER_MODES[effectiveMode] || []
276
+ if (wider.indexOf(mode) < 0) throw new Error('sandbox escalation to "' + mode + '" is not strictly wider than this call\'s current "' + effectiveMode + '" mode')
277
+ if (approval === undefined) throw new Error('sandbox escalation to "' + mode + '" requires approval, but no approval service is composed')
278
+ if (exec.agent === undefined) throw new Error('sandbox escalation to "' + mode + '" requires approval, but the call has no agent to route it through')
279
+ const outcome = await approval.request({
280
+ agent: exec.agent,
281
+ toolName: toolName,
282
+ callId: exec.callId,
283
+ reason: 'escalate sandbox to ' + mode + ': ' + justification,
284
+ signal: exec.signal,
285
+ })
286
+ if (outcome === 'allowed-once') return mode
287
+ if (outcome === 'rejected') throw new Error('the user rejected escalating this call to "' + mode + '"')
288
+ if (outcome === 'cancelled') throw new Error('approval for escalating to "' + mode + '" was cancelled')
289
+ throw new Error('sandbox escalation to "' + mode + '" requires approval, but no approval channel is available')
290
+ }
291
+
292
+ async function resolveCallPolicy(toolName, args, exec) {
293
+ const standing = resolveStanding(exec)
294
+ validateEscalationArgs(args.sandbox_permissions, args.justification)
295
+ if (args.sandbox_permissions === undefined) return standing
296
+ if (sandboxPolicy === undefined) throw new Error('sandbox_permissions is not available in this composition (no sandbox policy service)')
297
+ const granted = await approveEscalation(toolName, args.sandbox_permissions, args.justification, exec, standing)
298
+ return standing === undefined ? { mode: granted, workspaceRoot: workspaceRoot(exec) } : { ...standing, mode: granted }
299
+ }
300
+
301
+ async function runShell(command, workdir, timeoutMs, exec, policy) {
302
+ const request = { command: command, timeoutMs: timeoutMs, signal: exec.signal }
303
+ if (workdir !== undefined) request.workdir = workdir
304
+ if (shellEnv !== undefined) {
305
+ try { request.dshEnv = shellEnv.collect(exec) } catch { /* optional managed env */ }
306
+ }
307
+ if (policy !== undefined) request.sandboxPolicy = policy
308
+ return await ctx.shell.run(ctx.shell.resolve(request))
309
+ }
310
+
311
+ function validateCommon(args) {
312
+ if (args === undefined || args === null || typeof args !== 'object') throw new Error('invalid arguments')
313
+ if (typeof args.description !== 'string' || args.description.trim().length === 0) throw new Error('invalid description: expected a non-empty string (5-10 words, shown in the UI)')
314
+ if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== 'number' || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new Error('invalid timeoutMs: expected a positive number')
315
+ }
316
+
317
+ async function loadProjectConfig(exec) {
318
+ const cfgPath = join(workspaceRoot(exec), '.dsh', 'godot-dev-shell.json')
319
+ let info
320
+ try {
321
+ info = await ctx.fs.lstat(cfgPath)
322
+ } catch {
323
+ return {}
324
+ }
325
+ if (info === undefined) return {}
326
+ const target = await ctx.fs.resolve(cfgPath)
327
+ const text = await ctx.fs.readText(target)
328
+ try {
329
+ const parsed = JSON.parse(text)
330
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
331
+ } catch (e) {
332
+ throw new Error('godot-dev-shell: 项目配置解析失败 ' + cfgPath + ': ' + errText(e))
333
+ }
334
+ }
335
+
336
+ function configCommands(cfg) {
337
+ if (cfg === null || typeof cfg !== 'object') return undefined
338
+ if (cfg.commands === null || typeof cfg.commands !== 'object') return undefined
339
+ return cfg.commands
340
+ }
341
+
342
+ function legacyConfig(cfg) {
343
+ if (cfg === null || typeof cfg !== 'object') return false
344
+ return configCommands(cfg) === undefined && (cfg.modes !== undefined || cfg.verifyDir !== undefined)
345
+ }
346
+
347
+ function absoluteUnderRoot(root, rel) {
348
+ if (/^[A-Za-z]:[\\/]/.test(rel) || /^\\\\/.test(rel)) return rel
349
+ return root + '\\' + rel.replace(/\//g, '\\').replace(/^[\\]+/, '')
350
+ }
351
+
352
+ // Project-neutral prompt pointers; project specifics live in each workspace.
353
+ try {
354
+ const systemPrompt = ctx.get('systemPrompt')
355
+ if (systemPrompt !== undefined) {
356
+ ctx.effect(() => systemPrompt.section({
357
+ name: 'tool:ps_script',
358
+ order: 106,
359
+ text: 'PowerShell 多行脚本(超过一行)一律用 ps_script 工具(脚本落临时文件执行,零转义层),不要内联进 pwsh 的 command;含中文/引号/反引号/${} 的命令同理。受限沙箱会拦截捕获原生命令输出的管道($x = & exe 静默变空,2>&1 可能报 failed to run):需要捕获输出时优先用 cmd /c 重定向到工作区内文件再读,或用 sandbox_permissions 升级重试。项目声明的固化命令(验证/构建/统计等)一律用 run_command(清单由项目 .dsh/godot-dev-shell.json 的 commands 声明,各命令语义见其 description 与项目 .dsh/godot-dev-shell.md;做该项目工作前先读规则)。',
360
+ }))
361
+ }
362
+ } catch (e) {
363
+ console.error('dsh-godot-dev-shell: systemPrompt section unavailable: ' + errText(e))
364
+ }
365
+
366
+ // ── ps_script ──
367
+ ctx.tools.register({
368
+ name: 'ps_script',
369
+ description: '以「临时脚本文件 + pwsh 7 优先(自动探测安装路径,未装则回退 Windows PowerShell 5.1)+ -NoProfile -ExecutionPolicy Bypass -File」方式执行 PowerShell 脚本,从架构上消除多层转义、命令行长度限制、执行策略拦截和中文编码问题。script 参数就是纯 PowerShell 源码:原样写入 UTF-8(带 BOM)临时文件后执行,不经任何命令行转义层,引号/反引号/${}/大括号/中文任意使用。多行脚本、含特殊字符的脚本、需要执行其他 .ps1 的脚本一律优先用本工具;单行简单命令用 pwsh 即可。返回结构化结果:stdout 与 stderr 分离、精确退出码(exit N 原样传播)、超时/信号标记、截断时附完整输出文件路径。脚本在会话工作区 .dsh/tmp/ 下生成,执行后自动删除。执行受文件沙箱约束(覆盖整棵进程树,工作区外写入会被拒绝)。重要限制:受限沙箱模式下,PowerShell 捕获原生命令输出所需的管道会被拦截($x = & exe 赋值捕获会静默变空,带 2>&1 的原生命令可能报 failed to run)——纯透传输出(不赋值不重定向)不受影响;需要捕获输出时优先用 cmd /c \'"exe" args > "输出文件" 2>&1\' 重定向到工作区内文件再 Get-Content,或用 sandbox_permissions="danger-full-access" + justification 重试本调用(审批向用户请求)。另注意:不要设 $ErrorActionPreference=\'Stop\' 再配合原生命令 2>&1(Godot 的 WARNING 会变 ErrorRecord 直接中断脚本)。常用配方:执行 .ps1 → & \'D:\\path\\x.ps1\';跑批处理 → & \'D:\\path\\to\\x.bat\'(必要时配合 workdir);需要透传退出码时结尾加 exit $LASTEXITCODE。',
370
+ parameters: PS_SCRIPT_PARAMS,
371
+ output: {
372
+ schema: OUTPUT_SCHEMA,
373
+ render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
374
+ },
375
+ presentCall: (args) => {
376
+ try {
377
+ const a = args !== undefined && args !== null && typeof args === 'object' ? args : {}
378
+ const first = typeof a.script === 'string' && a.script.length > 0 ? a.script.split('\n')[0].slice(0, 100) : 'ps_script'
379
+ const desc = typeof a.description === 'string' && a.description.length > 0 ? a.description : 'PowerShell script file execution'
380
+ return { card: 'terminal', title: first, description: desc }
381
+ } catch {
382
+ return undefined
383
+ }
384
+ },
385
+ async execute(args, exec) {
386
+ validateCommon(args)
387
+ if (typeof args.script !== 'string' || args.script.trim().length === 0) throw new Error('invalid script: expected a non-empty PowerShell source string')
388
+ if (args.script.indexOf(String.fromCharCode(0)) >= 0) throw new Error('invalid script: contains NUL characters')
389
+ if (args.script.length > MAX_SCRIPT_CHARS) throw new Error('invalid script: exceeds ' + MAX_SCRIPT_CHARS + ' characters')
390
+ if (args.workdir !== undefined && typeof args.workdir !== 'string') throw new Error('invalid workdir: expected a string')
391
+ const standing = resolveStanding(exec)
392
+ const root = workspaceRoot(exec).replace(/[\\/]+$/, '')
393
+ const tmpDir = join(root, '.dsh', 'tmp')
394
+ const fileName = 'gsh_' + Date.now().toString(36) + '_' + (seq = seq + 1) + '.ps1'
395
+ const scriptPath = join(tmpDir, fileName)
396
+ const content = '' + PROLOGUE + '\n' + args.script + (args.script.endsWith('\n') ? '' : '\n')
397
+ try {
398
+ const target = await ctx.fs.resolve(scriptPath)
399
+ await ctx.fs.writeText(target, content, undefined, undefined, standing)
400
+ } catch (e) {
401
+ throw new Error('ps_script: writing temp script ' + scriptPath + ' failed: ' + errText(e) + ' — the file sandbox may have denied it; this tool needs workspace-write')
402
+ }
403
+ const sweep = "Get-ChildItem -LiteralPath '" + tmpDir + "' -Filter 'gsh_*.ps1' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTimeUtc -lt [datetime]::UtcNow.AddHours(-1) } | Remove-Item -ErrorAction SilentlyContinue"
404
+ const runner = shellRunnerQuoted()
405
+ const runLine = '& ' + runner + " -NoProfile -ExecutionPolicy Bypass -File '" + scriptPath + "'"
406
+ const cleanupLine = "Remove-Item -LiteralPath '" + scriptPath + "' -ErrorAction SilentlyContinue"
407
+ const command = sweep + '; ' + runLine + '; $gsh_ec = $LASTEXITCODE; ' + cleanupLine + '; if ($null -eq $gsh_ec) { $gsh_ec = 1 }; exit $gsh_ec'
408
+ const policy = await resolveCallPolicy('ps_script', args, exec)
409
+ const result = await runShell(command, resolveWorkdir(args.workdir, exec), args.timeoutMs === undefined ? DEFAULT_SCRIPT_TIMEOUT : args.timeoutMs, exec, policy)
410
+ if (result.aborted) throw new Error('tool call aborted')
411
+ return canonicalResult(result)
412
+ },
413
+ })
414
+
415
+ // ── run_command ──
416
+ ctx.tools.register({
417
+ name: 'run_command',
418
+ description: '运行当前工作区在 .dsh/godot-dev-shell.json 的 commands 里声明的固化命令(验证脚本/批处理/可执行文件)。命令清单、各命令的语义与退出码约定完全由项目定义——见各命令的 description 与项目规则 .dsh/godot-dev-shell.md。执行机制与 ps_script 相同:.ps1 用 pwsh 7 优先的 -NoProfile -ExecutionPolicy Bypass -File;.bat/.cmd/.exe 用调用运算符;受沙箱约束并支持 sandbox_permissions 升级;返回 stdout/stderr 分离 + 精确退出码。参数化变体(如不同帧数)用 ps_script 直接带参数调用脚本。未声明的命令名会报错并列出可用清单。',
419
+ parameters: RUN_COMMAND_PARAMS,
420
+ output: {
421
+ schema: OUTPUT_SCHEMA,
422
+ render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
423
+ },
424
+ presentCall: (args) => {
425
+ try {
426
+ const a = args !== undefined && args !== null && typeof args === 'object' ? args : {}
427
+ const n = typeof a.name === 'string' && a.name.length > 0 ? a.name : 'command'
428
+ const desc = typeof a.description === 'string' && a.description.length > 0 ? a.description : 'Project command'
429
+ return { card: 'terminal', title: n, description: desc }
430
+ } catch {
431
+ return undefined
432
+ }
433
+ },
434
+ async execute(args, exec) {
435
+ validateCommon(args)
436
+ if (typeof args.name !== 'string' || args.name.length === 0) throw new Error('invalid name: expected a non-empty command name')
437
+ const cfg = await loadProjectConfig(exec)
438
+ const commands = configCommands(cfg)
439
+ if (commands === undefined) {
440
+ if (legacyConfig(cfg)) throw new Error('run_command: 项目配置仍是旧版 modes/verifyDir 结构——请迁移到 commands(每条: file/description/workdir/timeoutMs),见 .dsh/godot-dev-shell.json')
441
+ throw new Error('run_command: 本项目未声明任何命令——在工作区 .dsh/godot-dev-shell.json 的 commands 里声明(键为命令名,值含 file 字段)')
442
+ }
443
+ const cmd = commands[args.name]
444
+ if (cmd === null || typeof cmd !== 'object' || typeof cmd.file !== 'string' || cmd.file.length === 0) {
445
+ throw new Error('unknown command: ' + JSON.stringify(args.name) + ' — 可用命令: ' + Object.keys(commands).join('/') + ' (项目 .dsh/godot-dev-shell.json 的 commands)')
446
+ }
447
+ const root = workspaceRoot(exec).replace(/[\\/]+$/, '')
448
+ const fileAbs = absoluteUnderRoot(root, cmd.file)
449
+ if (fileAbs.indexOf("'") >= 0) throw new Error('run_command: 命令文件路径含单引号,拒绝执行: ' + fileAbs)
450
+ try {
451
+ const info = await ctx.fs.lstat(fileAbs)
452
+ if (info === undefined) throw new Error('file not found')
453
+ } catch (e) {
454
+ throw new Error('run_command: 命令文件不存在: ' + fileAbs + ' — 检查项目 .dsh/godot-dev-shell.json 的 commands.' + args.name + '.file (' + errText(e) + ')')
455
+ }
456
+ const ext = fileAbs.substring(fileAbs.lastIndexOf('.')).toLowerCase()
457
+ const runner = shellRunnerQuoted()
458
+ let runLine
459
+ if (ext === '.ps1') {
460
+ runLine = '& ' + runner + " -NoProfile -ExecutionPolicy Bypass -File '" + fileAbs + "'"
461
+ } else if (ext === '.bat' || ext === '.cmd' || ext === '.exe') {
462
+ runLine = "& '" + fileAbs + "'"
463
+ } else {
464
+ throw new Error('run_command: 不支持的命令文件类型 "' + ext + '"(支持 .ps1/.bat/.cmd/.exe): ' + fileAbs)
465
+ }
466
+ const command = runLine + '; exit $LASTEXITCODE'
467
+ let workdir
468
+ if (typeof cmd.workdir === 'string' && cmd.workdir.length > 0) {
469
+ workdir = absoluteUnderRoot(root, cmd.workdir)
470
+ } else {
471
+ workdir = root
472
+ }
473
+ const timeout = args.timeoutMs !== undefined ? args.timeoutMs : (typeof cmd.timeoutMs === 'number' && Number.isFinite(cmd.timeoutMs) && cmd.timeoutMs > 0 ? cmd.timeoutMs : DEFAULT_COMMAND_TIMEOUT)
474
+ const policy = await resolveCallPolicy('run_command', args, exec)
475
+ const result = await runShell(command, workdir, timeout, exec, policy)
476
+ if (result.aborted) throw new Error('tool call aborted')
477
+ return canonicalResult(result)
478
+ },
479
+ })
480
+
481
+ // ── add_command ──
482
+ ctx.tools.register({
483
+ name: 'add_command',
484
+ description: '向当前工作区注册或更新一条 run_command 命令(写入 .dsh/godot-dev-shell.json 的 commands;重名覆盖为更新)。完整工作流:先用 write/ps_script 把脚本写好(建议放 .dsh/scripts/)→ 用本工具注册(描述里写清语义与退出码约定)→ 需要时更新 .dsh/godot-dev-shell.md 的命令说明 → run_command 验证。',
485
+ parameters: {
486
+ type: 'object',
487
+ properties: {
488
+ name: { type: 'string', description: '命令名:字母开头,仅字母/数字/下划线/连字符,≤64 字符;run_command 按此名执行。' },
489
+ file: { type: 'string', description: '命令文件路径(相对工作区根或绝对);须已存在,支持 .ps1/.bat/.cmd/.exe。' },
490
+ description: { type: 'string', description: '命令语义说明(显示在配置面板,并指导模型正确使用;建议写清退出码约定)。' },
491
+ workdir: { type: 'string', description: '运行时工作目录(相对工作区根或绝对);缺省为工作区根。' },
492
+ timeoutMs: { type: 'number', description: '超时毫秒数;缺省用 run_command 默认 120000。' },
493
+ },
494
+ required: ['name', 'file'],
495
+ },
496
+ output: {
497
+ schema: {
498
+ type: 'object',
499
+ additionalProperties: false,
500
+ required: ['name', 'file', 'created', 'commands'],
501
+ properties: {
502
+ name: { type: 'string' },
503
+ file: { type: 'string' },
504
+ created: { type: 'boolean' },
505
+ commands: { type: 'array', items: { type: 'string' } },
506
+ },
507
+ },
508
+ render: (_args, value) => [{ type: 'text', text: (value.created ? '已注册命令 ' : '已更新命令 ') + value.name + ' → ' + value.file + '\n当前命令清单: ' + value.commands.join('/') }],
509
+ },
510
+ presentCall: (args) => {
511
+ try {
512
+ const a = args !== undefined && args !== null && typeof args === 'object' ? args : {}
513
+ const n = typeof a.name === 'string' && a.name.length > 0 ? a.name : 'add_command'
514
+ const desc = typeof a.description === 'string' && a.description.length > 0 ? a.description : 'Register project command'
515
+ return { card: 'terminal', title: n, description: desc }
516
+ } catch {
517
+ return undefined
518
+ }
519
+ },
520
+ async execute(args, exec) {
521
+ if (args === undefined || args === null || typeof args !== 'object') throw new Error('invalid arguments')
522
+ if (typeof args.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(args.name)) throw new Error('invalid name: expected a letter-starting identifier (letters/digits/_/-, max 64 chars)')
523
+ if (typeof args.file !== 'string' || args.file.length === 0) throw new Error('invalid file: expected a non-empty path')
524
+ if (args.workdir !== undefined && typeof args.workdir !== 'string') throw new Error('invalid workdir: expected a string')
525
+ if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== 'number' || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new Error('invalid timeoutMs: expected a positive number')
526
+ const root = workspaceRoot(exec).replace(/[\\/]+$/, '')
527
+ const fileAbs = absoluteUnderRoot(root, args.file)
528
+ if (fileAbs.indexOf("'") >= 0) throw new Error('add_command: 命令文件路径含单引号,拒绝: ' + fileAbs)
529
+ try {
530
+ const info = await ctx.fs.lstat(fileAbs)
531
+ if (info === undefined) throw new Error('file not found')
532
+ } catch (e) {
533
+ throw new Error('add_command: 命令文件不存在: ' + fileAbs + ' — 先把脚本写好(建议 .dsh/scripts/)再注册 (' + errText(e) + ')')
534
+ }
535
+ const ext = fileAbs.substring(fileAbs.lastIndexOf('.')).toLowerCase()
536
+ if (ext !== '.ps1' && ext !== '.bat' && ext !== '.cmd' && ext !== '.exe') {
537
+ throw new Error('add_command: 不支持的命令文件类型 "' + ext + '"(支持 .ps1/.bat/.cmd/.exe): ' + fileAbs)
538
+ }
539
+ const standing = resolveStanding(exec)
540
+ const cfgPath = join(root, '.dsh', 'godot-dev-shell.json')
541
+ let cfgRaw = null
542
+ try {
543
+ cfgRaw = await readFile(cfgPath, 'utf8')
544
+ } catch { cfgRaw = null }
545
+ let cfg = {}
546
+ if (cfgRaw !== null) {
547
+ try {
548
+ const parsed = JSON.parse(cfgRaw)
549
+ if (parsed !== null && typeof parsed === 'object') cfg = parsed
550
+ } catch (e) {
551
+ throw new Error('add_command: 项目配置解析失败 ' + cfgPath + ': ' + errText(e))
552
+ }
553
+ }
554
+ if (legacyConfig(cfg)) {
555
+ throw new Error('add_command: 项目配置仍是旧版 modes/verifyDir 结构——请先迁移到 commands 再添加')
556
+ }
557
+ if (cfg.commands === null || typeof cfg.commands !== 'object' || Array.isArray(cfg.commands)) cfg.commands = {}
558
+ const existed = Object.prototype.hasOwnProperty.call(cfg.commands, args.name)
559
+ cfg.commands[args.name] = {
560
+ file: args.file,
561
+ ...(typeof args.description === 'string' && args.description.length > 0 ? { description: args.description } : {}),
562
+ ...(typeof args.workdir === 'string' && args.workdir.length > 0 ? { workdir: args.workdir } : {}),
563
+ ...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
564
+ }
565
+ try {
566
+ const target = await ctx.fs.resolve(cfgPath)
567
+ await ctx.fs.writeText(target, JSON.stringify(cfg, null, 2) + '\n', undefined, undefined, standing)
568
+ } catch (e) {
569
+ throw new Error('add_command: 写入配置失败 ' + cfgPath + ': ' + errText(e))
570
+ }
571
+ return { name: args.name, file: args.file, created: !existed, commands: Object.keys(cfg.commands) }
572
+ },
573
+ })
574
+
575
+ // ── config panel API (GET /api/godot-dev-shell/panel?workspace=<path>) ──
576
+
577
+ async function gatherPanelData(requested) {
578
+ const registry = ctx.get('workspaceRegistry')
579
+ const workspaces = []
580
+ if (registry !== undefined) {
581
+ try {
582
+ const list = await registry.list()
583
+ if (Array.isArray(list)) {
584
+ for (const w of list) {
585
+ if (w === null || typeof w !== 'object') continue
586
+ workspaces.push({
587
+ id: typeof w.id === 'string' ? w.id : String(w.id === undefined ? '' : w.id),
588
+ path: typeof w.path === 'string' ? w.path : '',
589
+ title: typeof w.title === 'string' ? w.title : '',
590
+ live: false,
591
+ })
592
+ }
593
+ }
594
+ } catch { /* empty workspace list */ }
595
+ }
596
+ // 有活跃会话的工作区排前并标记(当前会话的工作区自然成为默认选中)
597
+ try {
598
+ const sessionsSvc = ctx.get('sessions')
599
+ if (sessionsSvc !== undefined) {
600
+ const live = sessionsSvc.list()
601
+ if (Array.isArray(live)) {
602
+ const cwds = []
603
+ for (const s of live) {
604
+ try {
605
+ const c = s.header.cwd
606
+ if (typeof c === 'string' && c.length > 1) cwds.push(c.replace(/[\\/]+$/, '').toLowerCase())
607
+ } catch { /* skip session */ }
608
+ }
609
+ if (cwds.length > 0) {
610
+ for (const w of workspaces) {
611
+ if (cwds.indexOf(String(w.path || '').replace(/[\\/]+$/, '').toLowerCase()) >= 0) w.live = true
612
+ }
613
+ workspaces.sort((a, b) => (a.live === true ? 0 : 1) - (b.live === true ? 0 : 1))
614
+ }
615
+ }
616
+ }
617
+ } catch { /* keep registry order */ }
618
+ let selected = typeof requested === 'string' && requested.length > 0 ? requested : ''
619
+ if (selected === '' && workspaces.length > 0) selected = workspaces[0].path
620
+ if (selected === '' || selected.indexOf("'") >= 0) {
621
+ return { workspaces, workspace: '', pwshRunner: 'pwsh (unresolved)', configRaw: '', configError: null, rulesMd: '', templates: [], commands: [], legacy: false, note: 'no workspace selected' }
622
+ }
623
+ const root = selected.replace(/[\\/]+$/, '')
624
+ const readIf = async (p) => {
625
+ try { return await readFile(p, 'utf8') } catch { return null }
626
+ }
627
+ const cfgRaw = await readIf(join(root, '.dsh', 'godot-dev-shell.json'))
628
+ let cfg = {}
629
+ let cfgError = null
630
+ if (cfgRaw !== null) {
631
+ try {
632
+ const parsed = JSON.parse(cfgRaw)
633
+ if (parsed !== null && typeof parsed === 'object') cfg = parsed
634
+ } catch (e) { cfgError = errText(e) }
635
+ }
636
+ const commands = configCommands(cfg)
637
+ const commandRows = []
638
+ if (commands !== undefined) {
639
+ for (const key of Object.keys(commands)) {
640
+ const c = commands[key]
641
+ if (c === null || typeof c !== 'object' || typeof c.file !== 'string' || c.file.length === 0) continue
642
+ const fileAbs = /^[A-Za-z]:[\\/]/.test(c.file) ? c.file : join(root, c.file)
643
+ let exists = false
644
+ try {
645
+ const st = await stat(fileAbs)
646
+ exists = st.isFile()
647
+ } catch { exists = false }
648
+ commandRows.push({
649
+ name: key,
650
+ description: typeof c.description === 'string' ? c.description : '',
651
+ file: c.file,
652
+ workdir: typeof c.workdir === 'string' ? c.workdir : '',
653
+ timeoutMs: typeof c.timeoutMs === 'number' && Number.isFinite(c.timeoutMs) ? c.timeoutMs : null,
654
+ exists,
655
+ })
656
+ }
657
+ }
658
+ const rulesMd = await readIf(join(root, '.dsh', 'godot-dev-shell.md'))
659
+ const templates = []
660
+ try {
661
+ const entries = await readdir(join(root, '.dsh', 'templates'), { withFileTypes: true })
662
+ for (const ent of entries) {
663
+ if (!ent.isFile()) continue
664
+ const name = ent.name
665
+ if (name === '' || name.indexOf("'") >= 0) continue
666
+ const content = await readIf(join(root, '.dsh', 'templates', name))
667
+ if (content !== null && content.length <= 65536) templates.push({ name, content })
668
+ }
669
+ } catch { /* no templates dir */ }
670
+ return {
671
+ workspaces,
672
+ workspace: root,
673
+ pwshRunner: shellRunnerQuoted(),
674
+ configRaw: cfgRaw === null ? '' : cfgRaw,
675
+ configError: cfgError,
676
+ rulesMd: rulesMd === null ? '' : rulesMd,
677
+ templates,
678
+ commands: commandRows,
679
+ legacy: legacyConfig(cfg),
680
+ }
681
+ }
682
+
683
+ try {
684
+ const webServer = ctx.get('webServer')
685
+ if (webServer !== undefined) {
686
+ ctx.effect(() => webServer.register({
687
+ kind: 'prefix',
688
+ path: '/api/godot-dev-shell/panel',
689
+ handler: async (req, res) => {
690
+ if (req.method !== 'GET') {
691
+ res.writeHead(405, { allow: 'GET' })
692
+ res.end('method not allowed')
693
+ return
694
+ }
695
+ try {
696
+ const url = new URL(req.url, 'http://localhost')
697
+ const data = await gatherPanelData(url.searchParams.get('workspace') ?? '')
698
+ writeJson(res, 200, data)
699
+ } catch (e) {
700
+ writeJson(res, 500, { error: errText(e) })
701
+ }
702
+ },
703
+ }))
704
+ } else {
705
+ console.error('dsh-godot-dev-shell: webServer unavailable — config panel API not registered')
706
+ }
707
+ } catch (e) {
708
+ console.error('dsh-godot-dev-shell: panel route registration failed: ' + errText(e))
709
+ }
710
+
711
+ console.log('dsh-godot-dev-shell: registered ps_script + run_command + add_command + panel API (pwsh runner: ' + shellRunnerQuoted() + ')')
712
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "dsh-godot-dev-shell",
3
+ "version": "2.1.0",
4
+ "description": "DSH bundle(项目无关的最小工具集):ps_script(PowerShell 脚本落临时文件执行,消除多层转义/编码/执行策略/退出码问题)+ run_command(运行工作区 .dsh/godot-dev-shell.json 声明的固化命令)+ 设置面板(工作区命令/规则/模板可视化)。零内置项目语义:命令清单、退出码约定、规则全部跟项目走。",
5
+ "keywords": [
6
+ "deepseek",
7
+ "harness",
8
+ "dsh",
9
+ "cordis",
10
+ "plugin",
11
+ "powershell",
12
+ "godot"
13
+ ],
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "cordis.patch.yml",
24
+ "README.md"
25
+ ],
26
+ "license": "MIT",
27
+ "dsh": {
28
+ "client": {
29
+ "inject": [
30
+ "@deepseek-ai/dsh-client-runtime"
31
+ ],
32
+ "platform": "web"
33
+ },
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ }
37
+ }
38
+ }