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.
- package/README.md +2 -1
- package/lib/client.js +312 -2
- package/lib/client.js.map +2 -2
- package/lib/index.js +244 -18
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +382 -2
- package/src/index.js +129 -2
- package/src/skill-ops.js +233 -0
package/src/client/index.jsx
CHANGED
|
@@ -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,
|
|
180
|
+
content = await readFile(path.join(skillDir, ENTRY), 'utf8')
|
|
168
181
|
} catch {
|
|
169
|
-
|
|
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)
|