dsh-plugin-dynamic-competition-four-images 1.1.0 → 1.2.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,86 +2,77 @@
2
2
 
3
3
  基于动态竞争理论构建的 DSH(DeepSeek Harness)插件,经过以「四象模型」为框架的改进,提供六维动态竞争评分能力。
4
4
 
5
- **自 1.1.0 起,技能以「工作区技能」交付,不再写入全局技能目录。**
5
+ **自 1.2.0 起:插件全局安装,插件内的技能由用户按工作区选择性添加**(设置 → 插件 → 四象模型),技能只写入被选择的工作区,不会成为全局技能。
6
6
 
7
7
  ## ✨ 核心能力
8
8
 
9
9
  - 六维动态竞争评分(主 / 环 / 位 / 行 / 才 / 心)。
10
10
  - AMC 张力分析、多点克制逻辑与十三项自检机制。
11
- - 技能正文:`.dsh/skills/dynamic-competition-analysis-four-images-model/SKILL.md`。
11
+ - 技能正文:包内 `skills/dynamic-competition-analysis-four-images-model/SKILL.md`。
12
12
 
13
- ## 🗂️ 工作区技能的分层
14
-
15
- DSH 的技能来自两级目录,本插件只使用工作区一级:
16
-
17
- | 层级 | 目录 | 来源标记 | 本插件的做法 |
18
- |---|---|---|---|
19
- | 全局技能 | `<DSH_HOME>/skills`(如 `~/.dsh/skills`) | `user-dsh` | **不再写入**,也不注册全局技能 |
20
- | 工作区技能 | `<项目根>/.dsh/skills` 或 `<项目根>/.agents/skills` | `project-dsh` / `project-agents` | 技能本体随包发布于此;插件也只在配置的工作区提供它 |
21
-
22
- 项目根 = 最近的含 `.git` 的祖先目录;没有 `.git` 时取会话工作目录。
23
-
24
- ## 🚀 安装与配置
13
+ ## 🚀 安装
25
14
 
26
15
  ### 方式一:通过包名安装(推荐)
27
16
 
28
- 1. 在 DSH 插件页面点击 **「+ 添加插件」**,输入包名 `dsh-plugin-dynamic-competition-four-images` 并安装、启用。
29
- 2. 在 profile 的 `cordis.patch.yml` 中把插件行限制到指定工作区:
30
-
31
- ```yaml
32
- - id: dynamic-competition-four-images
33
- config:
34
- workspaces:
35
- - D:\Path\To\Your\Workspace # 绝对路径;可写多条
36
- # - D:\Another\Workspace
37
- ```
38
-
39
- 3. 重启 DSH 生效。未配置 `workspaces` 时插件不提供技能(工作区里自带的 `.dsh/skills` 副本仍会被 DSH 原生发现)。
17
+ 1. 在 DSH 插件页面点击 **「+ 添加插件」**,输入包名 `dsh-plugin-dynamic-competition-four-images`,安装并启用。
18
+ 2. 退出并重启 DSH(宿主半边的技能桥在启动时注册)。
19
+ 3. 安装插件**不会**把技能放进任何工作区,需要按下一节手动添加。
40
20
 
41
21
  ### 方式二:通过自定义插件市场安装
42
22
 
43
- 1. 在 DSH 中开启「自定义插件市场」。
44
- 2. 进入「发现」页面,添加索引源 URL:
23
+ 1. 开启「自定义插件市场」,添加索引源 URL:
45
24
  `https://rootandsource.github.io/dsh-plugin-dynamic-competition-four-images/plugin-index/index.json`
46
- 3. 搜索「动态竞争」或「四象」,点击卡片安装,再按方式一的第 2、3 步配置工作区。
25
+ 2. 搜索「动态竞争」或「四象」,点击卡片安装并启用,然后重启 DSH。
47
26
 
48
- ### 配置项
27
+ ## 🧩 把技能添加到工作区(UI)
49
28
 
50
- | 字段 | 类型 | 默认 | 说明 |
51
- |---|---|---|---|
52
- | `workspaces` | `string[]` | `[]` | 允许使用该技能的工作区绝对路径。会话工作目录位于其中时,插件才提供技能;相对路径与空值会被忽略。 |
29
+ 打开 **设置 → 插件 → 四象模型** 页签:
53
30
 
54
- ## 🔁 从旧版(全局技能)迁移
31
+ 1. 在「目标工作区」里选择一个已有工作区,或点「选择其他目录…」挑一个目录;
32
+ - 状态徽标会显示:未安装 / 有更新可用 / 已是最新。
33
+ 2. 点 **「添加到工作区」**。
34
+ 3. 成功后会提示写入的文件数,以及安装位置:
35
+ `<工作区>/.dsh/skills/dynamic-competition-analysis-four-images-model/`。
55
36
 
56
- 旧版本把整包复制到 `~/.dsh/skills/dynamic-competition-analysis-four-images-model`,导致技能在每个工作区都可见。迁移只需删除这份全局副本(技能本体已随插件/工作区提供):
37
+ 写入后由 DSH 原生 filesystem provider 以 source `project-dsh` 发现,即成为该工作区的工作区技能;其它工作区与全局技能列表都不受影响。
57
38
 
58
- ```powershell
59
- Remove-Item -Recurse -Force "$env:USERPROFILE\.dsh\skills\dynamic-competition-analysis-four-images-model"
60
- ```
39
+ - **更新**:插件升级后再次点击「添加到工作区」,只会覆盖有变化的文件(内容一致的会跳过)。
40
+ - **移除**:删除该工作区里的 `.dsh/skills/dynamic-competition-analysis-four-images-model/` 目录即可。
41
+
42
+ ## 🔁 从旧版(全局技能)迁移
61
43
 
62
- 删除后,`~/.dsh/skills` 目录本身保留,不再出现 `dynamic-competition-analysis-four-images-model`。
44
+ 1.1.x 及更早版本把技能复制到 `~/.dsh/skills/`,或按工作区路径自动提供技能。升级到 1.2.0 后:
63
45
 
64
- ## 🧪 校验
46
+ - 删除旧全局副本(如果还在):
47
+ ```powershell
48
+ Remove-Item -Recurse -Force "$env:USERPROFILE\.dsh\skills\dynamic-competition-analysis-four-images-model"
49
+ ```
50
+ - 旧版 `workspaces` 配置项已废弃,`cordis.patch.yml` 里针对本插件的 `config.workspaces` 可以删掉。
51
+ - 需要在某个工作区使用技能时,用上面的 UI 重新添加。
65
52
 
66
- 在仓库会话中,该技能应显示为工作区技能(来源 `project-dsh`):
53
+ ## 🧪 校验与开发
67
54
 
68
55
  ```powershell
69
- node --test test/ # 或在受限沙箱中:node test/workspace-gate.test.mjs
70
- npm pack --dry-run # 确认 .dsh/skills 随包发布
56
+ node test/skill-installer.test.mjs # 受限沙箱下请直接运行单文件(node --test test/ 会因 spawn EPERM 失败)
57
+ npm pack --dry-run # 确认 index.js / client.js / skills / locale / icon 随包发布
71
58
  ```
72
59
 
