dsh-skill-picker 0.5.22 → 0.5.23

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.
@@ -76,6 +76,37 @@ function saveUsage(usage) {
76
76
  void pushSharedState()
77
77
  }
78
78
 
79
+ /**
80
+ * Manage one skill through the host: disable / enable / reveal / uninstall /
81
+ * restore.
82
+ *
83
+ * The request carries `x-dsh-skill-picker: 1` on purpose. Plugin routes are
84
+ * reachable without authentication (`/` needs a token, these do not), and these
85
+ * actions rename and move files — so they require a header that a cross-site
86
+ * form POST cannot set. See `handleSkillOp` in the host half.
87
+ *
88
+ * @param action - disable | enable | reveal | uninstall | restore | backups
89
+ * @param payload - { name } for skill actions, { id } for restore.
90
+ * @returns `{ ok, error?, ... }` — never throws.
91
+ */
92
+ async function skillOp(action, payload = {}) {
93
+ try {
94
+ const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? `?cwd=${encodeURIComponent(payload.cwd)}` : ''
95
+ const response = await fetch(`/dsh-skill-picker/skill${cwd}`, {
96
+ method: 'POST',
97
+ headers: { 'content-type': 'application/json', 'x-dsh-skill-picker': '1' },
98
+ body: JSON.stringify({ action, ...payload }),
99
+ })
100
+ const body = await response.json().catch(() => undefined)
101
+ if (body === undefined || body.ok !== true) {
102
+ return { ok: false, error: body?.error ?? `host returned ${response.status}` }
103
+ }
104
+ return body
105
+ } catch (cause) {
106
+ return { ok: false, error: String(cause?.message ?? cause) }
107
+ }
108
+ }
109
+
79
110
  /**
80
111
  * Whether a skill entry may be offered by a human-facing surface (this panel
81
112
  * and the `/` completion it feeds). The official `skills/list` DTO
@@ -315,6 +346,69 @@ const statusStyle = {
315
346
  fontSize: '13px',
316
347
  }
317
348
 
349
+ /** One row action (关闭 / 定位 / 卸载); matches the pin button's weight. */
350
+ const actionSpanStyle = {
351
+ flex: 'none',
352
+ marginLeft: '6px',
353
+ padding: '2px 4px',
354
+ borderRadius: '6px',
355
+ fontSize: '12px',
356
+ lineHeight: '16px',
357
+ cursor: 'pointer',
358
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
359
+ opacity: 0.55,
360
+ userSelect: 'none',
361
+ }
362
+
363
+ /** Result line under the list, with an optional undo for uninstall. */
364
+ const noticeStyle = {
365
+ display: 'flex',
366
+ alignItems: 'center',
367
+ gap: '8px',
368
+ margin: '0 8px 8px',
369
+ padding: '6px 10px',
370
+ borderRadius: '8px',
371
+ fontSize: '12px',
372
+ lineHeight: '18px',
373
+ background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))',
374
+ color: 'var(--dsw-alias-label-secondary, #b6bfcc)',
375
+ }
376
+
377
+ /** The right-click / long-press actions menu, positioned against the viewport. */
378
+ const menuStyle = {
379
+ position: 'fixed',
380
+ zIndex: 2147483000,
381
+ minWidth: '196px',
382
+ padding: '4px',
383
+ borderRadius: '10px',
384
+ border: '1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,0.24))',
385
+ background: 'var(--dsw-alias-bg-elevated, #23262e)',
386
+ boxShadow: '0 8px 28px rgba(0,0,0,0.36)',
387
+ color: 'var(--dsw-alias-label-primary, #e6e9ef)',
388
+ fontSize: '13px',
389
+ userSelect: 'none',
390
+ }
391
+
392
+ /** The skill name at the top of the menu (the thing being acted on). */
393
+ const menuHeaderStyle = {
394
+ padding: '6px 10px 4px',
395
+ fontSize: '11px',
396
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
397
+ borderBottom: '1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,0.18))',
398
+ marginBottom: '4px',
399
+ overflow: 'hidden',
400
+ textOverflow: 'ellipsis',
401
+ whiteSpace: 'nowrap',
402
+ }
403
+
404
+ /** One menu row. */
405
+ const menuItemStyle = {
406
+ padding: '6px 10px',
407
+ borderRadius: '6px',
408
+ cursor: 'pointer',
409
+ whiteSpace: 'nowrap',
410
+ }
411
+
318
412
  /** Lightweight source badge shown only when the list came from the host scan fallback (official API unavailable). */
