dsh-skill-hub 0.3.26 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +11 -3
  3. package/README.zh.md +11 -3
  4. package/lib/client.js +310 -39
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +790 -15
  7. package/lib/types/client/api.d.ts +8 -1
  8. package/lib/types/client/locales/common.d.ts +4 -0
  9. package/lib/types/client/locales/market.d.ts +10 -0
  10. package/lib/types/client/locales/sources.d.ts +3 -0
  11. package/lib/types/client/locales.d.ts +17 -0
  12. package/lib/types/client/panel/ForeignImportDialog.d.ts +15 -0
  13. package/lib/types/client/panel/PanelDialogs.d.ts +2 -0
  14. package/lib/types/client/panel/dialogs.d.ts +11 -1
  15. package/lib/types/client/panel/useSkillHub.d.ts +1 -0
  16. package/lib/types/protocol/api.d.ts +2 -0
  17. package/lib/types/protocol/catalog.d.ts +39 -1
  18. package/lib/types/protocol/foreign.d.ts +51 -0
  19. package/lib/types/protocol/sources.d.ts +13 -0
  20. package/lib/types/protocol.d.ts +3 -1
  21. package/lib/types/repo/install.d.ts +4 -0
  22. package/lib/types/routes/deps.d.ts +11 -0
  23. package/lib/types/routes/foreign.d.ts +10 -0
  24. package/lib/types/routes/helpers.d.ts +1 -1
  25. package/lib/types/skillfs/audit.d.ts +58 -0
  26. package/lib/types/skillfs/foreign.d.ts +51 -0
  27. package/lib/types/skillfs/hash.d.ts +24 -0
  28. package/lib/types/skillfs.d.ts +2 -0
  29. package/lib/types/store/store.d.ts +6 -0
  30. package/package.json +23 -21
  31. package/src/client/api.ts +14 -0
  32. package/src/client/locales/common.ts +8 -0
  33. package/src/client/locales/market.ts +20 -0
  34. package/src/client/locales/sources.ts +6 -0
  35. package/src/client/panel/ForeignImportDialog.tsx +130 -0
  36. package/src/client/panel/PanelDialogs.tsx +4 -1
  37. package/src/client/panel/SkillHubPanel.tsx +34 -4
  38. package/src/client/panel/SourceStatusBadge.tsx +23 -13
  39. package/src/client/panel/dialogs.tsx +14 -3
  40. package/src/client/panel/panel.module.css +6 -0
  41. package/src/client/panel/useSkillHub.ts +1 -1
  42. package/src/index.ts +2 -2
  43. package/src/protocol/api.ts +2 -0
  44. package/src/protocol/catalog.ts +56 -0
  45. package/src/protocol/foreign.ts +47 -0
  46. package/src/protocol/sources.ts +13 -0
  47. package/src/protocol.test.ts +59 -0
  48. package/src/protocol.ts +3 -0
  49. package/src/repo/install.ts +34 -0
  50. package/src/repo.test.ts +55 -0
  51. package/src/routes/catalog-data.ts +2 -0
  52. package/src/routes/deps.ts +15 -0
  53. package/src/routes/foreign.ts +127 -0
  54. package/src/routes/helpers.ts +1 -0
  55. package/src/routes/repo-import.ts +4 -1
  56. package/src/routes/sources.ts +53 -3
  57. package/src/routes.test.ts +162 -3
  58. package/src/routes.ts +2 -0
  59. package/src/skillfs/audit.test.ts +182 -0
  60. package/src/skillfs/audit.ts +229 -0
  61. package/src/skillfs/foreign.test.ts +121 -0
  62. package/src/skillfs/foreign.ts +198 -0
  63. package/src/skillfs/hash.test.ts +129 -0
  64. package/src/skillfs/hash.ts +112 -0
  65. package/src/skillfs/scan.ts +17 -0
  66. package/src/skillfs.test.ts +37 -0
  67. package/src/skillfs.ts +2 -0
  68. package/src/store/migrate.ts +7 -1
  69. package/src/store/store.ts +36 -3
  70. package/src/store.test.ts +24 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.26",
4
- "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
3
+ "version": "0.3.28",
4
+ "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics plus a content audit (hidden characters, remote-execution pipes, credential paths, size caps), and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync, local-edit detection via content hashes, and read-only import from other agents' skill folders. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -68,7 +68,9 @@
68
68
  "0.2.0-rc.1",
69
69
  "0.2.0-rc.2",
70
70
  "0.2.1-alpha.2",
71
- "0.2.1-alpha.5"
71
+ "0.2.1-alpha.5",
72
+ "0.2.1-rc.2",
73
+ "0.2.2-alpha.2"
72
74
  ]
73
75
  },
74
76
  "capability": {
@@ -88,7 +90,7 @@
88
90
  "js-yaml": "^4.1.0"
89
91
  },
