dsh-skill-hub 0.3.27 → 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 +10 -2
  3. package/README.zh.md +10 -2
  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 +2 -2
  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
@@ -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
@@ -54,6 +54,60 @@ export interface DiagnosticEntry {
54
54
  reason: string
55
55
  /** Whether this diagnostic can be auto-fixed (e.g. unquoted colon). */
56
56
  fixable?: boolean
57
+ /**
58
+ * 条目来源。缺省 = 发现诊断(provider 跳过了这个文件);`security` = 内容审计
59
+ * (文件能被加载,但正文里有值得看一眼的东西)。两者共用同一块 UI,但
60
+ * security 条目不能「修复」,只能说清问题在哪一行。
61
+ */
62
+ kind?: 'security'
63
+ /** security 条目的严重度;发现诊断条目省略。 */
64
+ severity?: 'block' | 'warn'
65
+ }
66
+
67
+ /**
68
+ * 技能目录在 system prompt 里占用的公告预算。渐进加载的第一层只放
69
+ * name + description,但技能多了这一层本身就会顶到客户端的上限而被截断,
70
+ * 表现为「装了却不触发」—— 面板必须能看见这个数字。
71
+ */
72
+ export interface AnnouncementBudget {
73
+ /** 参与公告的技能数(已启用且 modelInvocable)。 */
74
+ skills: number
75
+ /** name + description + whenToUse 的总字符数。 */
76
+ chars: number
77
+ /** 粗略 token 估算(按 3 字符/token)。 */
78
+ approxTokens: number
79
+ /** 建议预算(字符),超出即 overBudget。 */
80
+ budget: number
81
+ overBudget: boolean
82
+ }
83
+
84
+ /**
85
+ * 技能目录的建议字符预算。取 8000:这是 Codex 在上下文窗口未知时对初始
86
+ * 技能清单的硬上限,也是目前公开客户端里最保守的一档。超出后尾部条目会被
87
+ * 静默丢弃(技能看起来装了,实际不触发)。
88
+ */
89
+ export const ANNOUNCEMENT_CHAR_BUDGET = 8000
90
+
91
+ /**
92
+ * 按「已启用且模型可调用」的技能估算公告成本。只统计一层渐进加载真正注入的
93
+ * 字段(name + description + whenToUse),不含正文 —— 正文是激活后才载入的,
94
+ * 不占常驻预算。
95
+ */
96
+ export function computeAnnouncementBudget(skills: readonly CatalogSkill[]): AnnouncementBudget {
97
+ let chars = 0
98
+ let count = 0
99
+ for (const skill of skills) {
100
+ if (!skill.invocation.modelInvocable) continue
101
+ count += 1
102
+ chars += skill.name.length + skill.description.length + (skill.whenToUse?.length ?? 0)
103
+ }
104
+ return {
105
+ skills: count,
106
+ chars,
107
+ approxTokens: Math.ceil(chars / 3),
108
+ budget: ANNOUNCEMENT_CHAR_BUDGET,
109
+ overBudget: chars > ANNOUNCEMENT_CHAR_BUDGET,
110
+ }
57
111
  }
58
112
 
59
113
  /** POST /api/skill-hub/diagnostic/fix — repair a fixable diagnostic in place. */
@@ -78,6 +132,8 @@ export interface CatalogResponse {
78
132
  disabled: DisabledSkill[]
79
133
  /** Files in the writable roots the provider ignores, with reasons. */
80
134
  diagnostics: DiagnosticEntry[]
135
+ /** 技能目录占用的公告预算(见 AnnouncementBudget)。 */
136
+ announcement: AnnouncementBudget
81
137
  /** Skill names that appeared in multiple roots (first wins, others hidden). Mirrors codex name_counts. */
82
138
  duplicateNames?: string[]
83
139
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 外部 agent 技能(收编)的线格式。
3
+ *
4
+ * 发现源是别的 agent 的用户级技能目录(Claude Code / Codex / Cursor / …),
5
+ * 它们一律只读:这里只描述「看到了什么」和「要收编哪一个」。
6
+ */
7
+
8
+ /** 一个被扫描的外部技能根。 */
9
+ export interface ForeignRootView {
10
+ agent: string
11
+ dir: string
12
+ /** 该目录在本机是否存在(不存在时 skills 里不会出现它的条目)。 */
13
+ present: boolean
14
+ }
15
+
16
+ /** 一条可收编的外部技能。 */
17
+ export interface ForeignSkillView {
18
+ name: string
19
+ description: string
20
+ agent: string
21
+ /** 发现文件绝对路径(只读展示)。 */
22
+ path: string
23
+ kind: 'directory' | 'flat'
24
+ /** 目标根已有同名技能,不可导入。 */
25
+ existing: boolean
26
+ }
27
+
28
+ /** GET /api/skill-hub/foreign */
29
+ export interface ForeignListResponse {
30
+ ok: true
31
+ roots: ForeignRootView[]
32
+ skills: ForeignSkillView[]
33
+ }
34
+
35
+ /** POST /api/skill-hub/foreign/import — 按 agent + 名字收编(不信任前端给的路径)。 */
36
+ export interface ForeignImportRequest {
37
+ items: Array<{ agent: string; name: string }>
38
+ }
39
+
40
+ export interface ForeignImportResponse {
41
+ ok: true
42
+ imported: string[]
43
+ /** 导入前被移入回收站的同名占位(已禁用的发现文件 / 空壳目录),可恢复。 */
44
+ replaced?: string[]
45
+ skipped: Array<{ name: string; reason: string }>
46
+ failed: Array<{ name: string; error: string }>
47
+ }
@@ -15,6 +15,12 @@ export interface SourceRecord {
15
15
  skills: string[]
16
16
  /** 仓库内路径 → 文件大小 的基线清单(差异检测用;缺失 = 无基线)。 */
17
17
  manifest?: Record<string, number>
18
+ /**
19
+ * 技能名 → 本地内容哈希(导入/同步落盘后立刻采集)。用于区分「上游变了」
20
+ * 与「本地被改过」:没有它,「全部更新」只能盲覆盖。缺失 = 该技能还没采集过
21
+ * (旧记录),比对时按「未知」处理,不误报为已修改。
22
+ */
23
+ localHashes?: Record<string, string>
18
24
  }
19
25
 
20
26
  /** 回收站条目:上游删除后跟进删除(移入 .trash)的技能。 */
@@ -77,6 +83,11 @@ export interface SourceCheckResult {
77
83
  throttled?: boolean
78
84
  /** 该来源此前没有快照(迁移/旧记录),本次已回填 commit,尚未有差异基线。 */
79
85
  unverified?: boolean
86
+ /**
87
+ * 本地磁盘内容与上次导入/同步时不一致的技能(用户自己改过)。只在检查
88
+ * 时本地能读到文件才判定;哈希未采集过(旧记录)的技能不出现在这里。
89
+ */
90
+ localModified?: string[]
80
91
  }
81
92
 
82
93
  /** POST /api/skill-hub/sources/check */
@@ -100,6 +111,8 @@ export interface SourceSyncResponse {
100
111
  commitSha: string
101
112
  /** 成功同步的技能。 */
102
113
  synced: string[]
114
+ /** 本次同步覆盖掉的、本地有改动的技能(改前哈希 ≠ 记录哈希)。 */
115
+ overwritten?: string[]
103
116
  /** 失败的技能。 */
104
117
  failed: Array<{ name: string; error: string }>
105
118
  }