319
413
  const sourceBadgeStyle = {
320
414
  display: 'inline-flex',
@@ -366,12 +460,32 @@ function SkillPickerButton(props) {
366
460
  const [skills, setSkills] = useState(undefined)
367
461
  const [error, setError] = useState(undefined)
368
462
  const [source, setSource] = useState(undefined)
463
+ // Skills whose entry file is renamed to `SKILL.md.disabled`. The official
464
+ // `skills/list` RPC cannot report them (the provider does not see them at
465
+ // all), so they come from the host scan — without this a disabled skill would
466
+ // simply disappear and there would be no way to switch it back on.
467
+ const [disabledSkills, setDisabledSkills] = useState([])
468
+ /** Name of the skill an action is currently running on. */
469
+ const [busySkill, setBusySkill] = useState('')
470
+ /** Result line under the list: { kind, text, undo? }. */
471
+ const [notice, setNotice] = useState(undefined)
472
+ /**
473
+ * The right-click menu: { x, y, skill, disabled }.
474
+ *
475
+ * Actions live here instead of on the row so the list stays a list — one
476
+ * glyph per row (the pin, which doubles as the pinned/not state) and
477
+ * everything else behind the familiar right-click. Long-press opens the same
478
+ * menu, because a phone has no right button.
479
+ */
480
+ const [menu, setMenu] = useState(undefined)
369
481
  const [query, setQuery] = useState('')
370
482
  const [usage, setUsage] = useState(() => loadUsage())
371
483
  const [pinned, setPinned] = useState(() => loadPinned())
372
484
  const [active, setActive] = useState(0)
373
485
  const boxRef = useRef(null)
374
486
  const itemRefs = useRef([])
487
+ /** Pending long-press timer for the touch path (see the menu below). */
488
+ const longPressRef = useRef(0)
375
489
 
376
490
  // The usage store is shared with the official `/` menu: picks made there go
377
491
  // through window.__dshSkillPickerTrack (localStorage only). Refresh this
@@ -429,8 +543,8 @@ function SkillPickerButton(props) {
429
543
  draftRef.current = props.input.draft
430
544
  }
431
545
 
432
- const load = useCallback(async () => {
433
- if (skills !== undefined || error !== undefined) return
546
+ const load = useCallback(async (force = false) => {
547
+ if (!force && (skills !== undefined || error !== undefined)) return
434
548
  // Session identity comes from the slot's standard props; older kernels
435
549
  // carried it as `props.session` (see ./session-view.js).
436
550
  const sessionId = sessionIdOf(props)
@@ -459,6 +573,103 @@ function SkillPickerButton(props) {
459
573
  }
460
574
  }, [skills, error, props.listSkills, props.sessionId, props.session, props.cwd])
461
575
 
576
+ /**
577
+ * Load the disabled skills from the host's own scan.
578
+ *
579
+ * They are invisible to the official `skills/list` RPC, so this is the only
580
+ * way the panel can offer to switch one back on. Best-effort: without it the
581
+ * panel still works, it just cannot re-enable.
582
+ */
583
+ const loadDisabled = useCallback(async () => {
584
+ try {
585
+ const cwd = typeof props.cwd === 'string' && props.cwd !== '' ? `?cwd=${encodeURIComponent(props.cwd)}` : ''
586
+ const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
587
+ const json = await res.json()
588
+ if (!json.ok) return
589
+ setDisabledSkills((Array.isArray(json.skills) ? json.skills : []).filter((skill) => skill.disabled === true))
590
+ } catch {
591
+ /* the disabled list is a convenience; the panel works without it */
592
+ }
593
+ }, [props.cwd])
594
+
595
+ /**
596
+ * Run one management action and report it under the list.
597
+ *
598
+ * Reloads both lists afterwards: a disabled skill moves out of 「全部」 into
599
+ * 「已关闭」, and an uninstall takes it out of both.
600
+ */
601
+ const runSkillOp = async (action, skill, extra = {}) => {
602
+ const name = typeof skill?.name === 'string' ? skill.name : String(skill ?? '')
603
+ setBusySkill(name)
604
+ setNotice(undefined)
605
+ const result = await skillOp(action, { name, cwd: props.cwd, ...extra })
606
+ setBusySkill('')
607
+ if (result.ok !== true) {
608
+ setNotice({ kind: 'err', text: result.error ?? '操作失败' })
609
+ return
610
+ }
611
+ if (action === 'uninstall') {
612
+ const id = String(result.backupPath ?? '').split(/[\\/]/).filter(Boolean).pop()
613
+ setNotice({ kind: 'ok', text: `已卸载 /${name} —— 只是移进备份目录,没删`, undo: id })
614
+ } else if (action === 'disable') {
615
+ setNotice({ kind: 'ok', text: `已关闭 /${name} —— agent 也不会再加载它了` })
616
+ } else if (action === 'enable') {
617
+ setNotice({ kind: 'ok', text: `已开启 /${name}` })
618
+ } else if (action === 'reveal') {
619
+ setNotice({ kind: 'ok', text: `已在文件管理器里定位 /${name}` })
620
+ }
621
+ await Promise.all([load(true), loadDisabled()])
622
+ }
623
+
624
+ const undoUninstall = async (id) => {
625
+ if (typeof id !== 'string' || id === '') return
626
+ setBusySkill('(undo)')
627
+ const result = await skillOp('restore', { id, cwd: props.cwd })
628
+ setBusySkill('')
629
+ if (result.ok !== true) {
630
+ setNotice({ kind: 'err', text: result.error ?? '撤回失败' })
631
+ return
632
+ }
633
+ setNotice({ kind: 'ok', text: '已撤回,技能回到原位置' })
634
+ await Promise.all([load(true), loadDisabled()])
635
+ }
636
+
637
+ /**
638
+ * Open the context menu at a viewport point, kept fully on screen.
639
+ * `event` may be a mouse or a touch event; both carry clientX/clientY.
640
+ */
641
+ const openMenu = (event, skill, disabled) => {
642
+ const width = 208
643
+ const height = disabled ? 84 : 168
644
+ const x = Math.max(8, Math.min(event.clientX, (window.innerWidth || 1024) - width - 8))
645
+ const y = Math.max(8, Math.min(event.clientY, (window.innerHeight || 768) - height - 8))
646
+ setMenu({ x, y, skill, disabled })
647
+ }
648
+
649
+ // Close the menu on any outside interaction — the same contract as a native
650
+ // context menu, so it never lingers over the composer.
651
+ //
652
+ // BUBBLE phase on purpose, and the menu stops the event itself: a capturing
653
+ // listener would run before the clicked item's own handler and close the menu
654
+ // out from under the click, so every action would silently do nothing.
655
+ useEffect(() => {
656
+ if (menu === undefined) return undefined
657
+ const close = () => setMenu(undefined)
658
+ const onKey = (event) => {
659
+ if (event.key === 'Escape') close()
660
+ }
661
+ document.addEventListener('pointerdown', close)
662
+ document.addEventListener('keydown', onKey)
663
+ window.addEventListener('blur', close)
664
+ window.addEventListener('resize', close)
665
+ return () => {
666
+ document.removeEventListener('pointerdown', close)
667
+ document.removeEventListener('keydown', onKey)
668
+ window.removeEventListener('blur', close)
669
+ window.removeEventListener('resize', close)
670
+ }
671
+ }, [menu])
672
+
462
673
  const toggle = () => {
463
674
  if (!open) {
464
675
  // Pull first: a pin or a pick made on another client (desktop ↔ web ↔
@@ -467,6 +678,7 @@ function SkillPickerButton(props) {
467
678
  void syncSharedState()
468
679
  setUsage(loadUsage())
469
680
  void load()
681
+ void loadDisabled()
470
682
  }
471
683
  setOpen(!open)
472
684
  }
@@ -608,6 +820,27 @@ function SkillPickerButton(props) {
608
820
  itemRefs.current[index] = el
609
821
  }}
610
822
  onClick={() => pick(skill.name)}
823
+ onContextMenu={(event) => {
824
+ // Right-click opens the actions menu; see the menu block
825
+ // at the end of the panel. One glyph per row keeps the
826
+ // list scannable.
827
+ event.preventDefault()
828
+ event.stopPropagation()
829
+ openMenu(event, skill, false)
830
+ }}
831
+ onTouchStart={(event) => {
832
+ // A phone has no right button: long-press opens the same
833
+ // menu. Cancelled by any movement, so scrolling a long
834
+ // list never pops it up by accident.
835
+ const touch = event.touches?.[0]
836
+ if (touch === undefined) return
837
+ longPressRef.current = window.setTimeout(
838
+ () => openMenu({ clientX: touch.clientX, clientY: touch.clientY }, skill, false),
839
+ 500,
840
+ )
841
+ }}
842
+ onTouchEnd={() => window.clearTimeout(longPressRef.current)}
843
+ onTouchMove={() => window.clearTimeout(longPressRef.current)}
611
844
  onMouseEnter={(event) => {
612
845
  setActive(index)
613
846
  event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))'
@@ -685,6 +918,86 @@ function SkillPickerButton(props) {
685
918
  })
686
919
  })()}
