dsh-plugin-capabilities 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/lib/client.js +936 -220
- package/lib/client.js.map +4 -4
- package/lib/index.js +817 -41
- package/lib/index.js.map +4 -4
- package/market/mcp.json +164 -0
- package/market/skills.json +36 -0
- package/package.json +2 -1
- package/src/client/CapabilitiesSection.tsx +11 -7
- package/src/client/MarketTab.tsx +263 -0
- package/src/client/McpTab.tsx +198 -0
- package/src/client/SkillsTab.tsx +261 -26
- package/src/client/css.ts +42 -0
- package/src/client/index.ts +58 -5
- package/src/client/locales.ts +112 -2
- package/src/index.ts +59 -19
- package/src/market.test.ts +57 -0
- package/src/market.ts +166 -0
- package/src/opener.ts +34 -0
- package/src/repos.test.ts +213 -0
- package/src/repos.ts +207 -0
- package/src/routes.ts +340 -6
- package/src/skills.test.ts +45 -1
- package/src/skills.ts +22 -1
- package/src/smoke.test.ts +54 -1
- package/src/state.test.ts +64 -0
- package/src/state.ts +109 -0
- package/src/tar.test.ts +100 -0
- package/src/tar.ts +154 -0
- package/src/types.ts +11 -1
package/src/client/McpTab.tsx
CHANGED
|
@@ -82,6 +82,128 @@ function mapToPairs(map: Record<string, string> | undefined, separator: string):
|
|
|
82
82
|
return Object.entries(map).map(([key, value]) => `${key}${separator}${value.includes('\n') ? JSON.stringify(value) : value}`).join('\n')
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** YAML double-quoted scalar (JSON syntax is valid YAML); plain for simple ids. */
|
|
86
|
+
function yamlScalar(value: string): string {
|
|
87
|
+
return /^[A-Za-z0-9_./@-]+$/.test(value) ? value : JSON.stringify(value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Shape the profile patch row exactly as the host would persist it (preview). */
|
|
91
|
+
export function mcpRowYaml(input: { serverName: string; transport: 'stdio' | 'streamable-http'; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string> }): string {
|
|
92
|
+
const name = input.serverName.trim() === '' ? 'server-name' : input.serverName.trim()
|
|
93
|
+
const lines = [
|
|
94
|
+
'- insert:',
|
|
95
|
+
` - id: mcp-${name}`,
|
|
96
|
+
" name: '@deepseek-ai/dsh-mcp-client'",
|
|
97
|
+
' config:',
|
|
98
|
+
` serverName: ${yamlScalar(name)}`,
|
|
99
|
+
` transport: ${input.transport}`,
|
|
100
|
+
]
|
|
101
|
+
if (input.transport === 'stdio') {
|
|
102
|
+
lines.push(` command: ${yamlScalar(input.command ?? '')}`)
|
|
103
|
+
const args = input.args ?? []
|
|
104
|
+
if (args.length > 0) {
|
|
105
|
+
lines.push(' args:')
|
|
106
|
+
for (const arg of args) lines.push(` - ${yamlScalar(arg)}`)
|
|
107
|
+
}
|
|
108
|
+
const env = input.env ?? {}
|
|
109
|
+
if (Object.keys(env).length > 0) {
|
|
110
|
+
lines.push(' env:')
|
|
111
|
+
for (const [key, value] of Object.entries(env)) lines.push(` ${yamlScalar(key)}: ${yamlScalar(value)}`)
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
lines.push(` url: ${yamlScalar(input.url ?? '')}`)
|
|
115
|
+
const headers = input.headers ?? {}
|
|
116
|
+
if (Object.keys(headers).length > 0) {
|
|
117
|
+
lines.push(' headers:')
|
|
118
|
+
for (const [key, value] of Object.entries(headers)) lines.push(` ${yamlScalar(key)}: ${yamlScalar(value)}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The equivalent mcpServers JSON a foreign config or docs page would show. */
|
|
125
|
+
export function mcpJsonExample(transport: 'stdio' | 'streamable-http'): string {
|
|
126
|
+
const entry = transport === 'stdio'
|
|
127
|
+
? {
|
|
128
|
+
command: 'npx',
|
|
129
|
+
args: ['-y', '@example/mcp-server'],
|
|
130
|
+
env: { API_KEY: 'value' },
|
|
131
|
+
}
|
|
132
|
+
: {
|
|
133
|
+
type: 'http',
|
|
134
|
+
url: 'https://example.com/mcp',
|
|
135
|
+
headers: { Authorization: 'Bearer <token>' },
|
|
136
|
+
}
|
|
137
|
+
return JSON.stringify({ mcpServers: { 'server-name': entry } }, null, 2)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fields parsed out of a pasted MCP JSON config (any common shape). */
|
|
141
|
+
export interface ParsedMcpJson {
|
|
142
|
+
serverName?: string
|
|
143
|
+
transport: 'stdio' | 'streamable-http'
|
|
144
|
+
command?: string
|
|
145
|
+
args?: string[]
|
|
146
|
+
env?: Record<string, string>
|
|
147
|
+
url?: string
|
|
148
|
+
headers?: Record<string, string>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Parse one MCP server from pasted JSON: a bare entry, a dsh row, or a
|
|
152
|
+
* `{"mcpServers": {…}}` wrapper (first entry wins). Returns the reason on bad input. */
|
|
153
|
+
export function parseMcpJson(text: string): ParsedMcpJson | { error: string } {
|
|
154
|
+
let parsed: unknown
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(text)
|
|
157
|
+
} catch {
|
|
158
|
+
return { error: 'not valid JSON' }
|
|
159
|
+
}
|
|
160
|
+
if (typeof parsed !== 'object' || parsed === null) return { error: 'expected a JSON object' }
|
|
161
|
+
let record = parsed as Record<string, unknown>
|
|
162
|
+
let nameFromWrapper: string | undefined
|
|
163
|
+
const wrapped = record.mcpServers ?? record.mcp_servers ?? record.servers
|
|
164
|
+
if (typeof wrapped === 'object' && wrapped !== null && !Array.isArray(wrapped)) {
|
|
165
|
+
const first = Object.entries(wrapped as Record<string, unknown>)[0]
|
|
166
|
+
if (first === undefined) return { error: 'mcpServers object is empty' }
|
|
167
|
+
nameFromWrapper = first[0]
|
|
168
|
+
if (typeof first[1] !== 'object' || first[1] === null) return { error: 'server entry is not an object' }
|
|
169
|
+
record = first[1] as Record<string, unknown>
|
|
170
|
+
}
|
|
171
|
+
const stringMap = (value: unknown): Record<string, string> | undefined => {
|
|
172
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
173
|
+
const out: Record<string, string> = {}
|
|
174
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
175
|
+
if (typeof entry === 'string') out[key] = entry
|
|
176
|
+
}
|
|
177
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
178
|
+
}
|
|
179
|
+
const args = Array.isArray(record.args) && record.args.every(entry => typeof entry === 'string')
|
|
180
|
+
? record.args as string[]
|
|
181
|
+
: undefined
|
|
182
|
+
const command = typeof record.command === 'string' ? record.command : undefined
|
|
183
|
+
const url = typeof record.url === 'string' ? record.url : undefined
|
|
184
|
+
const declared = typeof record.type === 'string' ? record.type : typeof record.transport === 'string' ? record.transport : undefined
|
|
185
|
+
const httpDeclared = declared === 'http' || declared === 'streamable-http' || declared === 'sse'
|
|
186
|
+
const transport: 'stdio' | 'streamable-http' = command !== undefined && !httpDeclared
|
|
187
|
+
? 'stdio'
|
|
188
|
+
: url !== undefined ? 'streamable-http' : httpDeclared ? 'streamable-http' : 'stdio'
|
|
189
|
+
if (transport === 'stdio' && command === undefined) return { error: 'stdio config needs a "command" field' }
|
|
190
|
+
if (transport === 'streamable-http' && url === undefined) return { error: 'http config needs a "url" field' }
|
|
191
|
+
const serverName = nameFromWrapper
|
|
192
|
+
?? (typeof record.serverName === 'string' ? record.serverName : undefined)
|
|
193
|
+
?? (typeof record.name === 'string' && record.name !== '@deepseek-ai/dsh-mcp-client' ? record.name : undefined)
|
|
194
|
+
const env = stringMap(record.env)
|
|
195
|
+
const headers = stringMap(record.headers)
|
|
196
|
+
return {
|
|
197
|
+
...(serverName !== undefined ? { serverName } : {}),
|
|
198
|
+
transport,
|
|
199
|
+
...(command !== undefined ? { command } : {}),
|
|
200
|
+
...(args !== undefined ? { args } : {}),
|
|
201
|
+
...(env !== undefined ? { env } : {}),
|
|
202
|
+
...(url !== undefined ? { url } : {}),
|
|
203
|
+
...(headers !== undefined ? { headers } : {}),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
85
207
|
export function McpTab(props: { t: Translate; injected: McpInjected }): ReactElement {
|
|
86
208
|
const { t, injected } = props
|
|
87
209
|
const [servers, setServers] = useState<McpRow[] | null>(null)
|
|
@@ -96,6 +218,8 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
96
218
|
const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
|
|
97
219
|
const [formError, setFormError] = useState<string | null>(null)
|
|
98
220
|
const [reload, setReload] = useState(0)
|
|
221
|
+
const [pasteJson, setPasteJson] = useState('')
|
|
222
|
+
const [pasteError, setPasteError] = useState<string | null>(null)
|
|
99
223
|
|
|
100
224
|
const openImport = async (): Promise<void> => {
|
|
101
225
|
setImportOpen(true)
|
|
@@ -150,11 +274,15 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
150
274
|
|
|
151
275
|
const openCreate = (): void => {
|
|
152
276
|
setFormError(null)
|
|
277
|
+
setPasteError(null)
|
|
278
|
+
setPasteJson('')
|
|
153
279
|
setEditor({ id: '', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
|
|
154
280
|
}
|
|
155
281
|
|
|
156
282
|
const openEdit = (row: McpRow): void => {
|
|
157
283
|
setFormError(null)
|
|
284
|
+
setPasteError(null)
|
|
285
|
+
setPasteJson('')
|
|
158
286
|
setEditor({
|
|
159
287
|
id: row.id,
|
|
160
288
|
serverName: row.serverName,
|
|
@@ -167,6 +295,40 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
167
295
|
})
|
|
168
296
|
}
|
|
169
297
|
|
|
298
|
+
/** Fill the form from pasted JSON (mcpServers wrapper, bare entry, dsh row). */
|
|
299
|
+
const doPasteFill = (): void => {
|
|
300
|
+
if (editor === null || pasteJson.trim() === '') return
|
|
301
|
+
const parsed = parseMcpJson(pasteJson)
|
|
302
|
+
if ('error' in parsed) {
|
|
303
|
+
setPasteError(parsed.error)
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
// Existing rows keep their identity (serverName + transport); a pasted
|
|
307
|
+
// config of the other transport cannot apply to them.
|
|
308
|
+
const lockIdentity = editor.id !== ''
|
|
309
|
+
if (lockIdentity && parsed.transport !== editor.transport) {
|
|
310
|
+
setPasteError(t('pasteTransportMismatch'))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
setPasteError(null)
|
|
314
|
+
setFormError(null)
|
|
315
|
+
setEditor({
|
|
316
|
+
...editor,
|
|
317
|
+
...(parsed.serverName !== undefined && !lockIdentity ? { serverName: parsed.serverName } : {}),
|
|
318
|
+
...(!lockIdentity ? { transport: parsed.transport } : {}),
|
|
319
|
+
...(parsed.transport === 'stdio'
|
|
320
|
+
? {
|
|
321
|
+
command: parsed.command ?? editor.command,
|
|
322
|
+
args: (parsed.args ?? []).join('\n'),
|
|
323
|
+
env: mapToPairs(parsed.env, '='),
|
|
324
|
+
}
|
|
325
|
+
: {
|
|
326
|
+
url: parsed.url ?? editor.url,
|
|
327
|
+
headers: mapToPairs(parsed.headers, ':'),
|
|
328
|
+
}),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
170
332
|
const doSave = async (): Promise<void> => {
|
|
171
333
|
if (editor === null) return
|
|
172
334
|
setBusy(true)
|
|
@@ -373,6 +535,42 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
373
535
|
</label>
|
|
374
536
|
</>
|
|
375
537
|
)}
|
|
538
|
+
<details className="dpc-format">
|
|
539
|
+
<summary>{t('formatTitle')}</summary>
|
|
540
|
+
<div className="dpc-form">
|
|
541
|
+
<label className="dpc-label">
|
|
542
|
+
<span>{t('formatPaste')}</span>
|
|
543
|
+
<textarea
|
|
544
|
+
className="dpc-textarea"
|
|
545
|
+
data-short="true"
|
|
546
|
+
placeholder={'{\n "mcpServers": { "name": { "command": "npx", "args": ["…"] } } }\n'}
|
|
547
|
+
value={pasteJson}
|
|
548
|
+
onChange={(event) => setPasteJson(event.target.value)}
|
|
549
|
+
/>
|
|
550
|
+
</label>
|
|
551
|
+
{pasteError !== null && <p className="dpc-formError">{pasteError}</p>}
|
|
552
|
+
<div className="dpc-cardRow">
|
|
553
|
+
<Button variant="outline" size="sm" disabled={pasteJson.trim() === ''} onClick={doPasteFill}>{t('formatFill')}</Button>
|
|
554
|
+
</div>
|
|
555
|
+
<p className="dpc-formatHint">{t('formatYamlHint')}</p>
|
|
556
|
+
<pre className="dpc-code">{mcpRowYaml({
|
|
557
|
+
serverName: editor.serverName,
|
|
558
|
+
transport: editor.transport,
|
|
559
|
+
...(editor.transport === 'stdio'
|
|
560
|
+
? {
|
|
561
|
+
command: editor.command.trim(),
|
|
562
|
+
args: editor.args.split(/\r?\n/).map(line => line.trim()).filter(line => line !== ''),
|
|
563
|
+
env: parsePairs(editor.env, '='),
|
|
564
|
+
}
|
|
565
|
+
: {
|
|
566
|
+
url: editor.url.trim(),
|
|
567
|
+
headers: parsePairs(editor.headers, ':'),
|
|
568
|
+
}),
|
|
569
|
+
})}</pre>
|
|
570
|
+
<p className="dpc-formatHint">{t('formatJsonHint')}</p>
|
|
571
|
+
<pre className="dpc-code">{mcpJsonExample(editor.transport)}</pre>
|
|
572
|
+
</div>
|
|
573
|
+
</details>
|
|
376
574
|
{formError !== null && <p className="dpc-formError">{formError}</p>}
|
|
377
575
|
<div className="dpc-cardRow">
|
|
378
576
|
<span className="dpc-spacer" />
|
package/src/client/SkillsTab.tsx
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
/** Settings
|
|
2
|
-
*
|
|
1
|
+
/** Settings “技能” tab: view the discovered catalog, edit the user-owned
|
|
2
|
+
* root, toggle skills on/off, open their folders, and manage the custom
|
|
3
|
+
* skill repositories (local paths and GitHub checkouts) feeding the catalog.
|
|
4
|
+
* Pure presentation-layer — data arrives through props. */
|
|
3
5
|
|
|
4
6
|
import { useEffect, useState } from 'react'
|
|
5
7
|
import type { ReactElement } from 'react'
|
|
6
8
|
import { Button, IconRefreshOutline14, IconSkillOutline16, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
7
9
|
import { CSS } from './css.ts'
|
|
8
|
-
import type { Translate } from './index.ts'
|
|
10
|
+
import type { OpenTarget, Translate } from './index.ts'
|
|
11
|
+
import type { RootRowView } from './MarketTab.tsx'
|
|
9
12
|
|
|
10
|
-
/** One skill as the host reports it (plus the
|
|
13
|
+
/** One skill as the host reports it (plus the flags the route adds). */
|
|
11
14
|
export interface SkillRowView {
|
|
12
15
|
name: string
|
|
13
16
|
description: string
|
|
@@ -16,6 +19,10 @@ export interface SkillRowView {
|
|
|
16
19
|
source: string
|
|
17
20
|
provider: string
|
|
18
21
|
editable: boolean
|
|
22
|
+
/** The skill's folder when it lives on disk (drives open-folder). */
|
|
23
|
+
dir?: string
|
|
24
|
+
/** File-backed skills can be enabled/disabled through the policy route. */
|
|
25
|
+
policyEditable: boolean
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
export interface SkillsInjected {
|
|
@@ -23,6 +30,11 @@ export interface SkillsInjected {
|
|
|
23
30
|
get(name: string): Promise<{ content: string }>
|
|
24
31
|
save(input: { name: string; description: string; whenToUse?: string; modelInvocable: boolean; userInvocable: boolean; content: string }): Promise<{ ok: boolean }>
|
|
25
32
|
remove(name: string): Promise<{ ok: boolean }>
|
|
33
|
+
policy(name: string, enabled: boolean): Promise<{ ok: boolean }>
|
|
34
|
+
open(target: OpenTarget): Promise<{ ok: boolean }>
|
|
35
|
+
roots(): Promise<{ roots: RootRowView[] }>
|
|
36
|
+
addRoot(input: { kind: 'local' | 'git'; path?: string; url?: string }): Promise<{ ok: boolean; root: RootRowView }>
|
|
37
|
+
removeRoot(id: string): Promise<{ ok: boolean }>
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
/** Editor dialog state; null when closed. */
|
|
@@ -46,6 +58,21 @@ const SOURCE_KEYS: Record<string, string> = {
|
|
|
46
58
|
custom: 'sourceCustom',
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
/** Status tag for the invocation policy; undefined = default (both allowed). */
|
|
62
|
+
function policyTag(skill: SkillRowView): { key?: string; off: boolean } {
|
|
63
|
+
const { modelInvocable, userInvocable } = skill.invocation
|
|
64
|
+
if (!modelInvocable && !userInvocable) return { key: 'skillDisabled', off: true }
|
|
65
|
+
if (!modelInvocable) return { key: 'skillUserOnly', off: false }
|
|
66
|
+
if (!userInvocable) return { key: 'skillModelOnly', off: false }
|
|
67
|
+
return { off: false }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Add-repository form state; null when the form is collapsed. */
|
|
71
|
+
interface AddRootState {
|
|
72
|
+
kind: 'local' | 'git'
|
|
73
|
+
value: string
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): ReactElement {
|
|
50
77
|
const { t, injected } = props
|
|
51
78
|
const [skills, setSkills] = useState<SkillRowView[] | null>(null)
|
|
@@ -55,6 +82,14 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
55
82
|
const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
|
|
56
83
|
const [formError, setFormError] = useState<string | null>(null)
|
|
57
84
|
const [reload, setReload] = useState(0)
|
|
85
|
+
const [query, setQuery] = useState('')
|
|
86
|
+
const [sourceFilter, setSourceFilter] = useState<string>('all')
|
|
87
|
+
|
|
88
|
+
// Custom skill repositories.
|
|
89
|
+
const [roots, setRoots] = useState<RootRowView[] | null>(null)
|
|
90
|
+
const [addRoot, setAddRoot] = useState<AddRootState | null>(null)
|
|
91
|
+
const [rootBusy, setRootBusy] = useState(false)
|
|
92
|
+
const [confirmRootId, setConfirmRootId] = useState<string | null>(null)
|
|
58
93
|
|
|
59
94
|
useEffect(() => {
|
|
60
95
|
let current = true
|
|
@@ -62,6 +97,10 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
62
97
|
(body) => { if (current) setSkills(body.skills) },
|
|
63
98
|
(error: Error) => { if (current) { setSkills([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) } },
|
|
64
99
|
)
|
|
100
|
+
void injected.roots().then(
|
|
101
|
+
(body) => { if (current) setRoots(body.roots) },
|
|
102
|
+
() => { if (current) setRoots([]) },
|
|
103
|
+
)
|
|
65
104
|
return () => { current = false }
|
|
66
105
|
}, [injected, reload, t])
|
|
67
106
|
|
|
@@ -152,8 +191,89 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
152
191
|
}
|
|
153
192
|
}
|
|
154
193
|
|
|
194
|
+
const doToggle = async (skill: SkillRowView): Promise<void> => {
|
|
195
|
+
const enabled = skill.invocation.modelInvocable || skill.invocation.userInvocable
|
|
196
|
+
setBusy(true)
|
|
197
|
+
try {
|
|
198
|
+
await injected.policy(skill.name, !enabled)
|
|
199
|
+
setOutcome({ ok: true, text: !enabled ? t('skillEnabled') : t('skillDisabledMsg') })
|
|
200
|
+
refreshUntil(list => {
|
|
201
|
+
const row = list.find(item => item.name === skill.name)
|
|
202
|
+
return row !== undefined && row.invocation.modelInvocable === !enabled && row.invocation.userInvocable === !enabled
|
|
203
|
+
})
|
|
204
|
+
} catch (error) {
|
|
205
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
206
|
+
} finally {
|
|
207
|
+
setBusy(false)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const doOpen = async (target: OpenTarget): Promise<void> => {
|
|
212
|
+
try {
|
|
213
|
+
await injected.open(target)
|
|
214
|
+
} catch (error) {
|
|
215
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const doAddRoot = async (): Promise<void> => {
|
|
220
|
+
if (addRoot === null || addRoot.value.trim() === '') return
|
|
221
|
+
setRootBusy(true)
|
|
222
|
+
setFormError(null)
|
|
223
|
+
try {
|
|
224
|
+
await injected.addRoot(addRoot.kind === 'local'
|
|
225
|
+
? { kind: 'local', path: addRoot.value.trim() }
|
|
226
|
+
: { kind: 'git', url: addRoot.value.trim() })
|
|
227
|
+
setOutcome({ ok: true, text: t('rootAdded') })
|
|
228
|
+
setAddRoot(null)
|
|
229
|
+
void injected.roots().then(
|
|
230
|
+
(body) => setRoots(body.roots),
|
|
231
|
+
() => setRoots([]),
|
|
232
|
+
)
|
|
233
|
+
// The provider remounts asynchronously; refresh the catalog a few
|
|
234
|
+
// times so the repository's skills appear without manual reload.
|
|
235
|
+
for (let tick = 0; tick < 4; tick++) {
|
|
236
|
+
await new Promise(resolve => setTimeout(resolve, 1500))
|
|
237
|
+
void injected.list().then(
|
|
238
|
+
(body) => setSkills(body.skills),
|
|
239
|
+
() => undefined,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
setFormError(String(error instanceof Error ? error.message : error))
|
|
244
|
+
} finally {
|
|
245
|
+
setRootBusy(false)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const doRemoveRoot = async (): Promise<void> => {
|
|
250
|
+
if (confirmRootId === null) return
|
|
251
|
+
setRootBusy(true)
|
|
252
|
+
try {
|
|
253
|
+
await injected.removeRoot(confirmRootId)
|
|
254
|
+
setOutcome({ ok: true, text: t('rootRemoved') })
|
|
255
|
+
setRoots(null)
|
|
256
|
+
void injected.roots().then(
|
|
257
|
+
(body) => setRoots(body.roots),
|
|
258
|
+
() => setRoots([]),
|
|
259
|
+
)
|
|
260
|
+
setReload((value) => value + 1)
|
|
261
|
+
} catch (error) {
|
|
262
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
263
|
+
} finally {
|
|
264
|
+
setRootBusy(false)
|
|
265
|
+
setConfirmRootId(null)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
155
269
|
const readOnly = editor?.mode === 'view'
|
|
156
270
|
|
|
271
|
+
const needle = query.trim().toLowerCase()
|
|
272
|
+
const filtered = skills === null ? [] : skills.filter(skill =>
|
|
273
|
+
(sourceFilter === 'all' || skill.source === sourceFilter)
|
|
274
|
+
&& (needle === '' || skill.name.toLowerCase().includes(needle) || skill.description.toLowerCase().includes(needle)))
|
|
275
|
+
const sources = skills === null ? [] : [...new Set(skills.map(skill => skill.source))]
|
|
276
|
+
|
|
157
277
|
return (
|
|
158
278
|
<div className="dpc-section">
|
|
159
279
|
<style>{CSS}</style>
|
|
@@ -162,6 +282,7 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
162
282
|
<IconSkillOutline16 aria-hidden="true" />
|
|
163
283
|
<h3>{t('skillsTitle')}</h3>
|
|
164
284
|
<span className="dpc-spacer" />
|
|
285
|
+
<Button variant="ghost" size="sm" onClick={() => void doOpen({ target: 'user-skills' })}>{t('openUserSkills')}</Button>
|
|
165
286
|
<Button variant="primary" size="sm" onClick={openCreate}>{t('newSkill')}</Button>
|
|
166
287
|
</div>
|
|
167
288
|
<p className="dpc-intro">{t('skillsIntro')}</p>
|
|
@@ -175,39 +296,138 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
175
296
|
|
|
176
297
|
<div className="dpc-listHead">
|
|
177
298
|
<h3>{t('skillsTab')}</h3>
|
|
178
|
-
{skills !== null && <span className="dpc-count">{skills.length}</span>}
|
|
299
|
+
{skills !== null && <span className="dpc-count">{filtered.length}/{skills.length}</span>}
|
|
179
300
|
<span className="dpc-spacer" />
|
|
180
|
-
<
|
|
301
|
+
<input
|
|
302
|
+
className="dpc-search"
|
|
303
|
+
type="search"
|
|
304
|
+
placeholder={t('searchSkills')}
|
|
305
|
+
aria-label={t('searchSkills')}
|
|
306
|
+
value={query}
|
|
307
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
308
|
+
/>
|
|
309
|
+
<button type="button" className="dpc-refresh" aria-label={t('refresh')} title={t('refresh')} disabled={busy} onClick={() => setReload((value) => value + 1)}>
|
|
181
310
|
<IconRefreshOutline14 size={14} aria-hidden="true" />
|
|
182
311
|
</button>
|
|
183
312
|
</div>
|
|
184
313
|
|
|
314
|
+
{sources.length > 1 && (
|
|
315
|
+
<div className="dpc-chips" role="group" aria-label={t('source')}>
|
|
316
|
+
{[{ id: 'all', label: t('filterAll') }, ...sources.map(source => ({ id: source, label: t(SOURCE_KEYS[source] ?? 'sourceCustom') }))].map(chip => (
|
|
317
|
+
<button
|
|
318
|
+
key={chip.id}
|
|
319
|
+
type="button"
|
|
320
|
+
className="dpc-chip"
|
|
321
|
+
data-active={sourceFilter === chip.id ? 'true' : undefined}
|
|
322
|
+
onClick={() => setSourceFilter(chip.id)}
|
|
323
|
+
>
|
|
324
|
+
{chip.label}
|
|
325
|
+
</button>
|
|
326
|
+
))}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
|
|
185
330
|
{skills === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
186
|
-
{skills !== null &&
|
|
187
|
-
{skills !== null &&
|
|
331
|
+
{skills !== null && filtered.length === 0 && <p className="dpc-empty">{skills.length === 0 ? t('emptySkills') : t('noMatch')}</p>}
|
|
332
|
+
{skills !== null && filtered.length > 0 && (
|
|
188
333
|
<ul className="dpc-cards">
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
334
|
+
{filtered.map((skill) => {
|
|
335
|
+
const tag = policyTag(skill)
|
|
336
|
+
return (
|
|
337
|
+
<li className="dpc-card" key={`${skill.source}/${skill.name}`}>
|
|
338
|
+
<div className="dpc-cardTop">
|
|
339
|
+
<strong className="dpc-cardTitle" title={skill.name}>{skill.name}</strong>
|
|
340
|
+
<span className="dpc-tag" data-kind="source">{t(SOURCE_KEYS[skill.source] ?? 'sourceCustom')}</span>
|
|
341
|
+
{tag.key !== undefined && <span className="dpc-tag" data-kind={tag.off ? 'off' : undefined}>{t(tag.key)}</span>}
|
|
342
|
+
</div>
|
|
343
|
+
<p className="dpc-cardDesc" title={skill.description}>{skill.description}</p>
|
|
344
|
+
<div className="dpc-cardRow">
|
|
345
|
+
{skill.policyEditable && (
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className="dpc-switch"
|
|
349
|
+
role="switch"
|
|
350
|
+
aria-checked={skill.invocation.modelInvocable || skill.invocation.userInvocable}
|
|
351
|
+
aria-label={t('toggleSkill')}
|
|
352
|
+
title={t('toggleSkillHint')}
|
|
353
|
+
disabled={busy}
|
|
354
|
+
onClick={() => void doToggle(skill)}
|
|
355
|
+
>
|
|
356
|
+
<span className="dpc-switchKnob" />
|
|
357
|
+
</button>
|
|
358
|
+
)}
|
|
359
|
+
{skill.dir !== undefined && (
|
|
360
|
+
<button type="button" className="dpc-link" onClick={() => void doOpen({ target: 'skill', name: skill.name })}>{t('openFolder')}</button>
|
|
361
|
+
)}
|
|
362
|
+
<span className="dpc-spacer" />
|
|
363
|
+
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void openExisting(skill)}>
|
|
364
|
+
{skill.editable ? t('edit') : t('view')}
|
|
365
|
+
</Button>
|
|
366
|
+
{skill.editable && (
|
|
367
|
+
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmName(skill.name)}>{t('delete')}</Button>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
</li>
|
|
371
|
+
)
|
|
372
|
+
})}
|
|
373
|
+
</ul>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
<div className="dpc-listHead dpc-rootsHead">
|
|
377
|
+
<h3>{t('rootsTitle')}</h3>
|
|
378
|
+
{roots !== null && <span className="dpc-count">{roots.length}</span>}
|
|
379
|
+
<span className="dpc-spacer" />
|
|
380
|
+
{addRoot === null && (
|
|
381
|
+
<Button variant="ghost" size="sm" onClick={() => setAddRoot({ kind: 'git', value: '' })}>{t('addRoot')}</Button>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
<p className="dpc-intro">{t('rootsIntro')}</p>
|
|
385
|
+
{roots === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
386
|
+
{roots !== null && roots.length === 0 && <p className="dpc-empty">{t('emptyRoots')}</p>}
|
|
387
|
+
{roots !== null && roots.length > 0 && (
|
|
388
|
+
<ul className="dpc-roots">
|
|
389
|
+
{roots.map((root) => (
|
|
390
|
+
<li className="dpc-root" key={root.id}>
|
|
391
|
+
<span className="dpc-tag" data-kind="source">{root.kind === 'git' ? 'GitHub' : t('rootLocal')}</span>
|
|
392
|
+
<strong className="dpc-rootLabel" title={root.kind === 'git' ? root.url : root.path}>{root.label}</strong>
|
|
393
|
+
<span className="dpc-rootPath">{root.kind === 'git' ? root.url : root.path}</span>
|
|
394
|
+
{!root.live && <span className="dpc-tag" data-kind="off">{t('rootStale')}</span>}
|
|
395
|
+
<span className="dpc-spacer" />
|
|
396
|
+
<button type="button" className="dpc-link" onClick={() => void doOpen({ target: 'root', id: root.id })}>{t('openFolder')}</button>
|
|
397
|
+
<Button variant="ghost" size="sm" disabled={rootBusy} onClick={() => setConfirmRootId(root.id)}>{t('delete')}</Button>
|
|
207
398
|
</li>
|
|
208
399
|
))}
|
|
209
400
|
</ul>
|
|
210
401
|
)}
|
|
402
|
+
{addRoot !== null && (
|
|
403
|
+
<div className="dpc-form dpc-addRoot">
|
|
404
|
+
<div className="dpc-addRootRow">
|
|
405
|
+
<select
|
|
406
|
+
className="dpc-select"
|
|
407
|
+
aria-label={t('rootKind')}
|
|
408
|
+
value={addRoot.kind}
|
|
409
|
+
onChange={(event) => setAddRoot({ ...addRoot, kind: event.target.value as AddRootState['kind'] })}
|
|
410
|
+
>
|
|
411
|
+
<option value="git">GitHub</option>
|
|
412
|
+
<option value="local">{t('rootLocal')}</option>
|
|
413
|
+
</select>
|
|
414
|
+
<input
|
|
415
|
+
className="dpc-input"
|
|
416
|
+
type="text"
|
|
417
|
+
placeholder={addRoot.kind === 'git' ? 'https://github.com/anthropics/skills' : t('rootLocalPlaceholder')}
|
|
418
|
+
aria-label={t('rootPlaceholder')}
|
|
419
|
+
value={addRoot.value}
|
|
420
|
+
autoFocus
|
|
421
|
+
onChange={(event) => setAddRoot({ ...addRoot, value: event.target.value })}
|
|
422
|
+
onKeyDown={(event) => { if (event.key === 'Enter') void doAddRoot() }}
|
|
423
|
+
/>
|
|
424
|
+
<Button variant="primary" size="sm" disabled={rootBusy || addRoot.value.trim() === ''} onClick={() => void doAddRoot()}>{t('add')}</Button>
|
|
425
|
+
<Button variant="ghost" size="sm" disabled={rootBusy} onClick={() => { setAddRoot(null); setFormError(null) }}>{t('cancel')}</Button>
|
|
426
|
+
</div>
|
|
427
|
+
<p className="dpc-intro">{addRoot.kind === 'git' ? t('rootGitHint') : t('rootLocalHint')}</p>
|
|
428
|
+
{formError !== null && <p className="dpc-formError">{formError}</p>}
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
211
431
|
|
|
212
432
|
<Modal
|
|
213
433
|
open={editor !== null}
|
|
@@ -298,6 +518,21 @@ export function SkillsTab(props: { t: Translate; injected: SkillsInjected }): Re
|
|
|
298
518
|
>
|
|
299
519
|
<p>{t('deleteWarn')}</p>
|
|
300
520
|
</Modal>
|
|
521
|
+
|
|
522
|
+
<Modal
|
|
523
|
+
open={confirmRootId !== null}
|
|
524
|
+
onClose={() => setConfirmRootId(null)}
|
|
525
|
+
title={t('confirmRemoveRoot')}
|
|
526
|
+
description={roots?.find(root => root.id === confirmRootId)?.label ?? undefined}
|
|
527
|
+
footer={
|
|
528
|
+
<>
|
|
529
|
+
<Button variant="ghost" onClick={() => setConfirmRootId(null)}>{t('cancel')}</Button>
|
|
530
|
+
<Button variant="primary" disabled={rootBusy} onClick={() => void doRemoveRoot()}>{t('delete')}</Button>
|
|
531
|
+
</>
|
|
532
|
+
}
|
|
533
|
+
>
|
|
534
|
+
<p>{t('removeRootWarn')}</p>
|
|
535
|
+
</Modal>
|
|
301
536
|
</div>
|
|
302
537
|
)
|
|
303
538
|
}
|
package/src/client/css.ts
CHANGED
|
@@ -66,5 +66,47 @@ export const CSS = `
|
|
|
66
66
|
.dpc-importHead{display:flex;align-items:center;gap:8px;padding:0 2px}
|
|
67
67
|
.dpc-importCount{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
|
|
68
68
|
.dpc-importAll{margin-left:auto;display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
69
|
+
/* Skills search + source filter chips. */
|
|
70
|
+
.dpc-search{width:200px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px}
|
|
71
|
+
.dpc-search:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
|
|
72
|
+
.dpc-chips{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
73
|
+
.dpc-chip{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:16px;cursor:pointer}
|
|
74
|
+
.dpc-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
75
|
+
.dpc-chip[data-active='true']{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}
|
|
76
|
+
.dpc-chip:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
|
|
77
|
+
/* Load-policy switch on skill cards (no form semantics — instant apply). */
|
|
78
|
+
.dpc-switch{position:relative;flex:none;width:30px;height:18px;border:0;border-radius:999px;background:var(--dsw-alias-bg-layer-1);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2);cursor:pointer;transition:background .15s}
|
|
79
|
+
.dpc-switch[aria-checked='true']{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 55%,transparent);box-shadow:none}
|
|
80
|
+
.dpc-switchKnob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-label-primary);transition:left .15s}
|
|
81
|
+
.dpc-switch[aria-checked='true'] .dpc-switchKnob{left:14px;background:#fff}
|
|
82
|
+
.dpc-switch:disabled{opacity:.55;cursor:default}
|
|
83
|
+
.dpc-switch:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
|
|
84
|
+
/* Quiet inline link-button (open folder, homepage). */
|
|
85
|
+
.dpc-link{border:0;padding:0;background:transparent;color:var(--dsw-alias-state-business-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-decoration:none}
|
|
86
|
+
.dpc-link:hover{text-decoration:underline}
|
|
87
|
+
.dpc-link:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;border-radius:2px}
|
|
88
|
+
/* Skill repositories list + add form. */
|
|
89
|
+
.dpc-rootsHead{margin-top:10px}
|
|
90
|
+
.dpc-roots{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}
|
|
91
|
+
.dpc-root{display:flex;align-items:center;gap:8px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-3)}
|
|
92
|
+
.dpc-rootLabel{flex:none;font-size:13px;line-height:18px;font-weight:600}
|
|
93
|
+
.dpc-rootPath{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);font-family:var(--ds-font-family-code)}
|
|
94
|
+
.dpc-addRoot{border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px}
|
|
95
|
+
.dpc-addRootRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
96
|
+
.dpc-addRootRow .dpc-select{width:auto;flex:none}
|
|
97
|
+
.dpc-addRootRow .dpc-input{flex:1;min-width:200px}
|
|
98
|
+
/* Market segments + env hint + MCP format preview. */
|
|
99
|
+
.dpc-segments{display:inline-flex;gap:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:3px;background:var(--dsw-alias-bg-layer-1)}
|
|
100
|
+
.dpc-segment{border:0;border-radius:6px;padding:4px 14px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}
|
|
101
|
+
.dpc-segment:hover{color:var(--dsw-alias-label-primary)}
|
|
102
|
+
.dpc-segment[data-active='true']{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-weight:600}
|
|
103
|
+
.dpc-segment:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
|
|
104
|
+
.dpc-envHint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-warning-primary,var(--dsw-alias-label-secondary))}
|
|
105
|
+
.dpc-format{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-1)}
|
|
106
|
+
.dpc-format summary{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);cursor:pointer;user-select:none}
|
|
107
|
+
.dpc-format summary:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;border-radius:2px}
|
|
108
|
+
.dpc-format .dpc-form{margin-top:8px}
|
|
109
|
+
.dpc-formatHint{margin:2px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
110
|
+
.dpc-code{margin:4px 0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:8px 10px;overflow-x:auto;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code);font-size:11px;line-height:17px;white-space:pre}
|
|
69
111
|
@media(max-width:680px){.dpc-cards{grid-template-columns:minmax(0,1fr)}}
|
|
70
112
|
`
|