90
92
  "peerDependencies": {
91
- "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2 || ^4.0.5-alpha.1 || ^4.0.5-alpha.2",
93
+ "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2 || ^4.0.5-alpha.1 || ^4.0.5-alpha.2 || ^4.0.6-alpha.1",
92
94
  "@deepseek-ai/dsh-client-connection": ">=0.1.7-alpha.1 <0.3.0-0",
93
95
  "@deepseek-ai/dsh-client-locale": ">=0.1.7-alpha.1 <0.3.0-0",
94
96
  "@deepseek-ai/dsh-client-runtime": ">=0.1.7-alpha.1 <0.3.0-0",
@@ -102,7 +104,7 @@
102
104
  "@deepseek-ai/dsh-settings": ">=0.1.7-alpha.1 <0.3.0-0",
103
105
  "@deepseek-ai/dsh-skill": ">=0.1.7-alpha.1 <0.3.0-0",
104
106
  "@deepseek-ai/dsh-system-prompt": ">=0.1.7-alpha.1 <0.3.0-0",
105
- "@deepseek-ai/schemastery": "^3.18.3 || ^3.18.5-alpha.1 || ^3.18.5-alpha.2",
107
+ "@deepseek-ai/schemastery": "^3.18.3 || ^3.18.5-alpha.1 || ^3.18.5-alpha.2 || ^3.18.6-alpha.1",
106
108
  "react": "^18.2.0",
107
109
  "react-dom": "^18.2.0"
108
110
  },
@@ -115,22 +117,22 @@
115
117
  }
116
118
  },