687
920
  </div>
921
+ {/* Result of the last management action, with an undo for uninstall. */}
922
+ {notice !== undefined && (
923
+ <div style={noticeStyle}>
924
+ <span style={{ flex: '1', color: notice.kind === 'err' ? 'var(--dsw-alias-label-error, #ff7b72)' : undefined }}>
925
+ {notice.text}
926
+ </span>
927
+ {typeof notice.undo === 'string' && notice.undo !== '' && (
928
+ <span
929
+ role="button"
930
+ tabIndex={-1}
931
+ onClick={() => void undoUninstall(notice.undo)}
932
+ style={{ cursor: 'pointer', textDecoration: 'underline', flex: 'none' }}
933
+ >
934
+ 撤回
935
+ </span>
936
+ )}
937
+ </div>
938
+ )}
939
+ {/* Skills switched off. The official RPC never reports these, so
940
+ without this section a disabled skill would be unreachable. */}
941
+ {disabledSkills.length > 0 && (
942
+ <div style={{ marginTop: '4px' }}>
943
+ <div
944
+ style={{
945
+ display: 'flex',
946
+ alignItems: 'center',
947
+ justifyContent: 'space-between',
948
+ padding: '6px 10px 2px',
949
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
950
+ fontSize: '11px',
951
+ fontWeight: 600,
952
+ letterSpacing: '0.04em',
953
+ }}
954
+ >
955
+ <span>⏻ 已关闭</span>
956
+ <span style={{ opacity: 0.7 }}>{disabledSkills.length}</span>
957
+ </div>
958
+ {disabledSkills.map((skill) => (
959
+ <div
960
+ key={`off-${skill.name}`}
961
+ onContextMenu={(event) => {
962
+ event.preventDefault()
963
+ event.stopPropagation()
964
+ openMenu(event, skill, true)
965
+ }}
966
+ style={{
967
+ ...itemStyle,
968
+ flexDirection: 'row',
969
+ alignItems: 'center',
970
+ opacity: 0.55,
971
+ }}
972
+ >
973
+ <span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
974
+ <span style={nameStyle}>{`/${skill.name}`}</span>
975
+ <span style={descStyle}>{skill.description ?? ''}</span>
976
+ </span>
977
+ <span
978
+ role="button"
979
+ tabIndex={-1}
980
+ title="重新开启(把 SKILL.md.disabled 改回 SKILL.md)"
981
+ aria-label="重新开启"
982
+ onClick={() => void runSkillOp('enable', skill)}
983
+ style={actionSpanStyle}
984
+ >
985
+ {busySkill === skill.name ? '…' : '⏻ 开启'}
986
+ </span>
987
+ <span
988
+ role="button"
989
+ tabIndex={-1}
990
+ title="在文件管理器中定位"
991
+ aria-label="在文件管理器中定位"
992
+ onClick={() => void runSkillOp('reveal', skill)}
993
+ style={actionSpanStyle}
994
+ >
995
+ 📂
996
+ </span>
997
+ </div>
998
+ ))}
999
+ </div>
1000
+ )}
688
1001
  {source === 'host' && (
689
1002
  <div style={sourceBadgeStyle} title="官方技能 API 不可用,列表来自本地目录扫描(与官方 / 补全同源)">
690
1003
  <span style={sourceBadgeTextStyle}>本地扫描</span>
@@ -698,6 +1011,73 @@ function SkillPickerButton(props) {
698
1011
  <span style={sourceBadgeTextStyle}>/ 增强:运行时接管</span>
699
1012
  </div>
700
1013
  )}
1014
+ <div style={{ margin: '0 10px 8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #8a94a6)' }}>
1015
+ 右键技能 = 置顶 / 关闭 / 定位 / 卸载(手机长按)
1016
+ </div>
1017
+ {/* The actions menu. Rendered here (not inside the row) so it can
1018
+ be positioned against the viewport and never clipped by the
1019
+ list's own scrolling. */}
1020
+ {menu !== undefined && (
1021
+ <div
1022
+ style={{ ...menuStyle, left: `${menu.x}px`, top: `${menu.y}px` }}
1023
+ onPointerDown={(event) => event.stopPropagation()}
1024
+ >
1025
+ <div style={menuHeaderStyle}>{`/${menu.skill.name}`}</div>
1026
+ {!menu.disabled && (
1027
+ <div
1028
+ style={menuItemStyle}
1029
+ role="button"
1030
+ tabIndex={-1}
1031
+ onClick={() => {
1032
+ setMenu(undefined)
1033
+ togglePin(menu.skill.name)
1034
+ }}
1035
+ >
1036
+ {pinned.includes(menu.skill.name) ? '📌 取消置顶' : '📌 置顶到顶部'}
1037
+ </div>
1038
+ )}
1039
+ <div
1040
+ style={menuItemStyle}
1041
+ role="button"
1042
+ tabIndex={-1}
1043
+ onClick={() => {
1044
+ const target = menu.skill
1045
+ const turningOff = !menu.disabled
1046
+ setMenu(undefined)
1047
+ void runSkillOp(turningOff ? 'disable' : 'enable', target)
1048
+ }}
1049
+ >
1050
+ {menu.disabled ? '⏻ 开启' : '⏻ 关闭(agent 也不再加载)'}
1051
+ </div>
1052
+ <div
1053
+ style={menuItemStyle}
1054
+ role="button"
1055
+ tabIndex={-1}
1056
+ onClick={() => {
1057
+ const target = menu.skill
1058
+ setMenu(undefined)
1059
+ void runSkillOp('reveal', target)
1060
+ }}
1061
+ >
1062
+ 📂 在文件管理器中定位
1063
+ </div>
1064
+ {!menu.disabled && (
1065
+ <div
1066
+ style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #ff7b72)' }}
1067
+ role="button"
1068
+ tabIndex={-1}
1069
+ onClick={() => {
1070
+ const target = menu.skill
1071
+ setMenu(undefined)
1072
+ if (!window.confirm(`卸载 /${target.name}?\n\n它会被移进备份目录(不是删除),面板里可以撤回。`)) return
1073
+ void runSkillOp('uninstall', target)
1074
+ }}
1075
+ >
1076
+ 🗑 卸载(移入备份,可撤回)
1077
+ </div>
1078
+ )}
1079
+ </div>
1080
+ )}
701
1081
  </>
702
1082
  )}
