dsh-skill-picker 0.5.22 → 0.5.24

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,24 +346,67 @@ const statusStyle = {
315
346
  fontSize: '13px',
316
347
  }
317
348
 
318
- /** Lightweight source badge shown only when the list came from the host scan fallback (official API unavailable). */
319
- const sourceBadgeStyle = {
320
- display: 'inline-flex',
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',
321
366
  alignItems: 'center',
322
- alignSelf: 'flex-start',
367
+ gap: '8px',
323
368
  margin: '0 8px 8px',
324
- padding: '2px 8px',
325
- border: '1px solid rgba(255, 193, 7, 0.35)',
326
- borderRadius: '999px',
327
- background: 'rgba(255, 193, 7, 0.1)',
328
- color: '#d9a520',
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',
329
395
  fontSize: '11px',
330
- lineHeight: '16px',
331
- flex: 'none',
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',
332
402
  }
333
403
 
334
- const sourceBadgeTextStyle = {
335
- fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
404
+ /** One menu row. */
405
+ const menuItemStyle = {
406
+ padding: '6px 10px',
407
+ borderRadius: '6px',
408
+ cursor: 'pointer',
409
+ whiteSpace: 'nowrap',
336
410
  }
337
411
 
338
412
  /** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
@@ -366,12 +440,32 @@ function SkillPickerButton(props) {
366
440
  const [skills, setSkills] = useState(undefined)
367
441
  const [error, setError] = useState(undefined)
368
442
  const [source, setSource] = useState(undefined)
443
+ // Skills whose entry file is renamed to `SKILL.md.disabled`. The official
444
+ // `skills/list` RPC cannot report them (the provider does not see them at
445
+ // all), so they come from the host scan — without this a disabled skill would
446
+ // simply disappear and there would be no way to switch it back on.
447
+ const [disabledSkills, setDisabledSkills] = useState([])
448
+ /** Name of the skill an action is currently running on. */
449
+ const [busySkill, setBusySkill] = useState('')
450
+ /** Result line under the list: { kind, text, undo? }. */
451
+ const [notice, setNotice] = useState(undefined)
452
+ /**
453
+ * The right-click menu: { x, y, skill, disabled }.
454
+ *
455
+ * Actions live here instead of on the row so the list stays a list — one
456
+ * glyph per row (the pin, which doubles as the pinned/not state) and
457
+ * everything else behind the familiar right-click. Long-press opens the same
458
+ * menu, because a phone has no right button.
459
+ */
460
+ const [menu, setMenu] = useState(undefined)
369
461
  const [query, setQuery] = useState('')
370
462
  const [usage, setUsage] = useState(() => loadUsage())
371
463
  const [pinned, setPinned] = useState(() => loadPinned())
372
464
  const [active, setActive] = useState(0)
373
465
  const boxRef = useRef(null)
374
466
  const itemRefs = useRef([])
467
+ /** Pending long-press timer for the touch path (see the menu below). */
468
+ const longPressRef = useRef(0)
375
469
 
376
470
  // The usage store is shared with the official `/` menu: picks made there go
377
471
  // through window.__dshSkillPickerTrack (localStorage only). Refresh this
@@ -429,8 +523,8 @@ function SkillPickerButton(props) {
429
523
  draftRef.current = props.input.draft
430
524
  }
431
525
 
432
- const load = useCallback(async () => {
433
- if (skills !== undefined || error !== undefined) return
526
+ const load = useCallback(async (force = false) => {
527
+ if (!force && (skills !== undefined || error !== undefined)) return
434
528
  // Session identity comes from the slot's standard props; older kernels
435
529
  // carried it as `props.session` (see ./session-view.js).
436
530
  const sessionId = sessionIdOf(props)
@@ -459,6 +553,122 @@ function SkillPickerButton(props) {
459
553
  }
460
554
  }, [skills, error, props.listSkills, props.sessionId, props.session, props.cwd])
461
555
 
556
+ /**
557
+ * Load the disabled skills from the host's own scan.
558
+ *
559
+ * They are invisible to the official `skills/list` RPC, so this is the only
560
+ * way the panel can offer to switch one back on. Best-effort: without it the
561
+ * panel still works, it just cannot re-enable.
562
+ */
563
+ const loadDisabled = useCallback(async () => {
564
+ try {
565
+ const cwd = typeof props.cwd === 'string' && props.cwd !== '' ? `?cwd=${encodeURIComponent(props.cwd)}` : ''
566
+ const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
567
+ const json = await res.json()
568
+ if (!json.ok) return
569
+ setDisabledSkills((Array.isArray(json.skills) ? json.skills : []).filter((skill) => skill.disabled === true))
570
+ } catch {
571
+ /* the disabled list is a convenience; the panel works without it */
572
+ }
573
+ }, [props.cwd])
574
+
575
+ /**
576
+ * Run one management action and report it under the list.
577
+ *
578
+ * Reloads both lists afterwards: a disabled skill moves out of 「全部」 into
579
+ * 「已关闭」, and an uninstall takes it out of both.
580
+ */
581
+ const runSkillOp = async (action, skill, extra = {}) => {
582
+ const name = typeof skill?.name === 'string' ? skill.name : String(skill ?? '')
583
+ setBusySkill(name)
584
+ setNotice(undefined)
585
+ const result = await skillOp(action, { name, cwd: props.cwd, ...extra })
586
+ setBusySkill('')
587
+ if (result.ok !== true) {
588
+ setNotice({ kind: 'err', text: result.error ?? '操作失败' })
589
+ return
590
+ }
591
+ if (action === 'uninstall') {
592
+ const id = String(result.backupPath ?? '').split(/[\\/]/).filter(Boolean).pop()
593
+ setNotice({ kind: 'ok', text: `已卸载 /${name} —— 只是移进备份目录,没删`, undo: id })
594
+ } else if (action === 'disable') {
595
+ setNotice({ kind: 'ok', text: `已关闭 /${name} —— agent 也不会再加载它了` })
596
+ } else if (action === 'enable') {
597
+ setNotice({ kind: 'ok', text: `已开启 /${name}` })
598
+ } else if (action === 'reveal') {
599
+ setNotice({ kind: 'ok', text: `已在文件管理器里定位 /${name}` })
600
+ }
601
+ await Promise.all([load(true), loadDisabled()])
602
+ }
603
+
604
+ const undoUninstall = async (id) => {
605
+ if (typeof id !== 'string' || id === '') return
606
+ setBusySkill('(undo)')
607
+ const result = await skillOp('restore', { id, cwd: props.cwd })
608
+ setBusySkill('')
609
+ if (result.ok !== true) {
610
+ setNotice({ kind: 'err', text: result.error ?? '撤回失败' })
611
+ return
612
+ }
613
+ setNotice({ kind: 'ok', text: '已撤回,技能回到原位置' })
614
+ await Promise.all([load(true), loadDisabled()])
615
+ }
616
+
617
+ /**
618
+ * Open the context menu at a viewport point, kept fully on screen.
619
+ * `event` may be a mouse or a touch event; both carry clientX/clientY.
620
+ */
621
+ const openMenu = (event, skill, disabled) => {
622
+ const width = 208
623
+ const height = disabled ? 84 : 168
624
+ const x = Math.max(8, Math.min(event.clientX, (window.innerWidth || 1024) - width - 8))
625
+ const y = Math.max(8, Math.min(event.clientY, (window.innerHeight || 768) - height - 8))
626
+ setMenu({ x, y, skill, disabled })
627
+ }
628
+
629
+ // Close the menu on any outside interaction — the same contract as a native
630
+ // context menu, so it never lingers over the composer.
631
+ //
632
+ // BUBBLE phase on purpose, and the menu stops the event itself: a capturing
633
+ // listener would run before the clicked item's own handler and close the menu
634
+ // out from under the click, so every action would silently do nothing.
635
+ useEffect(() => {
636
+ if (menu === undefined) return undefined
637
+ const close = () => setMenu(undefined)
638
+ const onKey = (event) => {
639
+ if (event.key === 'Escape') close()
640
+ }
641
+ document.addEventListener('pointerdown', close)
642
+ document.addEventListener('keydown', onKey)
643
+ window.addEventListener('blur', close)
644
+ window.addEventListener('resize', close)
645
+ return () => {
646
+ document.removeEventListener('pointerdown', close)
647
+ document.removeEventListener('keydown', onKey)
648
+ window.removeEventListener('blur', close)
649
+ window.removeEventListener('resize', close)
650
+ }
651
+ }, [menu])
652
+
653
+ /**
654
+ * The panel used to show "本地扫描" / "/ 增强:运行时接管" as badges under the
655
+ * list. They are diagnostics for whoever is debugging, not something a user
656
+ * needs on screen — they occupied two rows and said nothing useful day to day
657
+ * (国豪's call, and he was right).
658
+ *
659
+ * They are not simply dropped: "the plugin is fine" and "the plugin is not
660
+ * running at all" look identical without them, which is the whole lesson
661
+ * behind issue #14. So they moved here (one console line per panel open) and
662
+ * into the ⚡ button's tooltip.
663
+ */
664
+ useEffect(() => {
665
+ if (skills === undefined) return
666
+ console.info(
667
+ `[dsh-skill-picker] 技能列表 ${skills.length} 条,来源:${source === 'host' ? '本地扫描(官方 skills API 不可用)' : '官方 API'}`
668
+ + `;/ 增强:${slashEnhancementMode() === 'runtime' ? '运行时接管,已生效' : '未生效(见 issue #14)'}`,
669
+ )
670
+ }, [skills, source])
671
+
462
672
  const toggle = () => {
463
673
  if (!open) {
464
674
  // Pull first: a pin or a pick made on another client (desktop ↔ web ↔
@@ -467,6 +677,7 @@ function SkillPickerButton(props) {
467
677
  void syncSharedState()
468
678
  setUsage(loadUsage())
469
679
  void load()
680
+ void loadDisabled()
470
681
  }
471
682
  setOpen(!open)
472
683
  }
@@ -570,7 +781,7 @@ function SkillPickerButton(props) {
570
781
  <button
571
782
  type="button"
572
783
  onClick={toggle}
573
- title="选择技能(插入 /技能名 到发送框)"
784
+ title={`选择技能(插入 /技能名 到发送框)${source === 'host' ? ' · 列表来自本地扫描' : ''}${slashEnhancementMode() === 'runtime' ? ' · / 增强:运行时接管' : ''}`}
574
785
  aria-label="选择技能"
575
786
  style={{
576
787
  ...buttonStyle,
@@ -608,6 +819,27 @@ function SkillPickerButton(props) {
608
819
  itemRefs.current[index] = el
609
820
  }}
610
821
  onClick={() => pick(skill.name)}
822
+ onContextMenu={(event) => {
823
+ // Right-click opens the actions menu; see the menu block
824
+ // at the end of the panel. One glyph per row keeps the
825
+ // list scannable.
826
+ event.preventDefault()
827
+ event.stopPropagation()
828
+ openMenu(event, skill, false)
829
+ }}
830
+ onTouchStart={(event) => {
831
+ // A phone has no right button: long-press opens the same
832
+ // menu. Cancelled by any movement, so scrolling a long
833
+ // list never pops it up by accident.
834
+ const touch = event.touches?.[0]
835
+ if (touch === undefined) return
836
+ longPressRef.current = window.setTimeout(
837
+ () => openMenu({ clientX: touch.clientX, clientY: touch.clientY }, skill, false),
838
+ 500,
839
+ )
840
+ }}
841
+ onTouchEnd={() => window.clearTimeout(longPressRef.current)}
842
+ onTouchMove={() => window.clearTimeout(longPressRef.current)}
611
843
  onMouseEnter={(event) => {
612
844
  setActive(index)
613
845
  event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))'
@@ -685,17 +917,151 @@ function SkillPickerButton(props) {
685
917
  })
686
918
  })()}
687
919
  </div>
688
- {source === 'host' && (
689
- <div style={sourceBadgeStyle} title="官方技能 API 不可用,列表来自本地目录扫描(与官方 / 补全同源)">
690
- <span style={sourceBadgeTextStyle}>本地扫描</span>
920
+ {/* Result of the last management action, with an undo for uninstall. */}
921
+ {notice !== undefined && (
922
+ <div style={noticeStyle}>
923
+ <span style={{ flex: '1', color: notice.kind === 'err' ? 'var(--dsw-alias-label-error, #ff7b72)' : undefined }}>
924
+ {notice.text}
925
+ </span>
926
+ {typeof notice.undo === 'string' && notice.undo !== '' && (
927
+ <span
928
+ role="button"
929
+ tabIndex={-1}
930
+ onClick={() => void undoUninstall(notice.undo)}
931
+ style={{ cursor: 'pointer', textDecoration: 'underline', flex: 'none' }}
932
+ >
933
+ 撤回
934
+ </span>
935
+ )}
936
+ </div>
937
+ )}
938
+ {/* Skills switched off. The official RPC never reports these, so
939
+ without this section a disabled skill would be unreachable. */}
940
+ {disabledSkills.length > 0 && (
941
+ <div style={{ marginTop: '4px' }}>
942
+ <div
943
+ style={{
944
+ display: 'flex',
945
+ alignItems: 'center',
946
+ justifyContent: 'space-between',
947
+ padding: '6px 10px 2px',
948
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
949
+ fontSize: '11px',
950
+ fontWeight: 600,
951
+ letterSpacing: '0.04em',
952
+ }}
953
+ >
954
+ <span>⏻ 已关闭</span>
955
+ <span style={{ opacity: 0.7 }}>{disabledSkills.length}</span>
956
+ </div>
957
+ {disabledSkills.map((skill) => (
958
+ <div
959
+ key={`off-${skill.name}`}
960
+ onContextMenu={(event) => {
961
+ event.preventDefault()
962
+ event.stopPropagation()
963
+ openMenu(event, skill, true)
964
+ }}
965
+ style={{
966
+ ...itemStyle,
967
+ flexDirection: 'row',
968
+ alignItems: 'center',
969
+ opacity: 0.55,
970
+ }}
971
+ >
972
+ <span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
973
+ <span style={nameStyle}>{`/${skill.name}`}</span>
974
+ <span style={descStyle}>{skill.description ?? ''}</span>
975
+ </span>
976
+ <span
977
+ role="button"
978
+ tabIndex={-1}
979
+ title="重新开启(把 SKILL.md.disabled 改回 SKILL.md)"
980
+ aria-label="重新开启"
981
+ onClick={() => void runSkillOp('enable', skill)}
982
+ style={actionSpanStyle}
983
+ >
984
+ {busySkill === skill.name ? '…' : '⏻ 开启'}
985
+ </span>
986
+ <span
987
+ role="button"
988
+ tabIndex={-1}
989
+ title="在文件管理器中定位"
990
+ aria-label="在文件管理器中定位"
991
+ onClick={() => void runSkillOp('reveal', skill)}
992
+ style={actionSpanStyle}
993
+ >
994
+ 📂
995
+ </span>
996
+ </div>
997
+ ))}
691
998
  </div>
692
999
  )}
693
- {slashEnhancementMode() === 'runtime' && (
1000
+ <div style={{ margin: '0 10px 8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #8a94a6)' }}>
1001
+ 右键技能 = 置顶 / 关闭 / 定位 / 卸载(手机长按)
1002
+ </div>
1003
+ {/* The actions menu. Rendered here (not inside the row) so it can
1004
+ be positioned against the viewport and never clipped by the
1005
+ list's own scrolling. */}
1006
+ {menu !== undefined && (
694
1007
  <div
695
- style={sourceBadgeStyle}
696
- title="`/` 菜单的模糊+拼音搜索由本插件在运行时接管官方技能源(不依赖改写任何文件,issue #14)"
1008
+ style={{ ...menuStyle, left: `${menu.x}px`, top: `${menu.y}px` }}
1009
+ onPointerDown={(event) => event.stopPropagation()}
697
1010
  >
698
- <span style={sourceBadgeTextStyle}>/ 增强:运行时接管</span>
1011
+ <div style={menuHeaderStyle}>{`/${menu.skill.name}`}</div>
1012
+ {!menu.disabled && (
1013
+ <div
1014
+ style={menuItemStyle}
1015
+ role="button"
1016
+ tabIndex={-1}
1017
+ onClick={() => {
1018
+ setMenu(undefined)
1019
+ togglePin(menu.skill.name)
1020
+ }}
1021
+ >
1022
+ {pinned.includes(menu.skill.name) ? '📌 取消置顶' : '📌 置顶到顶部'}
1023
+ </div>
1024
+ )}
1025
+ <div
1026
+ style={menuItemStyle}
1027
+ role="button"
1028
+ tabIndex={-1}
1029
+ onClick={() => {
1030
+ const target = menu.skill
1031
+ const turningOff = !menu.disabled
1032
+ setMenu(undefined)
1033
+ void runSkillOp(turningOff ? 'disable' : 'enable', target)
1034
+ }}
1035
+ >
1036
+ {menu.disabled ? '⏻ 开启' : '⏻ 关闭(agent 也不再加载)'}
1037
+ </div>
1038
+ <div
1039
+ style={menuItemStyle}
1040
+ role="button"
1041
+ tabIndex={-1}
1042
+ onClick={() => {
1043
+ const target = menu.skill
1044
+ setMenu(undefined)
1045
+ void runSkillOp('reveal', target)
1046
+ }}
1047
+ >
1048
+ 📂 在文件管理器中定位
1049
+ </div>
1050
+ {!menu.disabled && (
1051
+ <div
1052
+ style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #ff7b72)' }}
1053
+ role="button"
1054
+ tabIndex={-1}
1055
+ onClick={() => {
1056
+ const target = menu.skill
1057
+ setMenu(undefined)
1058
+ if (!window.confirm(`卸载 /${target.name}?\n\n它会被移进备份目录(不是删除),面板里可以撤回。`)) return
1059
+ void runSkillOp('uninstall', target)
1060
+ }}
1061
+ >
1062
+ 🗑 卸载(移入备份,可撤回)
1063
+ </div>
1064
+ )}
699
1065
  </div>
700
1066
  )}
701
1067
  </>
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)