73
- 单元测试覆盖:frontmatter 解析、工作区路径匹配(含 Windows 大小写)、未配置工作区时不注册、工作区外不可见、工作区已有原生副本时不重复提供。
60
+ 测试覆盖:frontmatter 解析、技能发现(目录名与 name 一致)、工作区路径校验、文件复制/状态判定(缺失、过期、最新、保留工作区额外文件)、桥路由的 loopback 与只读校验、错误码。
74
61
 
75
62
  ## 📁 目录结构
76
63
 
77
64
  ```
78
65
  dsh-plugin-dynamic-competition-four-images/
79
- ├── index.js # 按 workspaces 门控的技能提供者(不注册全局技能)
80
- ├── cordis.patch.yml # bundle 行:workspaces 默认空
81
- ├── package.json # dsh.bundle 清单与 files
82
- ├── test/workspace-gate.test.mjs
66
+ ├── index.js # 宿主:技能发现 + loopback 桥(describe / install)
67
+ ├── client.js # 客户端:设置 → 插件 → 四象模型 页签
68
+ ├── cordis.patch.yml # bundle 行
69
+ ├── locale/{zh,en}.json # 插件显示名与描述
70
+ ├── icon.svg # 插件图标
71
+ ├── package.json # dsh.bundle.patch + dsh.client
72
+ ├── test/skill-installer.test.mjs
73
+ ├── release/ # 打包产物(当前 + 历史版本 tarball)
83
74
  ├── plugin-index/ # 自定义插件市场索引
84
- └── .dsh/skills/dynamic-competition-analysis-four-images-model/
75
+ ├── skills/dynamic-competition-analysis-four-images-model/
85
76
  ├── SKILL.md
86
77
  ├── reference/four-images-model-governance.md
87
78
  ├── references/full-spec.md
package/client.js ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * dsh-plugin-dynamic-competition-four-images — Client 半边
3
+ *
4
+ * 在「设置 → 插件」里增加一个页签:列出插件内的技能,允许用户把它们
5
+ * 添加到指定工作区(写入 <workspace>/.dsh/skills/<name>/)。数据层走宿主
6
+ * 的 loopback 桥(见 index.js),不依赖任何 @Remote 命名空间。
7
+ *
8
+ * 本文件由 dsh.client 加载,注册工厂 id 必须等于包名。
9
+ */
10
+
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-plugin-dynamic-competition-four-images',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const h = React.createElement
16
+
17
+ const NS = 'dsh-plugin-dynamic-competition-four-images'
18
+ const BRIDGE = '/api/dsh-plugin-dynamic-competition-four-images'
19
+ const SLOT = 'settings.plugins.tab'
20
+
21
+ const ZH = {
22
+ 'tab.label': '四象模型',
23
+ title: '四象模型分析技能',
24
+ lead: '把插件内携带的技能安装到指定工作区。安装后技能写入该工作区的 .dsh/skills/,只在该工作区生效,不会成为全局技能。',
25
+ 'workspace.label': '目标工作区',
26
+ 'workspace.placeholder': '选择工作区…',
27
+ 'workspace.pick': '选择其他目录…',
28
+ 'workspace.picking': '选择中…',
29
+ 'workspace.none': '还没有选择工作区',
30
+ 'workspace.custom': '自定义目录',
31
+ install: '添加到工作区',
32
+ installing: '正在添加…',
33
+ 'skills.empty': '插件内没有找到可用技能。',
34
+ 'status.unselected': '未选择工作区',
35
+ 'status.missing': '未安装',
36
+ 'status.outdated': '有更新可用',
37
+ 'status.up-to-date': '已是最新',
38
+ 'result.installed': '已添加到工作区',
39
+ 'result.updated': '已更新',
40
+ 'result.unchanged': '内容一致,无需更新',
41
+ 'result.written': '写入 {written} 个文件,共 {total} 个',
42
+ 'result.target': '安装位置:{path}',
43
+ 'error.prefix': '操作失败:',
44
+ 'error.describe': '无法读取插件技能列表',
45
+ 'error.noWorkspace': '请先选择一个工作区',
46
+ 'error.bridge': '宿主桥不可用,请重启 DSH 后重试',
47
+ version: '版本 {version}',
48
+ note: '重复添加只会覆盖有变化的文件;删除工作区里的该目录即可移除技能。',
49
+ }
50
+
51
+ const EN = {
52
+ 'tab.label': 'Four Images',
53
+ title: 'Four Images analysis skill',
54
+ lead: 'Install the skill shipped with this plugin into a workspace. It is written to that workspace’s .dsh/skills/ and stays scoped to that workspace; it never becomes a global skill.',
55
+ 'workspace.label': 'Target workspace',
56
+ 'workspace.placeholder': 'Choose a workspace…',
57
+ 'workspace.pick': 'Choose another folder…',
58
+ 'workspace.picking': 'Choosing…',
59
+ 'workspace.none': 'No workspace selected yet',
60
+ 'workspace.custom': 'Custom folder',
61
+ install: 'Add to workspace',
62
+ installing: 'Adding…',
63
+ 'skills.empty': 'No skill found inside this plugin.',
64
+ 'status.unselected': 'No workspace selected',
65
+ 'status.missing': 'Not installed',
66
+ 'status.outdated': 'Update available',
67
+ 'status.up-to-date': 'Up to date',
68
+ 'result.installed': 'Added to the workspace',
69
+ 'result.updated': 'Updated',
70
+ 'result.unchanged': 'Already up to date',
71
+ 'result.written': '{written} of {total} files written',
72
+ 'result.target': 'Installed at {path}',
73
+ 'error.prefix': 'Failed: ',
74
+ 'error.describe': 'Could not read the skill list',
75
+ 'error.noWorkspace': 'Choose a workspace first',
76
+ 'error.bridge': 'Host bridge unavailable; restart DSH and retry',
77
+ version: 'Version {version}',
78
+ note: 'Re-adding only overwrites files that changed; delete that folder in the workspace to remove the skill.',
79
+ }
80
+
81
+ const STATUS_TONE = {
82
+ missing: 'var(--dsw-alias-state-warn-primary)',
83
+ outdated: 'var(--dsw-alias-state-warn-primary)',
84
+ 'up-to-date': 'var(--dsw-alias-state-success-primary)',
85
+ unselected: 'var(--dsw-alias-state-idle-primary)',
86
+ }
87
+
88
+ function format(template, params) {
89
+ return template.replace(/\{(\w+)\}/g, (whole, key) => (params && key in params ? String(params[key]) : whole))
90
+ }
91
+
92
+ /** 调宿主桥;返回 value,失败抛错。 */
93
+ async function call(op, body) {
94
+ const response = await fetch(`${BRIDGE}/${op}`, {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body: JSON.stringify(body ?? {}),
98
+ })
99
+ let payload
100
+ try {
101
+ payload = await response.json()
102
+ } catch {
103
+ throw new Error(`HTTP ${response.status}`)
104
+ }
105
+ if (payload === null || typeof payload !== 'object') throw new Error(`HTTP ${response.status}`)
106
+ if (payload.ok !== true) throw new Error(payload.message || payload.code || `HTTP ${response.status}`)
107
+ return payload.value
108
+ }
109
+
110
+ function workspaceList(snapshot) {
111
+ const raw = snapshot && (snapshot.workspaces ?? snapshot.items ?? snapshot)
112
+ const list = []
113
+ if (!Array.isArray(raw)) return list
114
+ for (const item of raw) {
115
+ if (item === null || typeof item !== 'object') continue
116
+ const path = typeof item.path === 'string' ? item.path : undefined
117
+ if (path === undefined || path === '') continue
118
+ const title = typeof item.title === 'string' && item.title !== '' ? item.title : path
119
+ list.push({ id: String(item.workspaceId ?? item.id ?? path), title, path })
120
+ }
121
+ return list
122
+ }
123
+
124
+ const cardStyle = {
125
+ border: '1px solid var(--dsw-alias-border-l1)',
126
+ borderRadius: 12,
127
+ background: 'var(--dsw-alias-bg-layer-1)',
128
+ padding: '16px 18px',
129
+ display: 'grid',
130
+ gap: 14,
131
+ }
132
+
133
+ const labelStyle = {
134
+ display: 'block',
135
+ fontSize: 12,
136
+ fontWeight: 600,
137
+ letterSpacing: '0.02em',
138
+ color: 'var(--dsw-alias-label-secondary)',
139
+ marginBottom: 6,
140
+ }
141
+
142
+ const boxStyle = {
143
+ width: '100%',
144
+ boxSizing: 'border-box',
145
+ minHeight: 34,
146
+ padding: '6px 10px',
147
+ borderRadius: 8,
148
+ border: '1px solid var(--dsw-alias-border-l2)',
149
+ background: 'var(--dsw-alias-bg-base)',
150
+ color: 'var(--dsw-alias-label-primary)',
151
+ font: 'inherit',
152
+ fontSize: 13,
153
+ }
154
+
155
+ function buttonStyle(primary, disabled) {
156
+ return {
157
+ minHeight: 34,
158
+ padding: '6px 14px',
159
+ borderRadius: 8,
160
+ border: primary ? '1px solid transparent' : '1px solid var(--dsw-alias-border-l2)',
161
+ background: primary ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-bg-base)',
162
+ color: primary ? 'var(--dsw-alias-bg-base)' : 'var(--dsw-alias-label-primary)',
163
+ font: 'inherit',
164
+ fontSize: 13,
165
+ fontWeight: 600,
166
+ cursor: disabled ? 'default' : 'pointer',
167
+ opacity: disabled ? 0.55 : 1,
168
+ }
169
+ }
170
+
171
+ function badgeStyle(tone) {
172
+ return {
173
+ display: 'inline-flex',
174
+ alignItems: 'center',
175
+ gap: 6,
176
+ padding: '2px 8px',
177
+ borderRadius: 999,
178
+ border: `1px solid ${tone}`,
179
+ color: tone,
180
+ fontSize: 12,
181
+ fontWeight: 600,
182
+ }
183
+ }
184
+
185
+ return {
186
+ name: NS,
187
+ inject: ['slots'],
188
+ apply(ctx) {
189
+ const getLocale = () => {
190
+ try {
191
+ return typeof ctx.get === 'function' ? ctx.get('locale') : undefined
192
+ } catch {
193
+ return undefined
194
+ }
195
+ }
196
+ const localeFace = () => {
197
+ const live = getLocale()
198
+ return live !== null && live !== undefined && typeof live.bind === 'function' ? live : null
199
+ }
200
+
201
+ // locale 服务是异步 install 的:apply 时可能还没就绪,所以每次用时再取,
202
+ // 词典只注册一次(HMR 重复注册会被运行时拒绝,这里静默忽略)。
203
+ let dictsRegistered = false
204
+ const ensureDicts = () => {
205
+ if (dictsRegistered) return
206
+ const live = localeFace()
207
+ if (live === null) return
208
+ dictsRegistered = true
209
+ try {
210
+ ctx.effect(() => live.register(NS, { zh: ZH, en: EN }))
211
+ } catch {
212
+ // 已有词典(例如插件热更新)时保持现状
213
+ }
214
+ }
215
+
216
+ const translate = (key, params) => {
217
+ const live = localeFace()
218
+ if (live !== null) {
219
+ try {
220
+ return format(live.bind(NS)(key, params), params)
221
+ } catch {
222
+ // 绑定失败时退回内置中文
223
+ }
224
+ }
225
+ return format(ZH[key] ?? key, params)
226
+ }
227
+
228
+ const uiWorkspace = () => {
229
+ try {
230
+ return typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined
231
+ } catch {
232
+ return undefined
233
+ }
234
+ }
235
+
236
+ ensureDicts()
237
+
238
+ function Installer(props) {
239
+ const t = typeof props?.t === 'function' ? (key, params) => format(props.t(key, params), params) : translate
240
+ const snapshot =
241
+ props !== null && typeof props?.useWorkspaces === 'function' ? props.useWorkspaces((value) => value) : undefined
242
+ const workspaces = workspaceList(snapshot)
243
+
244
+ const [path, setPath] = React.useState('')
245
+ const [skills, setSkills] = React.useState([])
246
+ const [version, setVersion] = React.useState('')
247
+ const [error, setError] = React.useState(null)
248
+ const [busy, setBusy] = React.useState(false)
249
+ const [picking, setPicking] = React.useState(false)
250
+ const [notice, setNotice] = React.useState(null)
251
+
252
+ const refresh = React.useCallback(async (workspacePath) => {
253
+ try {
254
+ const value = await call('describe', workspacePath === '' ? {} : { workspacePath })
255
+ setSkills(Array.isArray(value.skills) ? value.skills : [])
256
+ setVersion(typeof value.version === 'string' ? value.version : '')
257
+ setError(null)
258
+ } catch (failure) {
259
+ setSkills([])
260
+ setError(`${t('error.describe')}:${failure instanceof Error ? failure.message : String(failure)}`)
261
+ }
262
+ }, [t])
263
+
264
+ React.useEffect(() => {
265
+ let cancelled = false
266
+ refresh(path).catch(() => {
267
+ if (!cancelled) setError(t('error.bridge'))
268
+ })
269
+ return () => {
270
+ cancelled = true
271
+ }
272
+ }, [path, refresh, t])
273
+
274
+ const pickDirectory = async () => {
275
+ const service = uiWorkspace()
276
+ if (service === undefined || typeof service.pickDirectory !== 'function') return
277
+ setPicking(true)
278
+ try {
279
+ const picked = await service.pickDirectory()
280
+ if (typeof picked === 'string' && picked !== '') {
281
+ setPath(picked)
282
+ setNotice(null)
283
+ }
284
+ } catch (failure) {
285
+ setNotice({ kind: 'error', text: `${t('error.prefix')}${failure instanceof Error ? failure.message : String(failure)}` })
286
+ } finally {
287
+ setPicking(false)
288
+ }
289
+ }
290
+
291
+ const install = async () => {
292
+ if (path === '') {
293
+ setNotice({ kind: 'error', text: t('error.noWorkspace') })
294
+ return
295
+ }
296
+ setBusy(true)
297
+ setNotice(null)
298
+ try {
299
+ const value = await call('install', { workspacePath: path })
300
+ const tone = value.status === 'unchanged' ? 'warn' : 'ok'
301
+ const head = t(`result.${value.status}`)
302
+ const detail = format(t('result.written'), { written: value.written, total: value.total })
303
+ setNotice({ kind: tone, text: `${head} · ${detail}`, target: format(t('result.target'), { path: value.targetPath }) })
304
+ await refresh(path)
305
+ } catch (failure) {
306
+ setNotice({ kind: 'error', text: `${t('error.prefix')}${failure instanceof Error ? failure.message : String(failure)}` })
307
+ } finally {
308
+ setBusy(false)
309
+ }
310
+ }
311
+
312
+ const options = workspaces.map((workspace) => ({ value: workspace.path, label: `${workspace.title} — ${workspace.path}` }))
313
+ if (path !== '' && !options.some((option) => option.value === path)) {
314
+ options.push({ value: path, label: `${t('workspace.custom')} — ${path}` })
315
+ }
316
+
317
+ const noticeTone =
318
+ notice === null
319
+ ? 'var(--dsw-alias-label-secondary)'
320
+ : notice.kind === 'error'
321
+ ? 'var(--dsw-alias-state-error-primary)'
322
+ : notice.kind === 'warn'
323
+ ? 'var(--dsw-alias-state-warn-primary)'
324
+ : 'var(--dsw-alias-state-success-primary)'
325
+
326
+ return h('div', { style: { display: 'grid', gap: 16, padding: '8px 0', maxWidth: 760, color: 'var(--dsw-alias-label-primary)' } }, [
327
+ h('div', { key: 'head' }, [
328
+ h('div', { key: 'title', style: { fontSize: 16, fontWeight: 600 } }, t('title')),
329
+ h(
330
+ 'p',
331
+ { key: 'lead', style: { margin: '6px 0 0', fontSize: 13, lineHeight: 1.6, color: 'var(--dsw-alias-label-secondary)' } },
332
+ t('lead'),
333
+ ),
334
+ version === ''
335
+ ? null
336
+ : h(
337
+ 'p',
338
+ { key: 'version', style: { margin: '4px 0 0', fontSize: 12, color: 'var(--dsw-alias-state-idle-primary)' } },
339
+ format(t('version'), { version }),
340
+ ),
341
+ ]),
342
+
343
+ h('div', { key: 'card', style: cardStyle }, [
344
+ h('div', { key: 'skills', style: { display: 'grid', gap: 8 } }, [
345
+ skills.length === 0
346
+ ? h('div', { key: 'empty', style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, error ?? t('skills.empty'))
347
+ : null,
348
+ ...skills.map((skill) =>
349
+ h('div', { key: skill.name, style: { display: 'grid', gap: 4 } }, [
350
+ h('div', { key: 'line', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [
351
+ h('code', { key: 'name', style: { fontSize: 13, fontWeight: 600 } }, skill.name),
352
+ h('span', { key: 'badge', style: badgeStyle(STATUS_TONE[skill.status] ?? STATUS_TONE.unselected) }, t(`status.${skill.status}`)),
353
+ ]),
354
+ h(
355
+ 'div',
356
+ { key: 'desc', style: { fontSize: 12, lineHeight: 1.6, color: 'var(--dsw-alias-label-secondary)' } },
357
+ skill.description,
358
+ ),
359
+ ]),
360
+ ),
361
+ ]),
362
+
363
+ h('div', { key: 'picker', style: { display: 'grid', gap: 8 } }, [
364
+ h('label', { key: 'label', style: labelStyle, htmlFor: `${NS}-workspace` }, t('workspace.label')),
365
+ h('div', { key: 'row', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
366
+ h(
367
+ 'select',
368
+ {
369
+ key: 'select',
370
+ id: `${NS}-workspace`,
371
+ value: path,
372
+ style: { ...boxStyle, flex: '1 1 260px' },
373
+ onChange: (event) => {
374
+ setPath(event.target.value)
375
+ setNotice(null)
376
+ },
377
+ },
378
+ [
379
+ h('option', { key: 'placeholder', value: '' }, t('workspace.placeholder')),
380
+ ...options.map((option) => h('option', { key: option.value, value: option.value }, option.label)),
381
+ ],
382
+ ),
383
+ h(
384
+ 'button',
385
+ { key: 'pick', type: 'button', style: buttonStyle(false, picking), disabled: picking, onClick: pickDirectory },
386
+ picking ? t('workspace.picking') : t('workspace.pick'),
387
+ ),
388
+ ]),
389
+ path === ''
390
+ ? h('div', { key: 'none', style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('workspace.none'))
391
+ : h('div', { key: 'path', style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', wordBreak: 'break-all' } }, path),
392
+ ]),
393
+
394
+ h('div', { key: 'actions', style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } }, [
395
+ h(
396
+ 'button',
397
+ {
398
+ key: 'install',
399
+ type: 'button',
400
+ style: buttonStyle(true, busy || path === '' || skills.length === 0),
401
+ disabled: busy || path === '' || skills.length === 0,
402
+ onClick: install,
403
+ },
404
+ busy ? t('installing') : t('install'),
405
+ ),
406
+ notice === null
407
+ ? null
408
+ : h(
409
+ 'div',
410
+ { key: 'notice', style: { fontSize: 12, lineHeight: 1.6, color: noticeTone, display: 'grid', gap: 2 } }, [
411
+ h('div', { key: 'text' }, notice.text),
412
+ notice.target === undefined ? null : h('div', { key: 'target', style: { color: 'var(--dsw-alias-label-secondary)', wordBreak: 'break-all' } }, notice.target),
413
+ ],
414
+ ),
415
+ ]),
416
+
417
+ h(
418
+ 'p',
419
+ { key: 'note', style: { margin: 0, fontSize: 12, lineHeight: 1.6, color: 'var(--dsw-alias-label-secondary)' } },
420
+ t('note'),
421
+ ),
422
+ ]),
423
+ ])
424
+ }
425
+
426
+ ctx.slots.inject(SLOT, () => {
427
+ // 设置面板打开时 locale 一定已就绪:补充注册词典,并按现状决定 locale 席位
428
+ ensureDicts()
429
+ const registration = {
430
+ name: SLOT,
431
+ id: 'dynamic-competition-four-images',
432
+ order: 40,
433
+ label: () => translate('tab.label'),
434
+ ...(localeFace() === null ? {} : { locale: NS }),
435
+ }
436
+ return ctx.slots.register(registration, Installer)
437
+ })
438
+ },
439
+ }
440
+ },
441
+ })
package/cordis.patch.yml CHANGED
@@ -1,17 +1,8 @@
1
1
  # dsh-plugin-dynamic-competition-four-images bundle patch.
2
2
  #
3
- # 该插件只按 config.workspaces 指定的工作区提供「动态竞争·四象模型」技能,
4
- # 不注册全局技能。
5
- #
6
- # 在 profile 的 cordis.patch.yml 里覆盖本行的 config 即可启用指定工作区:
7
- #
8
- # - id: dynamic-competition-four-images
9
- # config:
10
- # workspaces:
11
- # - D:\Path\To\Your\Workspace
12
- #
3
+ # 插件本身全局安装;技能不会自动出现在任何工作区。
4
+ # 用户在「设置 → 插件 → 四象模型」页签里选择目标工作区,点击“添加到工作区”,
5
+ # 宿主才把技能写入 <workspace>/.dsh/skills/<name>/。
13
6
  - insert:
14
7
  - id: dynamic-competition-four-images
15
8
  name: dsh-plugin-dynamic-competition-four-images
16
- config:
17
- workspaces: []
package/icon.svg ADDED
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Four Images">
2
+ <rect width="64" height="64" rx="14" fill="#0f172a"/>
3
+ <g fill="none" stroke="#e6edf7" stroke-width="2" opacity="0.85">
4
+ <rect x="14" y="14" width="16" height="16" rx="4"/>
5
+ <rect x="34" y="14" width="16" height="16" rx="4"/>
6
+ <rect x="14" y="34" width="16" height="16" rx="4"/>
7
+ <rect x="34" y="34" width="16" height="16" rx="4"/>
8
+ </g>
9
+ <path d="M22 22h.01" stroke="#7dd3fc" stroke-width="8" stroke-linecap="round"/>
10
+ <path d="M42 22h.01" stroke="#fbbf24" stroke-width="8" stroke-linecap="round"/>
11
+ <path d="M22 42h.01" stroke="#f87171" stroke-width="8" stroke-linecap="round"/>
12
+ <path d="M42 42h.01" stroke="#34d399" stroke-width="8" stroke-linecap="round"/>
13
+ </svg>
package/index.js CHANGED
@@ -1,332 +1,317 @@
1
1
  /**
2
- * dsh-plugin-dynamic-competition-four-images
2
+ * dsh-plugin-dynamic-competition-four-images — 宿主半边
3
3
  *
4
- * 把「动态竞争分析·四象模型」技能以 **工作区技能** 的方式交付。
4
+ * 插件本身安装在 DSH profile(全局)。它不注册任何全局技能,也不自动把技能
5
+ * 注入工作区;只提供两个 loopback 桥接口,供 Client 半边的设置页使用:
5
6
  *
6
- * 交付方式(二选一,互不冲突):
7
- * 1. 技能本体随包发布在 `.dsh/skills/dynamic-competition-analysis-four-images-model/`,
8
- * 工作区中的会话由 DSH 原生 filesystem provider 以 source `project-dsh` 发现;
9
- * 2. 本插件按 `config.workspaces` 指定的工作区提供同一份技能,供未把技能文件
10
- * 放进工作区、但已安装本插件的场景使用。
7
+ * POST /api/dsh-plugin-dynamic-competition-four-images/describe
8
+ * POST /api/dsh-plugin-dynamic-competition-four-images/install
11
9
  *
12
- * 本插件**不注册全局技能**:只有会话工作目录位于已配置的工作区内时才提供技能,
13
- * 因此不会出现在其他工作区或全局技能列表中。
10
+ * `install` 把包内 `.dsh/skills/<name>/` 复制到用户选择的工作区
11
+ * `<workspace>/.dsh/skills/<name>/`,此后由 DSH 原生 filesystem provider 以
12
+ * source `project-dsh` 发现,即成为该工作区的工作区技能。
14
13
  *
15
14
  * @module dsh-plugin-dynamic-competition-four-images
16
15
  */
17
16
 
18
- import { readFile, stat } from 'node:fs/promises'
19
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
17
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
18
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
20
19
  import { fileURLToPath } from 'node:url'
21
20
 
22
21
  /** Cordis 插件名。 */
23
22
  export const name = 'dynamic-competition-four-images'
24
23
 
25
- /** 技能注册表是本插件唯一的 Service 依赖。 */
26
- export const inject = ['skills']
27
-
28
- /** 技能名,与 SKILL.md frontmatter 的 `name` 一致。 */
29
- const SKILL_NAME = 'dynamic-competition-analysis-four-images-model'
30
- const SKILLS_ROOT = fileURLToPath(new URL('./.dsh/skills/', import.meta.url))
31
- const SKILL_DIR = join(SKILLS_ROOT, SKILL_NAME)
32
- const SKILL_FILE = join(SKILL_DIR, 'SKILL.md')
33
- const PROVIDER_NAME = 'dynamic-competition-four-images'
34
- /** 工作区技能的优先级:低于原生 project-dsh(100),与 bundled(600)区分。 */
35
- const PROJECT_SKILL_RANK = 700
36
- /** 以工作区技能来源上报,使界面把它当工作区技能而非全局技能。 */
37
- const SKILL_SOURCE = 'project-dsh'
24
+ const PACKAGE_ROOT = fileURLToPath(new URL('./', import.meta.url))
25
+ /** 包内技能源目录:随包发布的技能本体(普通目录,避免依赖 .dsh 路径)。 */
26
+ export const SKILLS_ROOT = join(PACKAGE_ROOT, 'skills')
27
+ const BRIDGE_PREFIX = '/api/dsh-plugin-dynamic-competition-four-images'
28
+ const MAX_JSON_BODY_BYTES = 64 * 1024
38
29
  const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
39
30
 
40
- /**
41
- * 注册技能提供者。仅在存在已配置工作区时注册,避免产生任何全局技能。
42
- * @param {import('@deepseek-ai/cordis').Context} ctx
43
- * @param {{ workspaces?: unknown }} [config]
44
- */
45
- export function apply(ctx, config) {
46
- const { workspaces, ignored } = normalizeWorkspaces(config)
47
- if (ignored.length > 0) {
48
- warn(ctx, `忽略 ${ignored.length} 条无效的 workspaces 配置(只接受非空绝对路径):${ignored.join(',')}`)
49
- }
50
- if (workspaces.length === 0) {
51
- warn(ctx, '未配置 workspaces,本插件不提供技能;工作区 .dsh/skills 下的副本仍由 DSH 原生发现。')
52
- return
31
+ /** 业务错误:桥接口把它翻译成 4xx + code。 */
32
+ export class BridgeError extends Error {
33
+ constructor(code, message) {
34
+ super(message)
35
+ this.name = 'BridgeError'
36
+ this.code = code
53
37
  }
38
+ }
54
39
 
55
- return ctx.skills.registerProvider(() => ({
56
- name: PROVIDER_NAME,
40
+ /** 注册 loopback 路由;webServer 缺席时静默不启用。 */
41
+ export function apply(ctx) {
42
+ ctx.inject(['webServer'], (sctx) => {
43
+ sctx.effect(() => {
44
+ const disposers = createBridgeRoutes().map((route) => sctx.webServer.register(route))
45
+ return () => {
46
+ for (const dispose of disposers) dispose()
47
+ }
48
+ }, 'dynamic-competition-four-images: skill bridge')
49
+ })
50
+ }
57
51
 
58
- async list(options) {
59
- const candidate = await workspaceCandidate(options, workspaces, ctx)
60
- return candidate === undefined ? [] : [candidate]
61
- },
52
+ /** 组出桥路由(供 webServer.register 与单测使用)。 */
53
+ export function createBridgeRoutes(options = {}) {
54
+ const skillsRoot = options.skillsRoot ?? SKILLS_ROOT
55
+ return [
56
+ defineRoute('/describe', async (body) => {
57
+ const skills = await readSkills(skillsRoot)
58
+ let workspacePath
59
+ if (typeof body?.workspacePath === 'string' && body.workspacePath.trim() !== '') {
60
+ workspacePath = await resolveWorkspace(body.workspacePath)
61
+ }
62
+ const list = []
63
+ for (const skill of skills) {
64
+ list.push({
65
+ name: skill.name,
66
+ description: skill.description,
67
+ status: workspacePath === undefined ? 'unselected' : await skillStatus(skill, workspacePath),
68
+ })
69
+ }
70
+ return { version: await packageVersion(), workspacePath: workspacePath ?? null, skills: list }
71
+ }),
62
72
 
63
- async get(candidate, options) {
64
- options?.signal?.throwIfAborted()
65
- if (candidate === undefined || candidate.name !== SKILL_NAME) return undefined
66
- const skill = await loadSkill(options?.signal)
67
- return skill === undefined ? undefined : toDefinition(skill)
68
- },
69
- }))
73
+ defineRoute('/install', async (body) => {
74
+ const workspacePath = await resolveWorkspace(body?.workspacePath)
75
+ const skills = await readSkills(skillsRoot)
76
+ const wanted = typeof body?.skillName === 'string' ? body.skillName : skills[0]?.name
77
+ const skill = skills.find((entry) => entry.name === wanted)
78
+ if (skill === undefined) {
79
+ throw new BridgeError('skill-not-found', `插件内没有名为 ${String(wanted)} 的技能`)
80
+ }
81
+ const before = await skillStatus(skill, workspacePath)
82
+ const copied = await installSkill(skill, workspacePath)
83
+ return {
84
+ skillName: skill.name,
85
+ workspacePath,
86
+ targetPath: copied.target,
87
+ status: copied.written === 0 ? 'unchanged' : before === 'missing' ? 'installed' : 'updated',
88
+ written: copied.written,
89
+ unchanged: copied.unchanged,
90
+ total: copied.total,
91
+ }
92
+ }),
93
+ ]
70
94
  }
71
95
 
72
- /**
73
- * 归一化 `workspaces` 配置:只接受非空绝对路径,去重并保持顺序。
74
- * @param {{ workspaces?: unknown }} [config]
75
- * @returns {{ workspaces: string[], ignored: string[] }}
76
- */
77
- export function normalizeWorkspaces(config) {
78
- const raw = config?.workspaces
79
- const entries = Array.isArray(raw) ? raw : []
80
- const workspaces = []
81
- const ignored = []
82
- const seen = new Set()
96
+ /** 读取包内全部技能(目录名必须与 frontmatter name 一致)。 */
97
+ export async function readSkills(root = SKILLS_ROOT) {
98
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
99
+ const skills = []
83
100
  for (const entry of entries) {
84
- if (typeof entry !== 'string') {
85
- ignored.push(String(entry))
101
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
102
+ const directory = join(root, entry.name)
103
+ let raw
104
+ try {
105
+ raw = await readFile(join(directory, 'SKILL.md'), 'utf8')
106
+ } catch {
86
107
  continue
87
108
  }
88
- const trimmed = entry.trim()
89
- if (trimmed === '' || !isAbsolute(trimmed)) {
90
- ignored.push(trimmed === '' ? '(空字符串)' : trimmed)
91
- continue
92
- }
93
- const resolved = resolve(trimmed)
94
- const key = normalizeForCompare(resolved)
95
- if (seen.has(key)) continue
96
- seen.add(key)
97
- workspaces.push(resolved)
109
+ const parsed = parseSkillMarkdown(raw)
110
+ if (parsed === undefined || parsed.name !== entry.name) continue
111
+ skills.push({ name: parsed.name, description: parsed.description, directory })
98
112
  }
99
- return { workspaces, ignored }
113
+ return skills.sort((left, right) => left.name.localeCompare(right.name))
100
114
  }
101
115
 
102
- /**
103
- * 判断 `inner` 是否等于 `outer` 或位于其下(大小写不敏感地适配 Windows)。
104
- * @param {string} inner
105
- * @param {string} outer
106
- */
107
- export function isInside(inner, outer) {
108
- const from = normalizeForCompare(resolve(outer))
109
- const to = normalizeForCompare(resolve(inner))
110
- const rel = relative(from, to)
111
- if (rel === '') return true
112
- if (rel === '..' || rel.startsWith(`..${sep}`)) return false
113
- return !isAbsolute(rel)
116
+ /** 目标工作区里该技能的状态:missing / outdated / up-to-date。 */
117
+ export async function skillStatus(skill, workspacePath) {
118
+ return skillStatusAt(skill, targetDirectory(skill.name, workspacePath))
114
119
  }
115
120
 
116
- /**
117
- * 解析一份技能 Markdown(frontmatter + 正文),不做文件 IO,便于单测。
118
- * @param {string} raw
119
- * @returns {{ name: string, description: string, whenToUse?: string, invocation: { modelInvocable: boolean, userInvocable: boolean }, content: string } | undefined}
120
- */
121
- export function parseSkill(raw) {
122
- const parts = splitFrontmatter(raw)
123
- if (parts === undefined) return undefined
124
- const data = parseFrontmatter(parts.yaml)
125
- const skillName = scalarField(data.name)
126
- const description = scalarField(data.description)
127
- const whenToUse = scalarField(data.whenToUse)
128
- if (skillName === undefined || description === undefined) return undefined
129
- if (!NAME_PATTERN.test(skillName)) return undefined
130
-
131
- const disableModelInvocation = parseBooleanFlag(data['disable-model-invocation'])
132
- const userInvocable = parseBooleanFlag(data['user-invocable'])
133
- // 缺省合法(undefined),非法拼写按 DSH 约定整条丢弃。
134
- if (disableModelInvocation === null || userInvocable === null) return undefined
135
-
136
- return {
137
- name: skillName,
138
- description,
139
- ...(whenToUse === undefined ? {} : { whenToUse }),
140
- invocation: {
141
- modelInvocable: !(disableModelInvocation ?? false),
142
- userInvocable: userInvocable ?? true,
143
- },
144
- content: parts.body.trim(),
121
+ /** 目标目录里该技能的状态(测试与复用入口)。 */
122
+ export async function skillStatusAt(skill, target) {
123
+ const exists = await directoryExists(target)
124
+ if (!exists) return 'missing'
125
+ let differing = 0
126
+ for (const file of await walkFiles(skill.directory)) {
127
+ const source = await readFile(file.absolute).catch(() => undefined)
128
+ const current = await readFile(join(target, file.relative)).catch(() => undefined)
129
+ if (source === undefined || current === undefined || !source.equals(current)) differing += 1
145
130
  }
131
+ return differing === 0 ? 'up-to-date' : 'outdated'
146
132
  }
147
133
 
148
- /** 技能文件路径,测试与诊断用。 */
149
- export const skillFile = SKILL_FILE
150
-
151
- /** 读取技能文件;缺失或不可读时返回 undefined。 */
152
- async function loadSkill(signal) {
153
- signal?.throwIfAborted()
154
- let raw
155
- try {
156
- raw = await readFile(SKILL_FILE, 'utf8')
157
- } catch {
158
- return undefined
159
- }
160
- signal?.throwIfAborted()
161
- return parseSkill(raw)
134
+ /** 把技能复制进工作区;返回写入/未变/总数。 */
135
+ export async function installSkill(skill, workspacePath) {
136
+ return copySkillTo(skill, targetDirectory(skill.name, workspacePath))
162
137
  }
163
138
 
164
- /**
165
- * 判断当前会话是否应拿到技能候选:
166
- * 工作目录必须在已配置工作区内,且该工作区的 `.dsh/skills` 里没有原生副本
167
- * (有副本时交给 DSH 原生 provider,避免同名重复)。
168
- */
169
- async function workspaceCandidate(options, workspaces, ctx) {
170
- const cwd = options?.cwd
171
- if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
172
- const directory = resolve(cwd)
173
- if (!workspaces.some((root) => isInside(directory, root))) return undefined
174
- if (await hasNativeSkill(directory)) return undefined
175
-
176
- const skill = await loadSkill(options?.signal)
177
- if (skill === undefined) {
178
- warn(ctx, `无法读取技能文件:${SKILL_FILE}`)
179
- return undefined
139
+ /** 把技能复制到目标目录(测试与复用入口);只覆盖有变化的文件。 */
140
+ export async function copySkillTo(skill, target) {
141
+ const files = await walkFiles(skill.directory)
142
+ let written = 0
143
+ let unchanged = 0
144
+ for (const file of files) {
145
+ const destination = join(target, file.relative)
146
+ const source = await readFile(file.absolute)
147
+ const current = await readFile(destination).catch(() => undefined)
148
+ if (current !== undefined && current.equals(source)) {
149
+ unchanged += 1
150
+ continue
151
+ }
152
+ await mkdir(dirname(destination), { recursive: true })
153
+ await writeFile(destination, source)
154
+ written += 1
180
155
  }
181
- return toCandidate(skill)
156
+ return { target, written, unchanged, total: files.length }
182
157
  }
183
158
 
184
- /** 工作区(项目根)里是否已有该技能的原生副本。 */
185
- async function hasNativeSkill(directory) {
186
- const root = await projectRootOf(directory)
187
- const skillsRoot = join(root, '.dsh', 'skills')
188
- return (
189
- (await fileExists(join(skillsRoot, SKILL_NAME, 'SKILL.md'))) ||
190
- (await fileExists(join(skillsRoot, `${SKILL_NAME}.md`)))
191
- )
159
+ /** 工作区技能目标目录:<workspace>/.dsh/skills/<name>。 */
160
+ function targetDirectory(skillName, workspacePath) {
161
+ return join(workspacePath, '.dsh', 'skills', skillName)
192
162
  }
193
163
 
194
- /** 最近的含 `.git` 的祖先目录;没有则回退为当前目录。 */
195
- async function projectRootOf(directory) {
196
- let current = directory
197
- for (;;) {
198
- if (await fileExists(join(current, '.git'))) return current
199
- const parent = dirname(current)
200
- if (parent === current) return directory
201
- current = parent
164
+ /** 校验并规范化用户给出的工作区路径。 */
165
+ export async function resolveWorkspace(input) {
166
+ if (typeof input !== 'string' || input.trim() === '') {
167
+ throw new BridgeError('workspace-required', '缺少工作区路径')
168
+ }
169
+ const trimmed = input.trim()
170
+ if (!isAbsolute(trimmed)) {
171
+ throw new BridgeError('workspace-not-absolute', '工作区路径必须是绝对路径')
172
+ }
173
+ const path = resolve(trimmed)
174
+ const info = await stat(path).catch(() => undefined)
175
+ if (info === undefined || !info.isDirectory()) {
176
+ throw new BridgeError('workspace-not-found', '工作区目录不存在或不是目录')
202
177
  }
178
+ return path
203
179
  }
204
180
 
205
- async function fileExists(path) {
181
+ async function packageVersion() {
206
182
  try {
207
- await stat(path)
208
- return true
183
+ const raw = await readFile(join(PACKAGE_ROOT, 'package.json'), 'utf8')
184
+ const parsed = JSON.parse(raw)
185
+ return typeof parsed.version === 'string' ? parsed.version : '0.0.0'
209
186
  } catch {
210
- return false
211
- }
212
- }
213
-
214
- function toCandidate(skill) {
215
- return {
216
- name: skill.name,
217
- description: skill.description,
218
- ...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }),
219
- invocation: skill.invocation,
220
- provider: PROVIDER_NAME,
221
- source: SKILL_SOURCE,
222
- resourceBase: { kind: 'directory', path: SKILL_DIR },
223
- rank: PROJECT_SKILL_RANK,
224
- locator: { skillFile: SKILL_FILE },
225
- path: SKILL_FILE,
187
+ return '0.0.0'
226
188
  }
227
189
  }
228
190
 
229
- function toDefinition(skill) {
230
- return {
231
- name: skill.name,
232
- description: skill.description,
233
- ...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }),
234
- invocation: skill.invocation,
235
- provider: PROVIDER_NAME,
236
- source: SKILL_SOURCE,
237
- resourceBase: { kind: 'directory', path: SKILL_DIR },
238
- path: SKILL_FILE,
239
- content: skill.content,
191
+ async function walkFiles(directory, prefix = '') {
192
+ const entries = await readdir(join(directory, prefix), { withFileTypes: true })
193
+ const files = []
194
+ for (const entry of entries) {
195
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`
196
+ if (entry.isDirectory()) {
197
+ files.push(...(await walkFiles(directory, relative)))
198
+ continue
199
+ }
200
+ if (entry.isFile() || entry.isSymbolicLink()) files.push({ relative, absolute: join(directory, relative) })
240
201
  }