703
1083
  </div>
package/src/index.js CHANGED
@@ -24,6 +24,17 @@ import path from 'node:path'
24
24
 
25
25
  import { isDirectoryEntry } from './dir-entry.js'
26
26
  import { dshHome, healUiSkillPatches, revertUiSkillPatches } from './patch-ui-skill.js'
27
+ import {
28
+ CLIENT_HEADER,
29
+ DISABLED_ENTRY,
30
+ ENTRY,
31
+ listSkillBackups,
32
+ resolveSkillPath,
33
+ restoreSkillBackup,
34
+ revealSkill,
35
+ setSkillDisabled,
36
+ uninstallSkill,
37
+ } from './skill-ops.js'
27
38
 
28
39
  /** Required services: the route registry and the prompt band. */
29
40
  export const inject = ['webServer', 'systemPrompt']
@@ -163,10 +174,21 @@ async function scanSkillsDirInto(map, dir) {
163
174
  if (!(await isDirectoryEntry(dir, entry))) continue
164
175
  const skillDir = path.join(dir, entry.name)
165
176
  let content
177
+ let entryFile = ENTRY
178
+ let disabled = false
166
179
  try {
167
- content = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8')
180
+ content = await readFile(path.join(skillDir, ENTRY), 'utf8')
168
181
  } catch {
169
- continue
182
+ // A disabled skill keeps its entry under a different name, which the
183
+ // official provider ignores entirely. The panel still has to list it, or
184
+ // there would be no way to switch it back on.
185
+ try {
186
+ content = await readFile(path.join(skillDir, DISABLED_ENTRY), 'utf8')
187
+ entryFile = DISABLED_ENTRY
188
+ disabled = true
189
+ } catch {
190
+ continue
191
+ }
170
192
  }
171
193
  const meta = parseFrontmatter(content)
172
194
  // The picker is a human-facing surface, so honour the invocation policy:
@@ -181,6 +203,8 @@ async function scanSkillsDirInto(map, dir) {
181
203
  name: meta.name ?? entry.name,
182
204
  description: meta.description ?? '',
183
205
  path: skillDir,
206
+ entry: entryFile,
207
+ disabled,
184
208
  })
