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.
- package/README.md +3 -1
- package/lib/client.js +310 -20
- 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 +389 -23
- 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,24 +346,67 @@ const statusStyle = {
|
|
|
315
346
|
fontSize: '13px',
|
|
316
347
|
}
|
|
317
348
|
|
|
318
|
-
/**
|
|
319
|
-
const
|
|
320
|
-
|
|
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
|
-
|
|
367
|
+
gap: '8px',
|
|
323
368
|
margin: '0 8px 8px',
|
|
324
|
-
padding: '
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
{
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
{
|
|
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={
|
|
696
|
-
|
|
1008
|
+
style={{ ...menuStyle, left: `${menu.x}px`, top: `${menu.y}px` }}
|
|
1009
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
697
1010
|
>
|
|
698
|
-
<
|
|
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,
|
|
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)
|