dsh-plugin-capabilities 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +863 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +1514 -0
- package/lib/index.js.map +7 -0
- package/package.json +64 -0
- package/src/agents.test.ts +105 -0
- package/src/agents.ts +135 -0
- package/src/client/McpTab.tsx +411 -0
- package/src/client/SkillsTab.tsx +301 -0
- package/src/client/css.ts +47 -0
- package/src/client/desktop-bridge.d.ts +12 -0
- package/src/client/index.ts +108 -0
- package/src/client/locales.ts +131 -0
- package/src/client/primitives.d.ts +57 -0
- package/src/http.ts +42 -0
- package/src/index.ts +56 -0
- package/src/mcp.test.ts +85 -0
- package/src/mcp.ts +170 -0
- package/src/profile.ts +17 -0
- package/src/routes.ts +285 -0
- package/src/skills.test.ts +73 -0
- package/src/skills.ts +81 -0
- package/src/smoke.test.ts +150 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/** Settings → Plugins “技能/Skills” tab: view the discovered catalog, edit the
|
|
2
|
+
* user-owned root. Pure presentation-layer — data arrives through props. */
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import type { ReactElement } from 'react'
|
|
6
|
+
import { Button, IconRefreshOutline14, IconSkillOutline16, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
7
|
+
import { CSS } from './css.ts'
|
|
8
|
+
import type { Translate } from './index.ts'
|
|
9
|
+
|
|
10
|
+
/** One skill as the host reports it (plus the editable flag the route adds). */
|
|
11
|
+
export interface SkillRowView {
|
|
12
|
+
name: string
|
|
13
|
+
description: string
|
|
14
|
+
whenToUse?: string
|
|
15
|
+
invocation: { modelInvocable: boolean; userInvocable: boolean }
|
|
16
|
+
source: string
|
|
17
|
+
provider: string
|
|
18
|
+
editable: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SkillsInjected {
|
|
22
|
+
list(): Promise<{ skills: SkillRowView[] }>
|
|
23
|
+
get(name: string): Promise<{ content: string }>
|
|
24
|
+
save(input: { name: string; description: string; whenToUse?: string; modelInvocable: boolean; userInvocable: boolean; content: string }): Promise<{ ok: boolean }>
|
|
25
|
+
remove(name: string): Promise<{ ok: boolean }>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Editor dialog state; null when closed. */
|
|
29
|
+
interface EditorState {
|
|
30
|
+
mode: 'create' | 'edit' | 'view'
|
|
31
|
+
name: string
|
|
32
|
+
description: string
|
|
33
|
+
whenToUse: string
|
|
34
|
+
modelInvocable: boolean
|
|
35
|
+
userInvocable: boolean
|
|
36
|
+
content: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SOURCE_KEYS: Record<string, string> = {
|
|
40
|
+
'project-dsh': 'sourceProjectDsh',
|
|
41
|
+
'project-agents': 'sourceProjectAgents',
|
|
42
|
+
'user-dsh': 'sourceUserDsh',
|
|
43
|
+
'user-agents': 'sourceUserAgents',
|
|
44
|
+
runtime: 'sourceRuntime',
|
|
45
|
+
bundled: 'sourceBundled',
|
|
46
|
+
custom: 'sourceCustom',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): ReactElement {
|
|
50
|
+
const { t, injected } = props
|
|
51
|
+
const [skills, setSkills] = useState<SkillRowView[] | null>(null)
|
|
52
|
+
const [editor, setEditor] = useState<EditorState | null>(null)
|
|
53
|
+
const [confirmName, setConfirmName] = useState<string | null>(null)
|
|
54
|
+
const [busy, setBusy] = useState(false)
|
|
55
|
+
const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
|
|
56
|
+
const [formError, setFormError] = useState<string | null>(null)
|
|
57
|
+
const [reload, setReload] = useState(0)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
let current = true
|
|
61
|
+
void injected.list().then(
|
|
62
|
+
(body) => { if (current) setSkills(body.skills) },
|
|
63
|
+
(error: Error) => { if (current) { setSkills([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) } },
|
|
64
|
+
)
|
|
65
|
+
return () => { current = false }
|
|
66
|
+
}, [injected, reload, t])
|
|
67
|
+
|
|
68
|
+
const openCreate = (): void => {
|
|
69
|
+
setFormError(null)
|
|
70
|
+
setEditor({ mode: 'create', name: '', description: '', whenToUse: '', modelInvocable: true, userInvocable: true, content: '' })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const openExisting = async (skill: SkillRowView): Promise<void> => {
|
|
74
|
+
setBusy(true)
|
|
75
|
+
setFormError(null)
|
|
76
|
+
try {
|
|
77
|
+
const body = await injected.get(skill.name)
|
|
78
|
+
setEditor({
|
|
79
|
+
mode: skill.editable ? 'edit' : 'view',
|
|
80
|
+
name: skill.name,
|
|
81
|
+
description: skill.description,
|
|
82
|
+
whenToUse: skill.whenToUse ?? '',
|
|
83
|
+
modelInvocable: skill.invocation.modelInvocable,
|
|
84
|
+
userInvocable: skill.invocation.userInvocable,
|
|
85
|
+
content: body.content,
|
|
86
|
+
})
|
|
87
|
+
} catch (error) {
|
|
88
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
89
|
+
} finally {
|
|
90
|
+
setBusy(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* After a write/delete, the watched directory takes a moment to invalidate
|
|
96
|
+
* the catalog — poll until the change is visible (bounded), then stop.
|
|
97
|
+
*/
|
|
98
|
+
const refreshUntil = (predicate: (skills: SkillRowView[]) => boolean, timeoutMs = 10_000): void => {
|
|
99
|
+
const deadline = Date.now() + timeoutMs
|
|
100
|
+
const tick = (): void => {
|
|
101
|
+
if (Date.now() > deadline) return
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
void injected.list().then(
|
|
104
|
+
(body) => {
|
|
105
|
+
setSkills(body.skills)
|
|
106
|
+
if (!predicate(body.skills)) tick()
|
|
107
|
+
},
|
|
108
|
+
() => tick(),
|
|
109
|
+
)
|
|
110
|
+
}, 1000)
|
|
111
|
+
}
|
|
112
|
+
tick()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const doSave = async (): Promise<void> => {
|
|
116
|
+
if (editor === null) return
|
|
117
|
+
setBusy(true)
|
|
118
|
+
setFormError(null)
|
|
119
|
+
try {
|
|
120
|
+
const name = editor.name.trim()
|
|
121
|
+
await injected.save({
|
|
122
|
+
name,
|
|
123
|
+
description: editor.description,
|
|
124
|
+
whenToUse: editor.whenToUse.trim() === '' ? undefined : editor.whenToUse,
|
|
125
|
+
modelInvocable: editor.modelInvocable,
|
|
126
|
+
userInvocable: editor.userInvocable,
|
|
127
|
+
content: editor.content,
|
|
128
|
+
})
|
|
129
|
+
setOutcome({ ok: true, text: t('saved') })
|
|
130
|
+
setEditor(null)
|
|
131
|
+
refreshUntil(skills => skills.some(skill => skill.name === name))
|
|
132
|
+
} catch (error) {
|
|
133
|
+
setFormError(String(error instanceof Error ? error.message : error))
|
|
134
|
+
} finally {
|
|
135
|
+
setBusy(false)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const doDelete = async (): Promise<void> => {
|
|
140
|
+
if (confirmName === null) return
|
|
141
|
+
const name = confirmName
|
|
142
|
+
setBusy(true)
|
|
143
|
+
try {
|
|
144
|
+
await injected.remove(name)
|
|
145
|
+
setOutcome({ ok: true, text: t('saved') })
|
|
146
|
+
refreshUntil(skills => !skills.some(skill => skill.name === name))
|
|
147
|
+
} catch (error) {
|
|
148
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
149
|
+
} finally {
|
|
150
|
+
setBusy(false)
|
|
151
|
+
setConfirmName(null)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const readOnly = editor?.mode === 'view'
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="dpc-section">
|
|
159
|
+
<style>{CSS}</style>
|
|
160
|
+
|
|
161
|
+
<div className="dpc-head">
|
|
162
|
+
<IconSkillOutline16 aria-hidden="true" />
|
|
163
|
+
<h3>{t('skillsTitle')}</h3>
|
|
164
|
+
<span className="dpc-spacer" />
|
|
165
|
+
<Button variant="primary" size="sm" onClick={openCreate}>{t('newSkill')}</Button>
|
|
166
|
+
</div>
|
|
167
|
+
<p className="dpc-intro">{t('skillsIntro')}</p>
|
|
168
|
+
|
|
169
|
+
{outcome !== null && (
|
|
170
|
+
<div className="dpc-banner" data-kind={outcome.ok ? 'ok' : 'error'} role="status">
|
|
171
|
+
<StateDot state={outcome.ok ? 'done' : 'error'} size={10} />
|
|
172
|
+
<div className="dpc-bannerBody"><span>{outcome.text}</span></div>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
<div className="dpc-listHead">
|
|
177
|
+
<h3>{t('skillsTab')}</h3>
|
|
178
|
+
{skills !== null && <span className="dpc-count">{skills.length}</span>}
|
|
179
|
+
<span className="dpc-spacer" />
|
|
180
|
+
<button type="button" className="dpc-refresh" aria-label={t('view')} title={t('view')} disabled={busy} onClick={() => setReload((value) => value + 1)}>
|
|
181
|
+
<IconRefreshOutline14 size={14} aria-hidden="true" />
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{skills === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
186
|
+
{skills !== null && skills.length === 0 && <p className="dpc-empty">{t('emptySkills')}</p>}
|
|
187
|
+
{skills !== null && skills.length > 0 && (
|
|
188
|
+
<ul className="dpc-cards">
|
|
189
|
+
{skills.map((skill) => (
|
|
190
|
+
<li className="dpc-card" key={`${skill.source}/${skill.name}`}>
|
|
191
|
+
<div className="dpc-cardTop">
|
|
192
|
+
<strong className="dpc-cardTitle" title={skill.name}>{skill.name}</strong>
|
|
193
|
+
<span className="dpc-tag" data-kind="source">{t(SOURCE_KEYS[skill.source] ?? 'sourceCustom')}</span>
|
|
194
|
+
</div>
|
|
195
|
+
<p className="dpc-cardDesc" title={skill.description}>{skill.description}</p>
|
|
196
|
+
<div className="dpc-cardRow">
|
|
197
|
+
{!skill.invocation.modelInvocable && <span className="dpc-tag">⚙</span>}
|
|
198
|
+
{!skill.invocation.userInvocable && <span className="dpc-tag">/</span>}
|
|
199
|
+
<span className="dpc-spacer" />
|
|
200
|
+
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void openExisting(skill)}>
|
|
201
|
+
{skill.editable ? t('edit') : t('view')}
|
|
202
|
+
</Button>
|
|
203
|
+
{skill.editable && (
|
|
204
|
+
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmName(skill.name)}>{t('delete')}</Button>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</li>
|
|
208
|
+
))}
|
|
209
|
+
</ul>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
<Modal
|
|
213
|
+
open={editor !== null}
|
|
214
|
+
onClose={() => setEditor(null)}
|
|
215
|
+
title={editor === null ? '' : editor.mode === 'create' ? t('newSkill') : editor.mode === 'edit' ? t('editSkill') : t('viewSkill')}
|
|
216
|
+
>
|
|
217
|
+
{editor !== null && (
|
|
218
|
+
<div className="dpc-form">
|
|
219
|
+
<label className="dpc-label">
|
|
220
|
+
<span>{t('skillName')}</span>
|
|
221
|
+
<input
|
|
222
|
+
className="dpc-input"
|
|
223
|
+
value={editor.name}
|
|
224
|
+
disabled={readOnly || editor.mode === 'edit'}
|
|
225
|
+
onChange={(event) => setEditor({ ...editor, name: event.target.value })}
|
|
226
|
+
/>
|
|
227
|
+
</label>
|
|
228
|
+
<label className="dpc-label">
|
|
229
|
+
<span>{t('skillDescription')}</span>
|
|
230
|
+
<input
|
|
231
|
+
className="dpc-input"
|
|
232
|
+
value={editor.description}
|
|
233
|
+
disabled={readOnly}
|
|
234
|
+
onChange={(event) => setEditor({ ...editor, description: event.target.value })}
|
|
235
|
+
/>
|
|
236
|
+
</label>
|
|
237
|
+
<label className="dpc-label">
|
|
238
|
+
<span>{t('skillWhenToUse')}</span>
|
|
239
|
+
<input
|
|
240
|
+
className="dpc-input"
|
|
241
|
+
value={editor.whenToUse}
|
|
242
|
+
disabled={readOnly}
|
|
243
|
+
onChange={(event) => setEditor({ ...editor, whenToUse: event.target.value })}
|
|
244
|
+
/>
|
|
245
|
+
</label>
|
|
246
|
+
<div className="dpc-checks">
|
|
247
|
+
<label>
|
|
248
|
+
<input
|
|
249
|
+
type="checkbox"
|
|
250
|
+
checked={editor.modelInvocable}
|
|
251
|
+
disabled={readOnly}
|
|
252
|
+
onChange={(event) => setEditor({ ...editor, modelInvocable: event.target.checked })}
|
|
253
|
+
/>
|
|
254
|
+
{t('modelInvocable')}
|
|
255
|
+
</label>
|
|
256
|
+
<label>
|
|
257
|
+
<input
|
|
258
|
+
type="checkbox"
|
|
259
|
+
checked={editor.userInvocable}
|
|
260
|
+
disabled={readOnly}
|
|
261
|
+
onChange={(event) => setEditor({ ...editor, userInvocable: event.target.checked })}
|
|
262
|
+
/>
|
|
263
|
+
{t('userInvocable')}
|
|
264
|
+
</label>
|
|
265
|
+
</div>
|
|
266
|
+
<label className="dpc-label">
|
|
267
|
+
<span>{t('skillContent')}</span>
|
|
268
|
+
<textarea
|
|
269
|
+
className="dpc-textarea"
|
|
270
|
+
value={editor.content}
|
|
271
|
+
readOnly={readOnly}
|
|
272
|
+
onChange={(event) => setEditor({ ...editor, content: event.target.value })}
|
|
273
|
+
/>
|
|
274
|
+
</label>
|
|
275
|
+
{formError !== null && <p className="dpc-formError">{formError}</p>}
|
|
276
|
+
<div className="dpc-cardRow">
|
|
277
|
+
<span className="dpc-spacer" />
|
|
278
|
+
<Button variant="ghost" onClick={() => setEditor(null)}>{readOnly ? t('close') : t('cancel')}</Button>
|
|
279
|
+
{!readOnly && <Button variant="primary" disabled={busy} onClick={() => void doSave()}>{t('save')}</Button>}
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</Modal>
|
|
284
|
+
|
|
285
|
+
<Modal
|
|
286
|
+
open={confirmName !== null}
|
|
287
|
+
onClose={() => setConfirmName(null)}
|
|
288
|
+
title={t('confirmDelete')}
|
|
289
|
+
description={confirmName ?? undefined}
|
|
290
|
+
footer={
|
|
291
|
+
<>
|
|
292
|
+
<Button variant="ghost" onClick={() => setConfirmName(null)}>{t('cancel')}</Button>
|
|
293
|
+
<Button variant="primary" disabled={busy} onClick={() => void doDelete()}>{t('delete')}</Button>
|
|
294
|
+
</>
|
|
295
|
+
}
|
|
296
|
+
>
|
|
297
|
+
<p>{t('deleteWarn')}</p>
|
|
298
|
+
</Modal>
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Shared scoped stylesheet for the capabilities tabs — rides the host's
|
|
2
|
+
* --dsw-* tokens (same design language as the plugin-inventory and install
|
|
3
|
+
* tabs) so light and dark themes both stay correct. Prefix: dpc-. */
|
|
4
|
+
|
|
5
|
+
export const CSS = `
|
|
6
|
+
.dpc-section{display:flex;flex-direction:column;gap:14px;width:100%;max-width:760px;color:var(--dsw-alias-label-primary)}
|
|
7
|
+
.dpc-head{display:flex;align-items:center;gap:8px}
|
|
8
|
+
.dpc-head h3{margin:0;font-size:13px;line-height:20px;font-weight:600}
|
|
9
|
+
.dpc-head>svg{flex:none;color:var(--dsw-alias-label-tertiary)}
|
|
10
|
+
.dpc-intro{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
|
|
11
|
+
.dpc-listHead{display:flex;align-items:baseline;gap:7px;padding:0 2px;margin-top:2px}
|
|
12
|
+
.dpc-listHead h3{margin:0;font-size:13px;line-height:20px;font-weight:600}
|
|
13
|
+
.dpc-count{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
|
|
14
|
+
.dpc-spacer{flex:1}
|
|
15
|
+
.dpc-refresh{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}
|
|
16
|
+
.dpc-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
17
|
+
.dpc-refresh:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
|
|
18
|
+
.dpc-empty{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
|
|
19
|
+
.dpc-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:10px;margin:0;padding:0;list-style:none}
|
|
20
|
+
.dpc-card{display:flex;flex-direction:column;gap:8px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3);padding:12px 14px}
|
|
21
|
+
.dpc-card:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
22
|
+
.dpc-cardTop{display:flex;align-items:center;gap:8px}
|
|
23
|
+
.dpc-cardTitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px;font-weight:600;font-family:var(--ds-font-family-code)}
|
|
24
|
+
.dpc-cardDesc{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
25
|
+
.dpc-cardRow{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
26
|
+
.dpc-tag{display:inline-flex;align-items:center;min-height:20px;border-radius:5px;padding:1px 6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;white-space:nowrap}
|
|
27
|
+
.dpc-tag[data-kind='source']{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
|
|
28
|
+
.dpc-tag[data-kind='off']{background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,var(--dsw-alias-label-tertiary)) 12%,transparent);color:var(--dsw-alias-label-secondary)}
|
|
29
|
+
.dpc-cardActions{display:flex;align-items:center;gap:6px;margin-left:auto}
|
|
30
|
+
.dpc-banner{display:flex;align-items:flex-start;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);font-size:13px;line-height:20px}
|
|
31
|
+
.dpc-banner[data-kind='ok']{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 35%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent)}
|
|
32
|
+
.dpc-banner[data-kind='error']{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 35%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent)}
|
|
33
|
+
.dpc-banner[data-kind='info']{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 35%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}
|
|
34
|
+
.dpc-bannerBody{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
|
35
|
+
.dpc-bannerHint{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
36
|
+
.dpc-form{display:flex;flex-direction:column;gap:10px}
|
|
37
|
+
.dpc-label{display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
38
|
+
.dpc-label>span:first-child{color:var(--dsw-alias-label-tertiary)}
|
|
39
|
+
.dpc-input,.dpc-textarea,.dpc-select{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 10px;outline:none;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}
|
|
40
|
+
.dpc-textarea{min-height:120px;resize:vertical;font-family:var(--ds-font-family-code);line-height:1.5}
|
|
41
|
+
.dpc-textarea[data-short='true']{min-height:64px}
|
|
42
|
+
.dpc-input:focus-visible,.dpc-textarea:focus-visible,.dpc-select:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
|
|
43
|
+
.dpc-checks{display:flex;gap:16px;font-size:13px;line-height:20px}
|
|
44
|
+
.dpc-checks label{display:inline-flex;align-items:center;gap:6px;cursor:pointer}
|
|
45
|
+
.dpc-formError{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
|
|
46
|
+
@media(max-width:680px){.dpc-cards{grid-template-columns:minmax(0,1fr)}}
|
|
47
|
+
`
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** dsh-plugin-capabilities client entry: contributes the “技能/Skills” and
|
|
2
|
+
* “MCP” tabs into Settings → Plugins. Calls the host routes with fetch. */
|
|
3
|
+
|
|
4
|
+
import { createElement as h } from 'react'
|
|
5
|
+
import { McpTab, type McpInjected, type McpRow } from './McpTab.tsx'
|
|
6
|
+
import { SkillsTab, type SkillsInjected, type SkillRowView } from './SkillsTab.tsx'
|
|
7
|
+
import { zh, en } from './locales.ts'
|
|
8
|
+
|
|
9
|
+
/** Locale dictionary namespace owned by this plugin. */
|
|
10
|
+
export const NS = 'settings.pluginCapabilities'
|
|
11
|
+
|
|
12
|
+
/** The `t` function bound by the locale service. */
|
|
13
|
+
export interface Translate {
|
|
14
|
+
(key: string): string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Minimal structural subset of the slots service. */
|
|
18
|
+
interface SlotsService {
|
|
19
|
+
inject(slot: string, register: () => unknown): void
|
|
20
|
+
register(meta: Record<string, unknown>, component: () => unknown): unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal structural subset of the locale service. */
|
|
24
|
+
interface LocaleService {
|
|
25
|
+
register(namespace: string, dicts: { zh: Record<string, string>; en: Record<string, string> }): unknown
|
|
26
|
+
bind(namespace: string): Translate
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The client cordis context this plugin relies on (structural). */
|
|
30
|
+
interface CapabilitiesClientContext {
|
|
31
|
+
effect(callback: () => unknown, label?: string): void
|
|
32
|
+
locale: LocaleService
|
|
33
|
+
slots: SlotsService
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Same-origin fetch of the manager's host routes. */
|
|
37
|
+
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
38
|
+
const response = await fetch(path, init)
|
|
39
|
+
const body = (await response.json()) as T & { error?: string }
|
|
40
|
+
if (!response.ok) throw new Error(body.error ?? `HTTP ${response.status}`)
|
|
41
|
+
return body
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const post = (path: string, body: unknown): Promise<unknown> =>
|
|
45
|
+
fetchJson(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
46
|
+
|
|
47
|
+
export const name = 'dsh-plugin-capabilities'
|
|
48
|
+
export const inject = ['slots', 'locale']
|
|
49
|
+
|
|
50
|
+
export interface SkillBody { content: string }
|
|
51
|
+
|
|
52
|
+
/** One foreign-agent server from the import scan. */
|
|
53
|
+
export interface ImportedServerView {
|
|
54
|
+
agent: 'claude-code' | 'codex'
|
|
55
|
+
name: string
|
|
56
|
+
transport: 'stdio' | 'streamable-http'
|
|
57
|
+
command?: string
|
|
58
|
+
args?: string[]
|
|
59
|
+
env?: Record<string, string>
|
|
60
|
+
url?: string
|
|
61
|
+
headers?: Record<string, string>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function apply(ctx: CapabilitiesClientContext): void {
|
|
65
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-plugin-capabilities: dictionaries')
|
|
66
|
+
const t = ctx.locale.bind(NS)
|
|
67
|
+
|
|
68
|
+
const skillsInjected: SkillsInjected = {
|
|
69
|
+
list: () => fetchJson<{ skills: SkillRowView[] }>('/dsh-plugin-capabilities/skills'),
|
|
70
|
+
get: (name: string) => fetchJson<SkillBody>(`/dsh-plugin-capabilities/skill?name=${encodeURIComponent(name)}`),
|
|
71
|
+
save: (input: unknown) => post('/dsh-plugin-capabilities/skill/save', input) as Promise<{ ok: boolean }>,
|
|
72
|
+
remove: (name: string) => post('/dsh-plugin-capabilities/skill/delete', { name }) as Promise<{ ok: boolean }>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mcpInjected: McpInjected = {
|
|
76
|
+
list: () => fetchJson<{ servers: McpRow[] }>('/dsh-plugin-capabilities/mcp'),
|
|
77
|
+
save: (input: unknown) => post('/dsh-plugin-capabilities/mcp/save', input) as Promise<{ ok: boolean; id: string }>,
|
|
78
|
+
toggle: (id: string, disabled: boolean) => post('/dsh-plugin-capabilities/mcp/toggle', { id, disabled }) as Promise<{ ok: boolean }>,
|
|
79
|
+
remove: (id: string) => post('/dsh-plugin-capabilities/mcp/remove', { id }) as Promise<{ ok: boolean }>,
|
|
80
|
+
scanImport: () => fetchJson<{ servers: ImportedServerView[]; existing: string[] }>('/dsh-plugin-capabilities/import/scan'),
|
|
81
|
+
applyImport: (items: Array<{ agent: string; name: string }>) =>
|
|
82
|
+
post('/dsh-plugin-capabilities/import/apply', { items }) as Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>,
|
|
83
|
+
restart: (): void => { window.dshDesktop?.restartSidecar?.() },
|
|
84
|
+
desktop: window.dshDesktop !== undefined,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ctx.slots.inject('settings.plugins.tab', () => {
|
|
88
|
+
return ctx.slots.register({
|
|
89
|
+
name: 'settings.plugins.tab',
|
|
90
|
+
id: 'capabilities-skills',
|
|
91
|
+
order: 30,
|
|
92
|
+
label: () => t('skillsTab'),
|
|
93
|
+
locale: NS,
|
|
94
|
+
inject: () => skillsInjected,
|
|
95
|
+
}, () => h(SkillsTab, { t, injected: skillsInjected }))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
ctx.slots.inject('settings.plugins.tab', () => {
|
|
99
|
+
return ctx.slots.register({
|
|
100
|
+
name: 'settings.plugins.tab',
|
|
101
|
+
id: 'capabilities-mcp',
|
|
102
|
+
order: 40,
|
|
103
|
+
label: () => t('mcpTab'),
|
|
104
|
+
locale: NS,
|
|
105
|
+
inject: () => mcpInjected,
|
|
106
|
+
}, () => h(McpTab, { t, injected: mcpInjected }))
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** zh/en dictionaries for the Settings capabilities tabs. */
|
|
2
|
+
|
|
3
|
+
export const zh = {
|
|
4
|
+
skillsTab: '技能',
|
|
5
|
+
mcpTab: 'MCP',
|
|
6
|
+
skillsTitle: '技能管理',
|
|
7
|
+
skillsIntro: '查看与编辑 dsh 发现的技能;用户级技能(DSH_HOME/skills)可在此新建、修改、删除,文件被监听,保存即生效。若存在 ~/.claude/skills 或 ~/.codex/skills,会自动纳入扫描(零拷贝、实时同步)。',
|
|
8
|
+
newSkill: '新建技能',
|
|
9
|
+
editSkill: '编辑技能',
|
|
10
|
+
viewSkill: '查看技能',
|
|
11
|
+
skillName: '名称(kebab-case)',
|
|
12
|
+
skillDescription: '描述',
|
|
13
|
+
skillWhenToUse: '使用时机(可选)',
|
|
14
|
+
skillContent: '正文(Markdown)',
|
|
15
|
+
modelInvocable: '允许模型调用',
|
|
16
|
+
userInvocable: '允许用户 / 调用',
|
|
17
|
+
save: '保存',
|
|
18
|
+
cancel: '取消',
|
|
19
|
+
delete: '删除',
|
|
20
|
+
edit: '编辑',
|
|
21
|
+
view: '查看',
|
|
22
|
+
close: '关闭',
|
|
23
|
+
emptySkills: '还没有发现任何技能',
|
|
24
|
+
loading: '载入中…',
|
|
25
|
+
source: '来源',
|
|
26
|
+
sourceProjectDsh: '项目',
|
|
27
|
+
sourceProjectAgents: '项目',
|
|
28
|
+
sourceUserDsh: '用户',
|
|
29
|
+
sourceUserAgents: '用户',
|
|
30
|
+
sourceRuntime: '运行时',
|
|
31
|
+
sourceBundled: '内置',
|
|
32
|
+
sourceCustom: '自定义',
|
|
33
|
+
readOnly: '只读',
|
|
34
|
+
confirmDelete: '确认删除技能?',
|
|
35
|
+
deleteWarn: '将删除 DSH_HOME/skills 下的整个技能目录,此操作不可撤销。',
|
|
36
|
+
saved: '已保存,技能目录被监听,稍候即可在会话中使用。',
|
|
37
|
+
mcpTitle: 'MCP 服务器',
|
|
38
|
+
mcpIntro: '管理 profile 中的 MCP 服务器行(@deepseek-ai/dsh-mcp-client)。新增、修改或删除后需要重启 dsh 才生效。',
|
|
39
|
+
addServer: '添加服务器',
|
|
40
|
+
editServer: '编辑服务器',
|
|
41
|
+
serverName: '服务器名(工具名前缀)',
|
|
42
|
+
transport: '传输方式',
|
|
43
|
+
transportStdio: 'stdio(本地命令)',
|
|
44
|
+
transportHttp: 'streamable-http(URL)',
|
|
45
|
+
command: '命令',
|
|
46
|
+
args: '参数(每行一个)',
|
|
47
|
+
envPairs: '环境变量(每行 KEY=VALUE)',
|
|
48
|
+
url: 'URL',
|
|
49
|
+
headersPairs: '请求头(每行 KEY: VALUE)',
|
|
50
|
+
disabled: '已停用',
|
|
51
|
+
enabled: '启用中',
|
|
52
|
+
toggle: '停用/启用',
|
|
53
|
+
confirmRemove: '确认移除该服务器?',
|
|
54
|
+
removeWarn: '将从 profile 配置中移除这一行,重启 dsh 后其工具不再出现。',
|
|
55
|
+
emptyMcp: '还没有配置 MCP 服务器',
|
|
56
|
+
importServers: '从其他 Agent 导入',
|
|
57
|
+
importIntro: '扫描 Claude Code(~/.claude.json)与 Codex(~/.codex/config.toml)的 MCP 服务器配置,勾选后导入为本 profile 的服务器行。',
|
|
58
|
+
importEmpty: '没有发现可导入的 MCP 服务器',
|
|
59
|
+
importExisting: '已存在',
|
|
60
|
+
importSelected: '导入选中项',
|
|
61
|
+
restartNeeded: '配置已写入,重启 dsh 后生效。',
|
|
62
|
+
restartDesktopHint: '重启由桌面应用负责:托盘菜单「重启服务」。',
|
|
63
|
+
restartOtherHint: '重启方式:关闭当前 dsh 进程后重新运行。',
|
|
64
|
+
restartNow: '重启服务',
|
|
65
|
+
failed: '操作失败',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const en = {
|
|
69
|
+
skillsTab: 'Skills',
|
|
70
|
+
mcpTab: 'MCP',
|
|
71
|
+
skillsTitle: 'Skills',
|
|
72
|
+
skillsIntro: 'View and edit the skills dsh discovers; user-level skills (DSH_HOME/skills) can be created, edited, and deleted here — the directory is watched, saves apply without a restart. ~/.claude/skills and ~/.codex/skills are scanned too when present (zero-copy, live-synced).',
|
|
73
|
+
newSkill: 'New skill',
|
|
74
|
+
editSkill: 'Edit skill',
|
|
75
|
+
viewSkill: 'View skill',
|
|
76
|
+
skillName: 'Name (kebab-case)',
|
|
77
|
+
skillDescription: 'Description',
|
|
78
|
+
skillWhenToUse: 'When to use (optional)',
|
|
79
|
+
skillContent: 'Body (Markdown)',
|
|
80
|
+
modelInvocable: 'Model-invocable',
|
|
81
|
+
userInvocable: 'User-invocable (/)',
|
|
82
|
+
save: 'Save',
|
|
83
|
+
cancel: 'Cancel',
|
|
84
|
+
delete: 'Delete',
|
|
85
|
+
edit: 'Edit',
|
|
86
|
+
view: 'View',
|
|
87
|
+
close: 'Close',
|
|
88
|
+
emptySkills: 'No skills discovered yet',
|
|
89
|
+
loading: 'Loading…',
|
|
90
|
+
source: 'Source',
|
|
91
|
+
sourceProjectDsh: 'project',
|
|
92
|
+
sourceProjectAgents: 'project',
|
|
93
|
+
sourceUserDsh: 'user',
|
|
94
|
+
sourceUserAgents: 'user',
|
|
95
|
+
sourceRuntime: 'runtime',
|
|
96
|
+
sourceBundled: 'bundled',
|
|
97
|
+
sourceCustom: 'custom',
|
|
98
|
+
readOnly: 'read-only',
|
|
99
|
+
confirmDelete: 'Delete this skill?',
|
|
100
|
+
deleteWarn: 'Removes the whole skill directory under DSH_HOME/skills. This cannot be undone.',
|
|
101
|
+
saved: 'Saved. The skills directory is watched; the skill is usable in sessions shortly.',
|
|
102
|
+
mcpTitle: 'MCP servers',
|
|
103
|
+
mcpIntro: 'Manage the MCP server rows (@deepseek-ai/dsh-mcp-client) in this profile. Additions, edits, and removals take effect after a dsh restart.',
|
|
104
|
+
addServer: 'Add server',
|
|
105
|
+
editServer: 'Edit server',
|
|
106
|
+
serverName: 'Server name (tool name prefix)',
|
|
107
|
+
transport: 'Transport',
|
|
108
|
+
transportStdio: 'stdio (local command)',
|
|
109
|
+
transportHttp: 'streamable-http (URL)',
|
|
110
|
+
command: 'Command',
|
|
111
|
+
args: 'Arguments (one per line)',
|
|
112
|
+
envPairs: 'Environment (KEY=VALUE per line)',
|
|
113
|
+
url: 'URL',
|
|
114
|
+
headersPairs: 'Headers (KEY: VALUE per line)',
|
|
115
|
+
disabled: 'Disabled',
|
|
116
|
+
enabled: 'Enabled',
|
|
117
|
+
toggle: 'Enable/disable',
|
|
118
|
+
confirmRemove: 'Remove this server?',
|
|
119
|
+
removeWarn: 'Removes the row from the profile configuration; its tools disappear after the next dsh restart.',
|
|
120
|
+
emptyMcp: 'No MCP servers configured yet',
|
|
121
|
+
importServers: 'Import from other agents',
|
|
122
|
+
importIntro: 'Scans Claude Code (~/.claude.json) and Codex (~/.codex/config.toml) MCP server configs; selected entries become server rows in this profile.',
|
|
123
|
+
importEmpty: 'No importable MCP servers found',
|
|
124
|
+
importExisting: 'already here',
|
|
125
|
+
importSelected: 'Import selected',
|
|
126
|
+
restartNeeded: 'Configuration written — restart dsh to apply.',
|
|
127
|
+
restartDesktopHint: 'The desktop app owns restarts: use the tray “Restart service”.',
|
|
128
|
+
restartOtherHint: 'Restart by closing this dsh process and running it again.',
|
|
129
|
+
restartNow: 'Restart service',
|
|
130
|
+
failed: 'Operation failed',
|
|
131
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Minimal ambient types for the primitives the browser half uses, matching
|
|
2
|
+
* @deepseek-ai/dsh-client-ui-primitives (provided by the host's frozen
|
|
3
|
+
* platform module table; never bundled). Only members used here are
|
|
4
|
+
* declared — keep in sync with the host package. */
|
|
5
|
+
|
|
6
|
+
declare module '@deepseek-ai/dsh-client-ui-primitives' {
|
|
7
|
+
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactElement, ReactNode, SVGProps } from 'react'
|
|
8
|
+
|
|
9
|
+
export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar'
|
|
10
|
+
export function Button(props: {
|
|
11
|
+
variant?: ButtonVariant
|
|
12
|
+
size?: 'md' | 'sm'
|
|
13
|
+
icon?: ReactNode
|
|
14
|
+
className?: string | undefined
|
|
15
|
+
children?: ReactNode
|
|
16
|
+
} & ButtonHTMLAttributes<HTMLButtonElement>): ReactElement
|
|
17
|
+
|
|
18
|
+
export function Input(props: {
|
|
19
|
+
icon?: ReactNode
|
|
20
|
+
className?: string
|
|
21
|
+
} & InputHTMLAttributes<HTMLInputElement>): ReactElement
|
|
22
|
+
|
|
23
|
+
export function Modal(props: {
|
|
24
|
+
open: boolean
|
|
25
|
+
onClose: () => void
|
|
26
|
+
title: string
|
|
27
|
+
closeLabel?: string
|
|
28
|
+
description?: string
|
|
29
|
+
children?: ReactNode
|
|
30
|
+
footer?: ReactNode
|
|
31
|
+
className?: string
|
|
32
|
+
contentClassName?: string
|
|
33
|
+
headless?: boolean
|
|
34
|
+
}): ReactElement | null
|
|
35
|
+
|
|
36
|
+
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
|
|
37
|
+
export function StateDot(props: {
|
|
38
|
+
state: StateDotState
|
|
39
|
+
size?: number | undefined
|
|
40
|
+
className?: string | undefined
|
|
41
|
+
}): ReactElement
|
|
42
|
+
|
|
43
|
+
export function IconRefreshOutline14(props: {
|
|
44
|
+
className?: string | undefined
|
|
45
|
+
size?: number | undefined
|
|
46
|
+
} & SVGProps<SVGSVGElement>): ReactElement
|
|
47
|
+
|
|
48
|
+
export function IconSkillOutline16(props: {
|
|
49
|
+
className?: string | undefined
|
|
50
|
+
size?: number | undefined
|
|
51
|
+
} & SVGProps<SVGSVGElement>): ReactElement
|
|
52
|
+
|
|
53
|
+
export function IconApiOutline14(props: {
|
|
54
|
+
className?: string | undefined
|
|
55
|
+
size?: number | undefined
|
|
56
|
+
} & SVGProps<SVGSVGElement>): ReactElement
|
|
57
|
+
}
|