185
209
  }
186
210
  }
@@ -364,6 +388,105 @@ async function handleSharedState(req, res, url) {
364
388
  }
365
389
  }
366
390
 
391
+ /**
392
+ * Every root a skill may live in, in the order the scanner reads them.
393
+ * Single source of truth: `skill-ops` must not re-derive these.
394
+ */
395
+ function allSkillRoots(cwd) {
396
+ const roots = [userAgentsSkillsDir(), userSkillsDir()]
397
+ if (typeof cwd === 'string' && cwd !== '') {
398
+ roots.push(path.join(cwd, '.agents', 'skills'), path.join(cwd, '.dsh', 'skills'))
399
+ }
400
+ return roots
401
+ }
402
+
403
+ /**
404
+ * `POST /dsh-skill-picker/skill` — disable / enable / reveal / uninstall /
405
+ * restore a skill, and list the uninstall backups.
406
+ *
407
+ * Three guards, in order:
408
+ *
409
+ * 1. The request must carry `x-dsh-skill-picker: 1`. Plugin routes bypass the
410
+ * web server's auth gate (they are reachable without a cookie), and these
411
+ * actions rename and move files. A cross-site `fetch` cannot set a custom
412
+ * header without a CORS preflight the host does not answer, so this header
413
+ * is what keeps an arbitrary web page out.
414
+ * 2. The target path must resolve inside a known skill root. Anything else —
415
+ * the root itself, a file nested inside a skill, `../..`, or an absolute
416
+ * path elsewhere — is refused before any filesystem call.
417
+ * 3. Nothing is ever deleted. "Uninstall" moves the skill into
418
+ * `$DSH_HOME/skill-backups/` beside a manifest, and `restore` moves it back.
419
+ */
420
+ async function handleSkillOp(req, res, url) {
421
+ const json = (code, payload) => {
422
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
423
+ res.end(JSON.stringify(payload))
424
+ }
425
+ if (req.method !== 'POST') {
426
+ json(405, { ok: false, error: 'POST only' })
427
+ return
428
+ }
429
+ if (req.headers[CLIENT_HEADER] !== '1') {
430
+ json(403, { ok: false, error: 'missing client header' })
431
+ return
432
+ }
433
+ try {
434
+ const body = await readJsonBody(req)
435
+ const cwd = url.searchParams.get('cwd') ?? undefined
436
+ const roots = allSkillRoots(cwd)
437
+ const action = String(body?.action ?? '')
438
+
439
+ if (action === 'backups') {
440
+ json(200, { ok: true, backups: await listSkillBackups(roots) })
441
+ return
442
+ }
443
+ if (action === 'restore') {
444
+ json(200, { ok: true, ...(await restoreSkillBackup(body?.id, roots)) })
445
+ return
446
+ }
447
+
448
+ // Resolve by NAME through the host's own scan, never by a path the browser
449
+ // supplied: the panel's primary source is the official `skills/list` RPC,
450
+ // whose DTO is not guaranteed to carry a filesystem path — and a name the
451
+ // host looks up itself cannot point outside the skill roots at all.
452
+ const name = String(body?.name ?? '')
453
+ if (name === '') {
454
+ json(400, { ok: false, error: 'name is required' })
455
+ return
456
+ }
457
+ const known = (await scanSkills(cwd)).find((skill) => skill.name === name)
458
+ if (known === undefined) {
459
+ json(404, { ok: false, error: `no skill named "${name}"` })
460
+ return
461
+ }
462
+ const target = resolveSkillPath(known.path, roots)
463
+ if (target === undefined) {
464
+ json(403, { ok: false, error: 'that skill lives outside the skill roots' })
465
+ return
466
+ }
467
+
468
+ if (action === 'disable') {
469
+ json(200, { ok: true, ...(await setSkillDisabled(target, true)) })
470
+ return
471
+ }
472
+ if (action === 'enable') {
473
+ json(200, { ok: true, ...(await setSkillDisabled(target, false)) })
474
+ return
475
+ }
476
+ if (action === 'reveal') {
477
+ json(200, { ok: true, revealed: revealSkill(target) })
478
+ return
479
+ }
480
+ if (action === 'uninstall') {
481
+ json(200, { ok: true, ...(await uninstallSkill(target)) })
482
+ return
483
+ }
484
+ json(400, { ok: false, error: `unknown action: ${action}` })
485
+ } catch (error) {
486
+ json(500, { ok: false, error: String(error?.message ?? error) })
487
+ }
488
+ }
489
+
367
490
  /**
368
491
  * Mount the skills route and the prompt section.
369
492
  * @param ctx - context carrying webServer and systemPrompt.
@@ -377,6 +500,10 @@ export function apply(ctx) {
377
500
  await handleSharedState(req, res, url)
378
501
  return
379
502
  }
503
+ if (url.pathname === '/dsh-skill-picker/skill') {
504
+ await handleSkillOp(req, res, url)
505
+ return
506
+ }
380
507
  // cwd query carries the active session's workspace root from the client.
381
508
  const cwd = url.searchParams.get('cwd') ?? undefined
382
509
  const skills = await scanSkills(cwd)