117
119
  "devDependencies": {
118
- "@deepseek-ai/cordis": "^4.0.5-alpha.2",
119
- "@deepseek-ai/dsh-client-connection": "^0.2.1-alpha.5",
120
- "@deepseek-ai/dsh-client-locale": "^0.2.1-alpha.5",
121
- "@deepseek-ai/dsh-client-store": "^0.2.1-alpha.5",
122
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.1-alpha.5",
123
- "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.1-alpha.5",
124
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-alpha.5",
125
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.1-alpha.5",
126
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-alpha.5",
127
- "@deepseek-ai/dsh-host-webserver": "^0.2.1-alpha.5",
128
- "@deepseek-ai/dsh-session": "^0.2.1-alpha.5",
129
- "@deepseek-ai/dsh-session-query": "^0.2.1-alpha.5",
130
- "@deepseek-ai/dsh-settings": "^0.2.1-alpha.5",
131
- "@deepseek-ai/dsh-skill": "^0.2.1-alpha.5",
132
- "@deepseek-ai/dsh-system-prompt": "^0.2.1-alpha.5",
133
- "@deepseek-ai/schemastery": "^3.18.5-alpha.2",
120
+ "@deepseek-ai/cordis": "^4.0.6-alpha.1",
121
+ "@deepseek-ai/dsh-client-connection": "^0.2.2-alpha.2",
122
+ "@deepseek-ai/dsh-client-locale": "^0.2.2-alpha.2",
123
+ "@deepseek-ai/dsh-client-store": "^0.2.2-alpha.2",
124
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.2-alpha.2",
125
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.2-alpha.2",
126
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.2-alpha.2",
127
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.2-alpha.2",
128
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.2-alpha.2",
129
+ "@deepseek-ai/dsh-host-webserver": "^0.2.2-alpha.2",
130
+ "@deepseek-ai/dsh-session": "^0.2.2-alpha.2",
131
+ "@deepseek-ai/dsh-session-query": "^0.2.2-alpha.2",
132
+ "@deepseek-ai/dsh-settings": "^0.2.2-alpha.2",
133
+ "@deepseek-ai/dsh-skill": "^0.2.2-alpha.2",
134
+ "@deepseek-ai/dsh-system-prompt": "^0.2.2-alpha.2",
135
+ "@deepseek-ai/schemastery": "^3.18.6-alpha.1",
134
136
  "@types/js-yaml": "^4.0.9",
135
137
  "@types/node": "^22.20.0",
136
138
  "@types/react": "~18.3.1",
package/src/client/api.ts CHANGED
@@ -10,6 +10,9 @@ import {
10
10
  type CreateResponse,
11
11
  type DiagnosticFixRequest,
12
12
  type DiagnosticFixResponse,
13
+ type ForeignImportRequest,
14
+ type ForeignImportResponse,
15
+ type ForeignListResponse,
13
16
  type GroupsResponse,
14
17
  type MarketCheckResponse,
15
18
  type MarketSourceAddResponse,
@@ -295,6 +298,17 @@ export class SkillHubApi {
295
298
  return this.get<SourcesResponse>(SKILL_HUB_API.sources)
296
299
  }
297
300
 
301
+ /** 列出其他 agent 用户级技能目录里的可收编技能(只读扫描)。 */
302
+ listForeignSkills(): Promise<ForeignListResponse> {
303
+ return this.get<ForeignListResponse>(SKILL_HUB_API.foreign)
304
+ }
305
+
306
+ /** 收编所选外部技能到 ~/.dsh/skills。 */
307
+ importForeignSkills(items: Array<{ agent: string; name: string }>): Promise<ForeignImportResponse> {
308
+ const payload: ForeignImportRequest = { items }
309
+ return this.post<ForeignImportResponse>(SKILL_HUB_API.foreignImport, payload)
310
+ }
311
+
298
312
  /** 检查指定(或全部)来源的上游更新。 */
299
313
  checkSources(repo?: string): Promise<SourceCheckResponse> {
300
314
  const payload: SourceCheckRequest = repo !== undefined ? { repo } : {}
@@ -17,6 +17,10 @@ export const zhCommon = {
17
17
  'legend.hint': '每个技能最多一个圆点;无圆点 = 不可被调用。',
18
18
  'panel.disabled': '已禁用',
19
19
  'panel.diagnostics': '发现诊断',
20
+ 'panel.auditBadge': '审计',
21
+ 'panel.announcement': '公告预算 {chars} 字符 ≈ {tokens} tokens · {skills} 个技能',
22
+ 'panel.announcementOver': '超出建议上限 {budget} 字符,排在后面的技能可能不触发',
23
+ 'panel.announcementHint': '技能目录的第一层(name + description)常驻在 system prompt 里。建议不超 {budget} 字符 —— 超出后客户端会截断尾部条目,表现为「装了却不触发」。',
20
24
  'panel.empty': '没有匹配的技能。',
21
25
  'panel.emptyAll': '本地还没有技能。点击「新建技能」创建第一个。',
22
26
  'panel.loading': '读取中…',
@@ -66,6 +70,10 @@ export const enCommon: Record<keyof typeof zhCommon, string> = {
66
70
  'legend.hint': 'At most one dot per skill; no dot = not invocable.',
67
71
  'panel.disabled': 'Disabled',
68
72
  'panel.diagnostics': 'Discovery diagnostics',
73
+ 'panel.auditBadge': 'audit',
74
+ 'panel.announcement': 'Announcement budget: {chars} chars ≈ {tokens} tokens · {skills} skills',
75
+ 'panel.announcementOver': 'over the suggested {budget}-char cap — later skills may stop triggering',
76
+ 'panel.announcementHint': 'The first progressive-disclosure layer (name + description) sits in the system prompt. Stay under {budget} chars: past it, clients truncate the tail and skills silently stop triggering.',
69
77
  'panel.empty': 'No matching skills.',
70
78
  'panel.emptyAll': 'No skills installed yet. Create the first one.',
71
79
  'panel.loading': 'Loading…',
@@ -79,6 +79,16 @@ export const zhMarket = {
79
79
  'repo.root.skills': 'skills',
80
80
  'repo.root.designTemplates': 'design-templates',
81
81
  'repo.root.generic': '{root}',
82
+ 'foreign.open': '从其他 Agent 导入',
83
+ 'foreign.title': '从其他 Agent 导入',
84
+ 'foreign.text': '读取 Claude Code / Codex / Cursor 等工具的用户级技能目录(只读),把所选技能复制到 ~/.dsh/skills。原目录不受影响。',
85
+ 'foreign.empty': '没有在这些工具的技能目录里发现技能。',
86
+ 'foreign.exists': '已存在',
87
+ 'foreign.import': '导入所选({count})',
88
+ 'foreign.importing': '导入中…',
89
+ 'foreign.skipped': '{count} 个因同名已存在被跳过。',
90
+ 'foreign.importedBanner': '已导入 {count} 个技能。',
91
+ 'foreign.importedReplacedBanner': '已导入 {count} 个技能,另有 {replaced} 个同名占位被移入回收站(可恢复)。',
82
92
  } as const
83
93
 
84
94
  export const enMarket: Record<keyof typeof zhMarket, string> = {
@@ -161,4 +171,14 @@ export const enMarket: Record<keyof typeof zhMarket, string> = {
161
171
  'repo.root.skills': 'Skills',
162
172
  'repo.root.designTemplates': 'Templates',
163
173
  'repo.root.generic': '{root}',
174
+ 'foreign.open': 'Import from another agent',
175
+ 'foreign.title': 'Import from another agent',
176
+ 'foreign.text': 'Reads the user-level skill folders of Claude Code / Codex / Cursor and friends (read-only) and copies the selected skills into ~/.dsh/skills. The originals are left untouched.',
177
+ 'foreign.empty': 'No skills found in those tool folders.',
178
+ 'foreign.exists': 'exists',
179
+ 'foreign.import': 'Import {count}',
180
+ 'foreign.importing': 'Importing…',
181
+ 'foreign.skipped': '{count} skipped because the name already exists.',
182
+ 'foreign.importedBanner': 'Imported {count} skill(s).',
183
+ 'foreign.importedReplacedBanner': 'Imported {count} skill(s); {replaced} same-name placeholder(s) moved to trash (restorable).',
164
184
  }
@@ -11,6 +11,9 @@ export const zhSources = {
11
11
  'source.checking': '检查中…',
12
12
  'source.upToDate': '已是最新',
13
13
  'source.updated': '有更新 {count} 个',
14
+ 'source.localModified': '本地改动 {count} 个',
15
+ 'source.localModifiedBadge': '本地已改',
16
+ 'source.localModifiedHint': '这些技能的磁盘内容与导入/同步时不一致(你自己改过)。同步会覆盖它们。',
14
17
  'source.deleted': '上游已删除 {count} 个',
15
18
  'source.error': '检查失败',
16
19
  'source.throttled': '刚刚检查过',
@@ -45,6 +48,9 @@ export const enSources: Record<keyof typeof zhSources, string> = {
45
48
  'source.checking': 'Checking…',
46
49
  'source.upToDate': 'Up to date',
47
50
  'source.updated': '{count} updates',
51
+ 'source.localModified': '{count} edited locally',
52
+ 'source.localModifiedBadge': 'edited',
53
+ 'source.localModifiedHint': 'These skills no longer match what was imported or synced — you changed them on disk. Syncing overwrites them.',
48
54
  'source.deleted': '{count} deleted upstream',
49
55
  'source.error': 'Check failed',
50
56
  'source.throttled': 'Checked recently',
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 从其他 agent 导入:列出 Claude Code / Codex / Cursor / Gemini CLI 等技能目录里的
3
+ * 技能,勾选后复制到本地可写根。
4
+ *
5
+ * 只读扫描 + 复制,不改动别家目录。已存在的同名技能不可选(导入会覆盖,
6
+ * 那是用户在别处没预期的事)。
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+ import type { ForeignSkillView } from '../../protocol.ts'
11
+ import type { SkillHubApi } from '../api.ts'
12
+ import { errorMessage, tt } from '../helpers.ts'
13
+ import { DialogShell } from './dialogs.tsx'
14
+ import css from './panel.module.css'
15
+
16
+ export interface ForeignImportDialogProps {
17
+ api: SkillHubApi
18
+ onClose: () => void
19
+ /** 导入成功后回调,由面板负责刷新目录与提示(replaced = 被移入回收站的同名占位)。 */
20
+ onImported: (imported: string[], replaced: number) => void
21
+ }
22
+
23
+ /** 一个技能在一组里的稳定键(同名技能可来自不同 agent)。 */
24
+ function keyOf(skill: { agent: string; name: string }): string {
25
+ return skill.agent + '/' + skill.name
26
+ }
27
+
28
+ export function ForeignImportDialog(props: ForeignImportDialogProps): React.JSX.Element {
29
+ const { api, onClose, onImported } = props
30
+ const [skills, setSkills] = useState<ForeignSkillView[] | null>(null)
31
+ const [error, setError] = useState<string | null>(null)
32
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
33
+ const [busy, setBusy] = useState(false)
34
+ const [message, setMessage] = useState<string | null>(null)
35
+
36
+ useEffect(() => {
37
+ let alive = true
38
+ void (async () => {
39
+ try {
40
+ const result = await api.listForeignSkills()
41
+ if (alive) setSkills(result.skills)
42
+ } catch (caught) {
43
+ if (alive) setError(errorMessage(caught))
44
+ }
45
+ })()
46
+ return () => { alive = false }
47
+ }, [api])
48
+
49
+ const toggle = (skill: ForeignSkillView): void => {
50
+ if (skill.existing || busy) return
51
+ const next = new Set(selected)
52
+ const key = keyOf(skill)
53
+ if (next.has(key)) next.delete(key)
54
+ else next.add(key)
55
+ setSelected(next)
56
+ }
57
+
58
+ const run = async (): Promise<void> => {
59
+ if (selected.size === 0 || skills === null) return
60
+ setBusy(true)
61
+ setError(null)
62
+ setMessage(null)
63
+ try {
64
+ const items = skills.filter((skill) => selected.has(keyOf(skill))).map((skill) => ({ agent: skill.agent, name: skill.name }))
65
+ const result = await api.importForeignSkills(items)
66
+ const replaced = result.replaced?.length ?? 0
67
+ if (result.failed.length > 0) {
68
+ setError(result.failed.map((failure) => failure.name + ': ' + failure.error).join('; '))
69
+ // 部分成功也要把成功的刷新出来,否则用户会以为一个都没进去。
70
+ if (result.imported.length > 0) onImported(result.imported, replaced)
71
+ } else if (result.skipped.length > 0) {
72
+ setMessage(tt('foreign.skipped', { count: result.skipped.length }))
73
+ if (result.imported.length > 0) onImported(result.imported, replaced)
74
+ } else {
75
+ onImported(result.imported, replaced)
76
+ onClose()
77
+ }
78
+ } catch (caught) {
79
+ setError(errorMessage(caught))
80
+ } finally {
81
+ setBusy(false)
82
+ }
83
+ }
84
+
85
+ return (
86
+ <DialogShell onClose={onClose}>
87
+ <h3 className={css.dialogTitle}>{tt('foreign.title')}</h3>
88
+ <p className={css.dialogText}>{tt('foreign.text')}</p>
89
+ {skills === null && error === null ? <p className={css.dialogText}>{tt('panel.loading')}</p> : null}
90
+ {skills !== null && skills.length === 0 ? <p className={css.dialogText}>{tt('foreign.empty')}</p> : null}
91
+ {skills !== null && skills.length > 0 ? (
92
+ <ul className={css.dialogList} style={{ maxHeight: 320, overflowY: 'auto' }}>
93
+ {skills.map((skill) => {
94
+ const key = keyOf(skill)
95
+ const disabled = skill.existing || busy
96
+ return (
97
+ <li key={key}>
98
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: disabled ? 'default' : 'pointer' }}>
99
+ <input
100
+ type='checkbox'
101
+ disabled={disabled}
102
+ checked={selected.has(key)}
103
+ onChange={() => { toggle(skill) }}
104
+ />
105
+ <span className={css.rowNameText}>{skill.name}</span>
106
+ <span className={css.statusBadge}>{skill.agent}</span>
107
+ {skill.existing ? <span className={css.statusBadge + ' ' + css.statusLocal}>{tt('foreign.exists')}</span> : null}
108
+ </label>
109
+ <div className={css.rowDesc} title={skill.description}>{skill.description}</div>
110
+ </li>
111
+ )
112
+ })}
113
+ </ul>
114
+ ) : null}
115
+ {message !== null ? <p className={css.dialogText}>{message}</p> : null}
116
+ {error !== null ? <p className={css.dialogText} style={{ color: 'var(--hub-danger)' }}>{error}</p> : null}
117
+ <div className={css.dialogActions}>
118
+ <button type='button' className={css.button} onClick={onClose}>{tt('form.cancel')}</button>
119
+ <button
120
+ type='button'
121
+ className={css.button + ' ' + css.primary}
122
+ disabled={selected.size === 0 || busy}
123
+ onClick={() => { void run() }}
124
+ >
125
+ {busy ? tt('foreign.importing') : tt('foreign.import', { count: selected.size })}
126
+ </button>
127
+ </div>
128
+ </DialogShell>
129
+ )
130
+ }
@@ -22,6 +22,8 @@ export interface PanelDialogsProps {
22
22
  confirmDialog: SkillHubState['confirmDialog']
23
23
  setConfirmDialog: SkillHubState['setConfirmDialog']
24
24
  runConfirmed: SkillHubState['runConfirmed']
25
+ /** 来源检查结果(同步确认里标出本地改动过的技能)。 */
26
+ sourceCheck: SkillHubState['sourceCheck']
25
27
  /** 无 release 的市场源分支选择。 */
26
28
  branchChoice: SkillHubState['branchChoice']
27
29
  branchBusy: SkillHubState['branchBusy']
@@ -54,7 +56,7 @@ export interface PanelDialogsProps {
54
56
  export function PanelDialogs(props: PanelDialogsProps): JSX.Element {
55
57
  const {
56
58
  conflictDialog, tags, collections, setConflictDialog, resolveConflict,
57
- confirmDialog, setConfirmDialog, runConfirmed,
59
+ confirmDialog, setConfirmDialog, runConfirmed, sourceCheck,
58
60
  branchChoice, branchBusy, setBranchChoice, confirmBranchChoice,
59
61
  versionDialog, versionBusy, setVersionDialog, confirmVersionDialog,
60
62
  marketSyncDialog, syncBusy, setMarketSyncDialog, confirmMarketSync,
@@ -80,6 +82,7 @@ export function PanelDialogs(props: PanelDialogsProps): JSX.Element {
80
82
  title={confirmDialog.kind === 'sync' ? tt('source.syncConfirmTitle') : tt('source.deleteConfirmTitle')}
81
83
  text={confirmDialog.kind === 'sync' ? tt('source.syncConfirmText') : tt('source.deleteConfirmText')}
82
84
  items={confirmDialog.skills}
85
+ {...(confirmDialog.kind === 'sync' ? { marked: sourceCheck[confirmDialog.repo]?.localModified ?? [] } : {})}
83
86
  confirmLabel={confirmDialog.kind === 'sync' ? tt('source.sync') : tt('source.followDelete')}
84
87
  danger={confirmDialog.kind === 'delete'}
85
88
  onCancel={() => { setConfirmDialog(null) }}
@@ -26,6 +26,7 @@ import { SourcesView } from './SourcesView.tsx'
26
26
  import { ScenesView } from './ScenesView.tsx'
27
27
  import { MarketView } from './MarketView.tsx'
28
28
  import { PanelDialogs } from './PanelDialogs.tsx'
29
+ import { ForeignImportDialog } from './ForeignImportDialog.tsx'
29
30
  import { useSkillHub } from './useSkillHub.ts'
30
31
  import css from './panel.module.css'
31
32
 
@@ -37,6 +38,8 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
37
38
  const hub = useSkillHub(props.api)
38
39
  /** 工作区输入草稿:回车才应用,避免每次按键都触发目录重拉。 */
39
40
  const [workspaceDraft, setWorkspaceDraft] = useState('')
41
+ /** 「从其他 Agent 导入」弹窗开合。 */
42
+ const [showForeign, setShowForeign] = useState(false)
40
43
  /** 「筛选」面板开合(来源 + 调用方式收进这里;搜索/排序/视图切换始终可见)。 */
41
44
  const [filtersOpen, setFiltersOpen] = useState(false)
42
45
  const {
@@ -52,7 +55,7 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
52
55
  setConfirmClearTrash, setBranchChoice, setMarketSyncDialog, setEditingTag, setEditName, setMembersDraft, setEditSearch,
53
56
  setShowLegend, setEditMode,
54
57
  checkUpdate, loadMarket, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, runDeleteSkill, runDeleteGroup,
55
- runConfirmed, resolveConflict, confirmBranchChoice, confirmMarketSync, create, saveTag, deleteTag, enableDisabled,
58
+ runConfirmed, resolveConflict, confirmBranchChoice, confirmMarketSync, create, saveTag, deleteTag, enableDisabled, reloadCatalog,
56
59
  } = hub
57
60
  const { shortenedCount, fixingPaths, clearListFilters } = hub
58
61
 
@@ -171,10 +174,22 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
171
174
  </span>
172
175
  <button type='button' className={css.legendToggle + (showLegend ? ' ' + css.legendToggleActive : '')} onClick={() => { setShowLegend((value) => !value) }} title={tt('legend.hint')}>?</button>
173
176
  <span className={css.actions}>
177
+ <button type='button' className={css.button} onClick={() => { setShowForeign(true) }}>{tt('foreign.open')}</button>
174
178
  <button type='button' className={css.button + ' ' + css.primary} onClick={() => { setShowForm((value) => !value) }}>{tt('panel.new')}</button>
175
179
  </span>
176
180
  </div>
177
181
 
182
+ {showForeign ? (
183
+ <ForeignImportDialog
184
+ api={props.api}
185
+ onClose={() => { setShowForeign(false) }}
186
+ onImported={(imported, replaced) => {
187
+ void reloadCatalog()
188
+ setSuccessBanner(tt(replaced > 0 ? 'foreign.importedReplacedBanner' : 'foreign.importedBanner', { count: imported.length, replaced }))
189
+ }}
190
+ />
191
+ ) : null}
192
+
178
193
  {showForm ? (
179
194
  <form className={css.form} onSubmit={(event) => { void create(event) }}>
180
195
  <p className={css.hintLine}>{tt('form.capabilityHint')}</p>
@@ -291,6 +306,17 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
291
306
  </div>
292
307
  ) : null}
293
308
 
309
+ {catalog !== null && catalog.announcement !== undefined && catalog.announcement.skills > 0 ? (
310
+ <div className={css.hintLine} style={{ margin: '2px 2px 0', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
311
+ <span title={tt('panel.announcementHint', { budget: catalog.announcement.budget })}>
312
+ {tt('panel.announcement', { chars: catalog.announcement.chars, tokens: catalog.announcement.approxTokens, skills: catalog.announcement.skills })}
313
+ </span>
314
+ {catalog.announcement.overBudget
315
+ ? <span style={{ color: 'var(--hub-danger)' }}>{tt('panel.announcementOver', { budget: catalog.announcement.budget })}</span>
316
+ : null}
317
+ </div>
318
+ ) : null}
319
+
294
320
  {filtered.length === 0 && search.trim() !== '' ? <div className={css.empty}>{tt('panel.empty')}</div> : null}
295
321
  {filtered.length === 0 && search.trim() === '' && catalog.skills.length === 0 && catalog.disabled.length === 0 && catalog.diagnostics.length === 0
296
322
  ? <div className={css.empty}>{tt('panel.emptyAll')}</div>
@@ -319,10 +345,13 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
319
345
  {catalog.diagnostics.length > 0 ? (
320
346
  <section className={css.section}>
321
347
  <div className={css.sectionTitle}>{tt('panel.diagnostics')}</div>
322
- {catalog.diagnostics.map((entry) => (
323
- <div key={entry.path} className={css.diagRow} style={{ display:'flex', alignItems:'flex-start', gap:10 }}>
348
+ {catalog.diagnostics.map((entry, index) => (
349
+ <div key={entry.path + '|' + String(index)} className={css.diagRow + (entry.severity === 'block' ? ' ' + css.diagRowBlock : '')} style={{ display:'flex', alignItems:'flex-start', gap:10 }}>
324
350
  <div style={{ flex:1, minWidth:0 }}>
325
- <div className={css.diagPath}>{entry.path}</div>
351
+ <div className={css.diagPath}>
352
+ {entry.kind === 'security' ? <span className={css.auditBadge}>{tt('panel.auditBadge')}</span> : null}
353
+ {entry.path}
354
+ </div>
326
355
  <div className={css.diagReason}>{entry.reason}</div>
327
356
  </div>
328
357
  {entry.fixable === true ? (
@@ -350,6 +379,7 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
350
379
  confirmDialog={confirmDialog}
351
380
  setConfirmDialog={setConfirmDialog}
352
381
  runConfirmed={runConfirmed}
382
+ sourceCheck={sourceCheck}
353
383
  branchChoice={branchChoice}
354
384
  branchBusy={branchBusy}
355
385
  setBranchChoice={setBranchChoice}
@@ -32,30 +32,40 @@ export function SourceStatusBadge(props: {
32
32
  </button>
33
33
  )
34
34
  }
35
- const badges = ((): JSX.Element | null => {
35
+ // 单一容器:状态徽章与「本地改动」是并列关系,多套一层 flex 会多出一份 gap。
36
+ const items: JSX.Element[] = (() => {
36
37
  if (check.error !== undefined) {
37
- return <span className={css.statusBadge + ' ' + css.statusError}>{tt('source.error')}</span>
38
+ return [<span key='error' className={css.statusBadge + ' ' + css.statusError}>{tt('source.error')}</span>]
38
39
  }
39
40
  if (check.throttled === true) {
40
- return <span className={css.statusBadge}>{tt('source.throttled')}</span>
41
+ return [<span key='throttled' className={css.statusBadge}>{tt('source.throttled')}</span>]
41
42
  }
42
43
  if (check.unverified === true) {
43
- return <span className={css.statusBadge}>{tt('source.unverified')}</span>
44
+ return [<span key='unverified' className={css.statusBadge}>{tt('source.unverified')}</span>]
44
45
  }
45
46
  if (check.changed) {
46
- return (
47
- <span className={css.statusBadges}>
48
- {check.updated.length > 0 ? <span className={css.statusBadge + ' ' + css.statusUpdated}>{tt('source.updated', { count: check.updated.length })}</span> : null}
49
- {check.deleted.length > 0 ? <span className={css.statusBadge + ' ' + css.statusError}>{tt('source.deleted', { count: check.deleted.length })}</span> : null}
50
- </span>
51
- )
47
+ return [
48
+ ...(check.updated.length > 0 ? [<span key='updated' className={css.statusBadge + ' ' + css.statusUpdated}>{tt('source.updated', { count: check.updated.length })}</span>] : []),
49
+ ...(check.deleted.length > 0 ? [<span key='deleted' className={css.statusBadge + ' ' + css.statusError}>{tt('source.deleted', { count: check.deleted.length })}</span>] : []),
50
+ ]
52
51
  }
53
- return <span className={css.statusBadge + ' ' + css.statusOk}>{tt('source.upToDate')}</span>
52
+ return [<span key='ok' className={css.statusBadge + ' ' + css.statusOk}>{tt('source.upToDate')}</span>]
54
53
  })()
55
- if (onCheck === undefined) return badges
54
+ // 本地改动与上游状态正交:上游没变时它同样存在,所以挂在状态旁边而不是
55
+ // 塞进状态分支里。
56
+ const modified = check.localModified ?? []
57
+ if (modified.length > 0) {
58
+ items.push(
59
+ <span key='local' className={css.statusBadge + ' ' + css.statusLocal} title={tt('source.localModifiedHint') + '\n' + modified.join(', ')}>
60
+ {tt('source.localModified', { count: modified.length })}
61
+ </span>,
62
+ )
63
+ }
64
+ const combined = <span className={css.statusBadges}>{items}</span>
65
+ if (onCheck === undefined) return combined
56
66
  return (
57
67
  <button type='button' className={css.statusWrap} title={tt('source.checkHint')} onClick={onCheck}>
58
- {badges}
68
+ {combined}
59
69
  </button>
60
70
  )
61
71
  }
@@ -55,7 +55,7 @@ export interface MarketSyncDialogState {
55
55
  }
56
56
 
57
57
  /** Overlay + centered alert-dialog shell; outside click or Escape cancels. */
58
- function DialogShell(props: { onClose: () => void; children: ReactNode }): JSX.Element {
58
+ export function DialogShell(props: { onClose: () => void; children: ReactNode }): JSX.Element {
59
59
  useEffect(() => {
60
60
  const onKey = (event: KeyboardEvent): void => {
61
61
  if (event.key === 'Escape') props.onClose()
@@ -78,20 +78,31 @@ export function ConfirmDialog(props: {
78
78
  text: string
79
79
  /** Optional skill names listed between the text and the buttons. */
80
80
  items?: readonly string[]
81
+ /**
82
+ * 这些条目在磁盘上已被本地改过。列出来时逐个标记 —— 「会覆盖本地修改」
83
+ * 这句话只有在能指出是哪几个时才有用。
84
+ */
85
+ marked?: readonly string[]
81
86
  confirmLabel: string
82
87
  /** Danger-styled confirm button (destructive actions). */
83
88
  danger?: boolean
84
89
  onCancel: () => void
85
90
  onConfirm: () => void
86
91
  }): JSX.Element {
87
- const { title, text, items, confirmLabel, danger, onCancel, onConfirm } = props
92
+ const { title, text, items, marked, confirmLabel, danger, onCancel, onConfirm } = props
93
+ const markedSet = new Set(marked ?? [])
88
94
  return (
89
95
  <DialogShell onClose={onCancel}>
90
96
  <h3 className={css.dialogTitle}>{title}</h3>
91
97
  <p className={css.dialogText}>{text}</p>
92
98
  {items !== undefined ? (
93
99
  <ul className={css.dialogList}>
94
- {items.map((name) => <li key={name}><span className={css.rowNameText}>{name}</span></li>)}
100
+ {items.map((name) => (
101
+ <li key={name}>
102
+ <span className={css.rowNameText}>{name}</span>
103
+ {markedSet.has(name) ? <span className={css.statusBadge + ' ' + css.statusLocal} style={{ marginLeft: 6 }}>{tt('source.localModifiedBadge')}</span> : null}
104
+ </li>
105
+ ))}
95
106
  </ul>
96
107
  ) : null}
97
108
  <div className={css.dialogActions}>
@@ -70,6 +70,10 @@
70
70
 
71
71
  .empty { font-size: 13px; color: var(--hub-text-3); padding: 20px 0; text-align: center; }
72
72
  .diagRow { font-size: 12px; padding: 9px 14px; border-left: 3px solid var(--hub-warn); background: rgba(210,153,34,.06); }
73
+ /* 审计条目与发现诊断共用一块 UI:block 级换成红色边框,一眼分得出「文件没加载」
74
+ 和「文件能加载但正文有问题」。 */
75
+ .diagRowBlock { border-left-color: var(--hub-danger); background: rgba(209,36,47,.06); }
76
+ .auditBadge { display: inline-block; margin-right: 6px; padding: 0 5px; border-radius: 4px; font-size: 11px; font-weight: 700; color: var(--hub-danger); background: rgba(209,36,47,.12); vertical-align: 1px; }
73
77
  .diagPath { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: var(--hub-text-2); word-break: break-all; }
74
78
  .diagReason { color: var(--hub-text-3); margin-top: 1px; }
75
79
  .updateLink { color: inherit; font-weight: 600; text-decoration: underline; }
@@ -163,6 +167,8 @@
163
167
  .statusOk { color: var(--hub-success); border-color: currentColor; background: transparent; }
164
168
  .statusUpdated { color: var(--hub-warn); border-color: currentColor; background: rgba(210,153,34,.08); }
165
169
  .statusError { color: var(--hub-danger); border-color: currentColor; background: rgba(209,36,47,.08); }
170
+ /* 本地已改动:既不是上游有更新,也不是错误 —— 是「这份磁盘内容和你导入的不一样」。 */
171
+ .statusLocal { color: var(--hub-accent); border-color: currentColor; background: rgba(47,129,247,.08); }
166
172
 
167
173
  /* Clickable status badge (group-header re-check affordance). */
168
174
  .statusWrap { display: inline-flex; gap: 4px; align-items: center; border: none; background: none; padding: 0; cursor: pointer; font-family: inherit; }
@@ -212,6 +212,6 @@ export function useSkillHub(api: SkillHubApi) {
212
212
  clearScan: marketFlow.clearScan,
213
213
  checkMarket: marketFlow.checkMarket, loadMarketStats: marketFlow.loadMarketStats,
214
214
  syncMarketSource: marketFlow.syncMarketSource, confirmMarketSync: marketFlow.confirmMarketSync,
215
- updateAll: marketFlow.updateAll, create: catalogFlow.create,
215
+ updateAll: marketFlow.updateAll, create: catalogFlow.create, reloadCatalog: catalogFlow.load,
216
216
  }
217
217
  }
package/src/index.ts CHANGED
@@ -134,8 +134,8 @@ const SECTION_ORDER = 152
134
134
 
135
135
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
136
136
  export const SKILL_HUB_GUIDANCE = [
137
- '本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由受 dsh 自身的 /api 信任栅栏保护(回环、监听器绑定地址、--trusted-host 声明的 authority,外加浏览器会话认证),因此反代部署下与 GUI 同一套放行规则。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。',
138
- 'The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → "Skills" is the management page; the plugin\'s configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with "check all" and "update all" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; the routes sit behind dsh\'s own /api trust fence (loopback, the listener\'s bind address, any --trusted-host authority, plus browser-session authentication), so a reverse-proxied deployment is judged exactly like the GUI around it. When the user mentions "skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill", this plugin is what they mean — collaborate accordingly.'
137
+ '本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;内容审计(隐藏字符、危险命令、凭据路径与体积上限;市场导入命中阻断级规则即整包拒绝);技能目录公告预算(一层渐进加载注入的字符数与 token 估算);本地内容哈希比对(标出被自己改过、同步会覆盖的技能);从其他 agent 的技能目录只读导入(Claude Code / Codex / Cursor / Gemini CLI 等);新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由受 dsh 自身的 /api 信任栅栏保护(回环、监听器绑定地址、--trusted-host 声明的 authority,外加浏览器会话认证),因此反代部署下与 GUI 同一套放行规则。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。',
138
+ 'The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → "Skills" is the management page; the plugin\'s configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with "check all" and "update all" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics plus a content audit (hidden characters, dangerous commands, credential paths, size caps — a market import with a blocking finding is refused as a whole); the skill-catalog announcement budget (characters and estimated tokens for the first progressive-disclosure layer); local content-hash comparison (flags skills you edited that a sync would overwrite); read-only import from other agents\' skill folders (Claude Code / Codex / Cursor / Gemini CLI and friends); new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; the routes sit behind dsh\'s own /api trust fence (loopback, the listener\'s bind address, any --trusted-host authority, plus browser-session authentication), so a reverse-proxied deployment is judged exactly like the GUI around it. When the user mentions "skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill", this plugin is what they mean — collaborate accordingly.'
139
139
  ].join('\n\n')
140
140
 
141
141
  /**
@@ -49,4 +49,6 @@ export const SKILL_HUB_API = {
49
49
  diagnosticFix: '/api/skill-hub/diagnostic/fix',
50
50
  marketSourceVersions: '/api/skill-hub/market/source/versions',
51
51
  marketStats: '/api/skill-hub/market/stats',
52
+ foreign: '/api/skill-hub/foreign',
53
+ foreignImport: '/api/skill-hub/foreign/import',
52
54
  } as const