202
+ return files
241
203
  }
242
204
 
243
- const BOOLEAN_TRUE = new Set(['true', 'yes', 'on', '1'])
244
- const BOOLEAN_FALSE = new Set(['false', 'no', 'off', '0'])
245
-
246
- /** 解析 YAML 风格布尔值;缺省返回 undefined(调用方决定默认值),非法拼写返回 null。 */
247
- function parseBooleanFlag(value) {
248
- if (value === undefined) return undefined
249
- const normalized = String(value).trim().toLowerCase()
250
- if (BOOLEAN_TRUE.has(normalized)) return true
251
- if (BOOLEAN_FALSE.has(normalized)) return false
252
- return null
205
+ async function directoryExists(path) {
206
+ const info = await stat(path).catch(() => undefined)
207
+ return info !== undefined && info.isDirectory()
253
208
  }
254
209
 
255
- function normalizeForCompare(path) {
256
- return process.platform === 'win32' ? path.toLowerCase() : path
210
+ /** loopback 守卫:只接受本机同源 POST。 */
211
+ export function isLoopbackRequest(request) {
212
+ const address = request?.socket?.remoteAddress
213
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
214
+ const host = request.headers?.host
215
+ if (typeof host !== 'string') return false
216
+ let hostUrl
217
+ try {
218
+ hostUrl = new URL(`http://${host}`)
219
+ } catch {
220
+ return false
221
+ }
222
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
223
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
224
+ const origin = request.headers.origin
225
+ if (origin === undefined) return true
226
+ try {
227
+ return new URL(origin).host === hostUrl.host
228
+ } catch {
229
+ return false
230
+ }
257
231
  }
258
232
 
259
- /** 拆出 frontmatter 行与正文;无合法 frontmatter 时返回 undefined。 */
260
- function splitFrontmatter(raw) {
233
+ /** 解析技能 Markdown 的 frontmatter(本插件只需 name/description)。 */
234
+ export function parseSkillMarkdown(raw) {
261
235
  const text = raw.replace(/^\uFEFF/, '')
262
236
  const lines = text.split(/\r?\n/)
263
237
  if (lines[0]?.trim() !== '---') return undefined
238
+ let end = -1
264
239
  for (let index = 1; index < lines.length; index += 1) {
265
- if (lines[index].trim() !== '---') continue
266
- return {
267
- yaml: lines.slice(1, index),
268
- body: lines.slice(index + 1).join('\n'),
240
+ if (lines[index].trim() === '---') {
241
+ end = index
242
+ break
269
243
  }
270
244
  }
271
- return undefined
272
- }
273
-
274
- /** 只解析本技能需要的 YAML 子集:标量、引号、块标量,其余行忽略。 */
275
- function parseFrontmatter(lines) {
245
+ if (end < 0) return undefined
276
246
  const data = {}
277
- let index = 0
278
- while (index < lines.length) {
279
- const line = lines[index]
280
- if (line.trim() === '' || /^\s*#/.test(line)) {
281
- index += 1
282
- continue
283
- }
284
- const match = /^([A-Za-z0-9_-]+):(.*)$/.exec(line)
285
- if (match === null) {
286
- index += 1
287
- continue
288
- }
289
- const key = match[1]
290
- const rest = match[2].trim()
291
- if (rest === '|' || rest === '>' || rest === '|-' || rest === '>-' || rest === '|+' || rest === '>+') {
292
- const block = []
293
- index += 1
294
- while (index < lines.length && (lines[index].startsWith(' ') || lines[index].startsWith('\t'))) {
295
- block.push(lines[index].trim())
296
- index += 1
297
- }
298
- data[key] = rest.startsWith('|') ? block.join('\n') : block.join(' ').replace(/\s+/g, ' ')
299
- continue
300
- }
301
- data[key] = unquote(rest)
302
- index += 1
247
+ for (let index = 1; index < end; index += 1) {
248
+ const match = /^([A-Za-z0-9_-]+):(.*)$/.exec(lines[index])
249
+ if (match === null) continue
250
+ let value = match[2].trim()
251
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1).replaceAll("''", "'")
252
+ else if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) value = value.slice(1, -1)
253
+ data[match[1]] = value
303
254
  }
304
- return data
255
+ const skillName = typeof data.name === 'string' ? data.name.trim() : ''
256
+ const description = typeof data.description === 'string' ? data.description.trim() : ''
257
+ if (skillName === '' || description === '' || !NAME_PATTERN.test(skillName)) return undefined
258
+ return { name: skillName, description }
305
259
  }
306
260
 
307
- function unquote(value) {
308
- if (value.length >= 2) {
309
- const first = value[0]
310
- const last = value[value.length - 1]
311
- if (first === last && (first === "'" || first === '"')) {
312
- const inner = value.slice(1, -1)
313
- return first === "'" ? inner.replaceAll("''", "'") : inner.replaceAll('\\"', '"').replaceAll('\\\\', '\\')
314
- }
261
+ function defineRoute(operation, handle) {
262
+ return {
263
+ kind: 'exact',
264
+ path: `${BRIDGE_PREFIX}${operation}`,
265
+ handler: async (req, res) => {
266
+ if (!isLoopbackRequest(req)) {
267
+ writeJson(res, 403, { ok: false, code: 'forbidden', message: 'loopback requests only' })
268
+ return
269
+ }
270
+ if (req.method !== 'POST') {
271
+ writeJson(res, 405, { ok: false, code: 'method-not-allowed', message: 'POST only' })
272
+ return
273
+ }
274
+ let body
275
+ try {
276
+ body = await readJsonBody(req)
277
+ } catch {
278
+ writeJson(res, 400, { ok: false, code: 'body-too-large', message: 'JSON body too large' })
279
+ return
280
+ }
281
+ if (body === undefined) {
282
+ writeJson(res, 400, { ok: false, code: 'bad-json', message: 'JSON body required' })
283
+ return
284
+ }
285
+ try {
286
+ writeJson(res, 200, { ok: true, value: await handle(body ?? {}) })
287
+ } catch (error) {
288
+ const known = error instanceof BridgeError
289
+ writeJson(res, known ? 400 : 500, {
290
+ ok: false,
291
+ code: known ? error.code : 'internal',
292
+ message: error instanceof Error ? error.message : String(error),
293
+ })
294
+ }
295
+ },
315
296
  }
316
- return value
317
297
  }
318
298
 
319
- function scalarField(value) {
320
- if (typeof value !== 'string') return undefined
321
- const trimmed = value.trim()
322
- return trimmed === '' ? undefined : trimmed
299
+ function writeJson(res, status, body) {
300
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
301
+ res.end(JSON.stringify(body))
323
302
  }
324
303
 
325
- function warn(ctx, message) {
304
+ async function readJsonBody(req) {
305
+ const chunks = []
306
+ let size = 0
307
+ for await (const chunk of req) {
308
+ size += chunk.length
309
+ if (size > MAX_JSON_BODY_BYTES) throw new Error('body too large')
310
+ chunks.push(chunk)
311
+ }
326
312
  try {
327
- const logger = typeof ctx?.logger === 'function' ? ctx.logger('dynamic-competition-four-images') : undefined
328
- logger?.warn?.(message)
313
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
329
314
  } catch {
330
- // 日志服务不可用时保持静默,不影响技能提供。
315
+ return undefined
331
316
  }
332
317
  }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Dynamic Competition · Four Images",
4
+ "description": "Install the Four Images analysis skill into the workspaces you choose: the plugin is installed globally, while the skill takes effect only where you add it."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "动态竞争·四象模型",
4
+ "description": "把四象模型分析技能按需安装到指定工作区:插件全局安装,技能只在你选择添加的工作区生效。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,18 +1,24 @@
1
1
  {
2
2
  "name": "dsh-plugin-dynamic-competition-four-images",
3
- "version": "1.1.0",
4
- "description": "动态竞争·四象模型技能的工作区交付插件:按 config.workspaces 配置,仅在指定工作区提供该技能,不再写入全局技能目录。",
3
+ "version": "1.2.0",
4
+ "description": "动态竞争·四象模型技能插件:插件全局安装,技能在「设置 → 插件」里按工作区选择性添加,只写入你选择的工作区。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
+ "./client": "./client.js",
9
10
  "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg",
10
13
  "./package.json": "./package.json"
11
14
  },
12
15
  "files": [
13
16
  "index.js",
17
+ "client.js",
14
18
  "cordis.patch.yml",
15
- ".dsh",
19
+ "skills",
20
+ "locale/*.json",
21
+ "icon.svg",
16
22
  "README.md",
17
23
  "LICENSE"
18
24
  ],
@@ -34,6 +40,10 @@
34
40
  "dsh": {
35
41
  "bundle": {
36
42
  "patch": "./cordis.patch.yml"
43
+ },
44
+ "client": {
45
+ "platform": "web",
46
+ "immediately": true
37
47
  }
38
48
  },
39
49
  "author": "",