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.
@@ -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" />
@@ -1,13 +1,16 @@
1
- /** Settings Plugins “技能/Skills” tab: view the discovered catalog, edit the
2
- * user-owned root. Pure presentation-layer data arrives through props. */
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 editable flag the route adds). */
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
- <button type="button" className="dpc-refresh" aria-label={t('view')} title={t('view')} disabled={busy} onClick={() => setReload((value) => value + 1)}>
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 && skills.length === 0 && <p className="dpc-empty">{t('emptySkills')}</p>}
187
- {skills !== null && skills.length > 0 && (
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
- {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>
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
  `