dsh-skill-picker 0.3.4 → 0.5.4

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.
@@ -15,7 +15,7 @@
15
15
  * @module dsh-skill-picker/client
16
16
  */
17
17
 
18
- import React, { useCallback, useEffect, useRef, useState } from 'react'
18
+ import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
19
19
  import fuzzysort from 'fuzzysort'
20
20
  import { pinyin } from 'pinyin-pro'
21
21
 
@@ -25,6 +25,30 @@ export const inject = ['slots', 'connection', 'sessions', 'inputTriggers']
25
25
  /** localStorage key for the picker's per-browser usage history. */
26
26
  const USAGE_KEY = 'dsh-skill-picker:usage'
27
27
 
28
+ /** localStorage key for the user's manually pinned skills (ordered array of names). */
29
+ const PINNED_KEY = 'dsh-skill-picker:pinned'
30
+
31
+ /** Read the pinned list {string[]}; never throws. */
32
+ function loadPinned() {
33
+ try {
34
+ const raw = localStorage.getItem(PINNED_KEY)
35
+ if (raw === null) return []
36
+ const parsed = JSON.parse(raw)
37
+ return Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string') : []
38
+ } catch {
39
+ return []
40
+ }
41
+ }
42
+
43
+ /** Persist the pinned list; never throws. */
44
+ function savePinned(pinned) {
45
+ try {
46
+ localStorage.setItem(PINNED_KEY, JSON.stringify(pinned))
47
+ } catch {
48
+ /* storage unavailable — pinning just won't persist */
49
+ }
50
+ }
51
+
28
52
  /** Read the usage history {name: {count, lastUsed}}; never throws. */
29
53
  function loadUsage() {
30
54
  try {
@@ -65,6 +89,30 @@ function rankByUsage(skills, usage) {
65
89
  })
66
90
  }
67
91
 
92
+ /**
93
+ * Grouped ordering for the ⚡ panel: pinned (manual, pinned order) first,
94
+ * then recently/frequently used (usage order), then the untouched rest (by
95
+ * name). Groups with no members are dropped. A fresh structure is returned;
96
+ * the input arrays are untouched.
97
+ */
98
+ function groupByPinned(skills, usage, pinned) {
99
+ const pinnedSet = new Set(pinned)
100
+ const pinnedList = pinned.map((name) => skills.find((s) => s.name === name)).filter(Boolean)
101
+ const ranked = rankByUsage(skills, usage)
102
+ const recent = []
103
+ const rest = []
104
+ for (const skill of ranked) {
105
+ if (pinnedSet.has(skill.name)) continue
106
+ if (usage[skill.name] !== undefined) recent.push(skill)
107
+ else rest.push(skill)
108
+ }
109
+ return [
110
+ { title: '📌 置顶', items: pinnedList },
111
+ { title: '🔥 最近使用', items: recent },
112
+ { title: '🗂️ 全部', items: rest },
113
+ ].filter((group) => group.items.length > 0)
114
+ }
115
+
68
116
  /**
69
117
  * Pinyin search text for a skill name/description: spaced full pinyin
70
118
  * (`ji yi`), joined (`jiyi`), and initials (`jy`) for the name, plus joined
@@ -241,10 +289,38 @@ function SkillPickerButton(props) {
241
289
  const [source, setSource] = useState(undefined)
242
290
  const [query, setQuery] = useState('')
243
291
  const [usage, setUsage] = useState(() => loadUsage())
292
+ const [pinned, setPinned] = useState(() => loadPinned())
244
293
  const [active, setActive] = useState(0)
245
294
  const boxRef = useRef(null)
246
295
  const itemRefs = useRef([])
247
296
 
297
+ // The usage store is shared with the official `/` menu: picks made there go
298
+ // through window.__dshSkillPickerTrack (localStorage only). Refresh this
299
+ // panel's state from storage whenever that event fires, so a slash pick
300
+ // shows up as "recently used" here too — not just in the slash list.
301
+ useEffect(() => {
302
+ const onUsageUpdated = () => setUsage(loadUsage())
303
+ window.addEventListener('dsh-skill-picker:usage-updated', onUsageUpdated)
304
+ return () => window.removeEventListener('dsh-skill-picker:usage-updated', onUsageUpdated)
305
+ }, [])
306
+
307
+ // Latest draft mirror: `useInput` is a selector hook and may only be called
308
+ // during render, while the pick handler runs from a click callback. Sync the
309
+ // store's current draft into a ref here (render time), so the click handler
310
+ // appends onto the REAL current draft instead of a stale snapshot. The
311
+ // owner-provided `input` snapshot is a secondary fallback only.
312
+ const draftRef = useRef('')
313
+ if (typeof props.useInput === 'function') {
314
+ try {
315
+ const state = props.useInput((s) => s)
316
+ if (state !== undefined && typeof state.draft === 'string') draftRef.current = state.draft
317
+ } catch {
318
+ /* keep the last known draft */
319
+ }
320
+ } else if (props.input !== undefined && typeof props.input.draft === 'string') {
321
+ draftRef.current = props.input.draft
322
+ }
323
+
248
324
  const load = useCallback(async () => {
249
325
  if (skills !== undefined || error !== undefined) return
250
326
  try {
@@ -273,23 +349,18 @@ function SkillPickerButton(props) {
273
349
  }, [skills, error, props.listSkills, props.session, props.cwd])
274
350
 
275
351
  const toggle = () => {
276
- if (!open) void load()
352
+ if (!open) {
353
+ setUsage(loadUsage())
354
+ void load()
355
+ }
277
356
  setOpen(!open)
278
357
  }
279
358
 
280
359
  const pick = (name) => {
281
- // Draft source: owner InputZone snapshot first, framework hook second.
282
- let draft = ''
283
- try {
284
- if (props.input !== undefined && typeof props.input.draft === 'string') {
285
- draft = props.input.draft
286
- } else if (typeof props.useInput === 'function') {
287
- const state = props.useInput((s) => s)
288
- if (state !== undefined && typeof state.draft === 'string') draft = state.draft
289
- }
290
- } catch (cause) {
291
- console.error('[dsh-skill-picker] reading draft failed:', cause)
292
- }
360
+ // Draft source: the render-time mirror of the live input store (see the
361
+ // `draftRef` sync above). Appending onto anything else risks overwriting
362
+ // the user's typed draft with a stale snapshot.
363
+ const draft = draftRef.current
293
364
  const separator = draft === '' || draft.endsWith(' ') || draft.endsWith('\n') ? '' : ' '
294
365
  const next = `${draft}${separator}/${name} `
295
366
  try {
@@ -311,6 +382,12 @@ function SkillPickerButton(props) {
311
382
  setQuery('')
312
383
  }
313
384
 
385
+ const togglePin = (name) => {
386
+ const next = pinned.includes(name) ? pinned.filter((n) => n !== name) : [...pinned, name]
387
+ setPinned(next)
388
+ savePinned(next)
389
+ }
390
+
314
391
  // Close on outside pointer-down (the shell's menu convention).
315
392
  useEffect(() => {
316
393
  if (!open) return
@@ -321,11 +398,12 @@ function SkillPickerButton(props) {
321
398
  return () => document.removeEventListener('mousedown', onDown)
322
399
  }, [open])
323
400
 
324
- // Usage ordering: last picked first, then most frequent, then by name
325
- // (shared with the `/` completion — one rule everywhere).
326
- const ordered = rankByUsage(skills ?? [], usage)
401
+ // Grouped ordering: pinned first (manual), then usage-ranked (recent then
402
+ // frequent then untouched by name). Shared rule with the `/` completion.
403
+ const groups = groupByPinned(skills ?? [], usage, pinned)
404
+ const flat = groups.flatMap((group) => group.items)
327
405
 
328
- const filtered = ordered
406
+ const filtered = flat
329
407
  .filter((skill) => {
330
408
  const q = query.trim().toLowerCase()
331
409
  if (q === '') return true
@@ -337,6 +415,11 @@ function SkillPickerButton(props) {
337
415
  })
338
416
  .slice(0, 60)
339
417
 
418
+ // Group titles show only while browsing (no query); searching collapses the
419
+ // list into one flat, pinned-first result set.
420
+ const showTitles = query.trim() === '' && groups.length > 1
421
+ const filteredNames = new Set(filtered.map((skill) => skill.name))
422
+
340
423
  // Keyboard navigation (#1): reset highlight when the query changes, keep it
341
424
  // in range when the result list shrinks, and keep the highlighted row visible.
342
425
  useEffect(() => {
@@ -401,8 +484,9 @@ function SkillPickerButton(props) {
401
484
  <div style={listStyle}>
402
485
  {filtered.length === 0 ? (
403
486
  <div style={statusStyle}>没有匹配的技能</div>
404
- ) : (
405
- filtered.map((skill, index) => (
487
+ ) : (() => {
488
+ let itemIndex = 0
489
+ const renderItem = (skill, index) => (
406
490
  <button
407
491
  key={skill.name}
408
492
  type="button"
@@ -419,16 +503,73 @@ function SkillPickerButton(props) {
419
503
  }}
420
504
  style={{
421
505
  ...itemStyle,
506
+ flexDirection: 'row',
507
+ alignItems: 'center',
422
508
  ...(index === active
423
509
  ? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))' }
424
510
  : {}),
425
511
  }}
426
512
  >
427
- <span style={nameStyle}>{`/${skill.name}`}</span>
428
- <span style={descStyle}>{skill.description ?? ''}</span>
513
+ <span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
514
+ <span style={nameStyle}>{`/${skill.name}`}</span>
515
+ <span style={descStyle}>{skill.description ?? ''}</span>
516
+ </span>
517
+ <span
518
+ role="button"
519
+ tabIndex={-1}
520
+ title={pinned.includes(skill.name) ? '取消置顶' : '置顶到列表顶部'}
521
+ aria-label={pinned.includes(skill.name) ? '取消置顶' : '置顶'}
522
+ onClick={(event) => {
523
+ event.stopPropagation()
524
+ togglePin(skill.name)
525
+ }}
526
+ style={{
527
+ flex: 'none',
528
+ marginLeft: '6px',
529
+ padding: '2px 4px',
530
+ borderRadius: '6px',
531
+ fontSize: '12px',
532
+ lineHeight: '16px',
533
+ cursor: 'pointer',
534
+ color: pinned.includes(skill.name)
535
+ ? 'var(--dsw-alias-label-primary-bluish, #4cc9f0)'
536
+ : 'var(--dsw-alias-label-tertiary, #8a94a6)',
537
+ opacity: pinned.includes(skill.name) ? 1 : 0.55,
538
+ userSelect: 'none',
539
+ }}
540
+ >
541
+ {pinned.includes(skill.name) ? '📌' : '📍'}
542
+ </span>
429
543
  </button>
430
- ))
431
- )}
544
+ )
545
+ if (!showTitles) {
546
+ return filtered.map((skill) => renderItem(skill, itemIndex++))
547
+ }
548
+ return groups.map((group) => {
549
+ const items = group.items.filter((skill) => filteredNames.has(skill.name))
550
+ if (items.length === 0) return null
551
+ return (
552
+ <Fragment key={group.title}>
553
+ <div
554
+ style={{
555
+ display: 'flex',
556
+ alignItems: 'center',
557
+ justifyContent: 'space-between',
558
+ padding: '6px 10px 2px',
559
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
560
+ fontSize: '11px',
561
+ fontWeight: 600,
562
+ letterSpacing: '0.04em',
563
+ }}
564
+ >
565
+ <span>{group.title}</span>
566
+ <span style={{ opacity: 0.7 }}>{items.length}</span>
567
+ </div>
568
+ {items.map((skill) => renderItem(skill, itemIndex++))}
569
+ </Fragment>
570
+ )
571
+ })
572
+ })()}
432
573
  </div>
433
574
  {source === 'host' && (
434
575
  <div style={sourceBadgeStyle} title="官方技能 API 不可用,列表来自本地目录扫描(与官方 / 补全同源)">
@@ -445,12 +586,6 @@ function SkillPickerButton(props) {
445
586
 
446
587
  /** Apply the browser half: register the picker into the composer tool row. */
447
588
  export function apply(ctx) {
448
- // Resolve the trigger pipeline service at apply scope (not inside an effect),
449
- // exactly like the official ui-skill plugin does — in DSH 0.1.2-alpha.x the
450
- // service lives in @deepseek-ai/dsh-client-ui-input-trigger and is only
451
- // reachable from the plugin root context.
452
- const inputTriggers = (typeof ctx.get === 'function' ? ctx.get('inputTriggers') : ctx.inputTriggers)
453
-
454
589
  // Primary skill source: the official host skills API. In DSH 0.1.2-alpha.x
455
590
  // the RPC moved from `connection.api.skills` (rc.x) to `remote.skills`
456
591
  // (used by the official ui-skill plugin); try both before falling back.
@@ -481,28 +616,6 @@ export function apply(ctx) {
481
616
  }
482
617
  }
483
618
 
484
- // Host-route fallback: same scan the ⚡ panel uses (official provider roots).
485
- const fetchHostSkills = async () => {
486
- const cwd = typeof currentCwd === 'string' && currentCwd !== '' ? `?cwd=${encodeURIComponent(currentCwd)}` : ''
487
- const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
488
- const json = await res.json()
489
- if (!json.ok) throw new Error(json.error || 'bad response')
490
- return Array.isArray(json.skills) ? json.skills : []
491
- }
492
-
493
- // Resolve skills by official RPC first, then the host scan route; never
494
- // throws (returns [] when both fail) so `/` completion degrades gracefully.
495
- const resolveSkills = async (sessionId) => {
496
- try {
497
- return await listSkills(sessionId)
498
- } catch {
499
- try {
500
- return await fetchHostSkills()
501
- } catch {
502
- return []
503
- }
504
- }
505
- }
506
619
  syncCwd()
507
620
  const unsubscribe = ctx.sessions.list.subscribe(syncCwd)
508
621
  ctx.effect(() => {
@@ -523,107 +636,57 @@ export function apply(ctx) {
523
636
  }
524
637
  }, 'dsh-skill-picker: composer input slot')
525
638
 
526
- // Fuzzy `/` completion source: negative order puts the skill group ABOVE
527
- // the slash commands (command source sits at order 0), and beats the
528
- // official ui-skill prefix source too — typing `/` matches name AND
529
- // description anywhere, with usage ordering (same rule as the ⚡ panel).
639
+ // Fuzzy `/` completion: instead of registering a parallel source group
640
+ // (which would appear as a second list next to the official one), expose a
641
+ // global matcher that the patched official ui-skill candidates calls. The
642
+ // official group stays THE single `/` list; only its matching behaviour is
643
+ // upgraded to fuzzy + pinyin (name AND description, subsequence scoring).
530
644
  ctx.effect(() => {
531
- // Skills cache per session, so both candidates and the lexicon (chip
532
- // decoration of `/skill-name` in the draft) have the same names.
533
- const namesCache = new Map() // sessionId -> string[] (skill names)
534
- const lexiconListeners = new Map() // sessionId -> Set<listener>
535
- const notifyLexicon = (sessionId) => {
536
- for (const fn of [...(lexiconListeners.get(sessionId) ?? [])]) {
537
- try { fn() } catch (err) { console.error('[dsh-skill-picker] lexicon listener failed:', err) }
538
- }
645
+ // Mirror the ⚡ panel's ordering: pinned first, then recently/frequently
646
+ // used skills, then the untouched rest — so both stay in sync.
647
+ const fuzzyMatch = (skills, query = '') => {
648
+ const ordered = groupByPinned(skills, loadUsage(), loadPinned()).flatMap((group) => group.items)
649
+ const q = String(query).trim().toLowerCase()
650
+ if (q === '') return ordered
651
+ // Rank by the ⚡ panel's exact order (pinned → recent → frequent → rest)
652
+ // so both lists stay in sync: fuzzysort only decides WHO matches, not
653
+ // the display order. Without this, a slash query re-sorts matches by
654
+ // match score and the two menus diverge for the same skill.
655
+ const rankByName = new Map(ordered.map((skill, index) => [skill.name, index]))
656
+ const targets = ordered.map((s) => ({
657
+ s,
658
+ search: `${s.name} ${s.description ?? ''} ${skillPinyinText(s.name, s.description ?? '')}`,
659
+ }))
660
+ const results = fuzzysort.go(q, targets, {
661
+ key: 'search',
662
+ limit: 30,
663
+ threshold: -10000,
664
+ })
665
+ return results
666
+ .filter((r) => r.score > 0)
667
+ .map((r) => r.obj.s)
668
+ .sort((a, b) => (rankByName.get(a.name) ?? 0) - (rankByName.get(b.name) ?? 0))
539
669
  }
540
- const refreshNames = async (sessionId) => {
670
+ window.__dshSkillPickerFuzzy = fuzzyMatch
671
+ // Usage tracking for picks made from the official `/` menu: the patched
672
+ // ui-skill onPick calls this so a slash pick ranks like a bolt-panel pick.
673
+ const trackPick = (name) => {
674
+ const usage = loadUsage()
675
+ const nextUsage = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
676
+ saveUsage(nextUsage)
677
+ // Notify the bolt panel (and any other listeners) to re-read storage so
678
+ // a slash pick ranks as "recently used" there too, not only in the
679
+ // official / menu.
541
680
  try {
542
- const skills = await resolveSkills(sessionId)
543
- namesCache.set(sessionId, (Array.isArray(skills) ? skills : []).map((s) => s.name))
544
- notifyLexicon(sessionId)
681
+ window.dispatchEvent(new CustomEvent('dsh-skill-picker:usage-updated'))
545
682
  } catch {
546
- /* keep last cache; lexicon stays empty until a successful fetch */
683
+ /* best-effort */
547
684
  }
548
685
  }
549
-
550
- const source = {
551
- trigger: '/',
552
- name: 'skill-fuzzy',
553
- order: -10,
554
- async candidates(session, { query, signal }) {
555
- const skills = await resolveSkills(session.sessionId)
556
- if (signal.aborted) return []
557
- namesCache.set(session.sessionId, skills.map((s) => s.name))
558
- notifyLexicon(session.sessionId)
559
- const ordered = rankByUsage(skills, loadUsage())
560
- const q = String(query ?? '').trim().toLowerCase()
561
- if (q === '') {
562
- // Empty `/` surfaces ALL skills, usage first (same rule as ⚡ panel).
563
- return ordered.map((s) => ({ name: s.name, description: s.description }))
564
- }
565
- // Typing uses fuzzysort (subsequence matching + relevance score) over
566
- // name AND description, so partial/gappy queries and keywords match.
567
- // The search string also carries the skill's pinyin forms (spaced,
568
- // joined, initials — name and description), so `ji yi` / `jiyi` / `jy`
569
- // match Chinese skill names & descriptions.
570
- const targets = ordered.map((s) => ({
571
- s,
572
- search: `${s.name} ${s.description ?? ''} ${skillPinyinText(s.name, s.description ?? '')}`,
573
- }))
574
- const results = fuzzysort.go(q, targets, {
575
- key: 'search',
576
- limit: 12,
577
- threshold: -10000,
578
- })
579
- return results
580
- .filter((r) => r.score > 0)
581
- .map((r) => ({ name: r.obj.s.name, description: r.obj.s.description }))
582
- },
583
- warm(session) {
584
- refreshNames(session.sessionId)
585
- },
586
- lexicon(session) {
587
- return namesCache.get(session.sessionId)
588
- },
589
- subscribeLexicon(session, listener) {
590
- const key = session.sessionId
591
- const set = lexiconListeners.get(key) ?? new Set()
592
- set.add(listener)
593
- lexiconListeners.set(key, set)
594
- return () => {
595
- const cur = lexiconListeners.get(key)
596
- if (cur !== undefined) {
597
- cur.delete(listener)
598
- if (cur.size === 0) lexiconListeners.delete(key)
599
- }
600
- }
601
- },
602
- onPick({ candidate }) {
603
- // Record usage (same rule as the ⚡ panel) so slash-picked skills
604
- // rank higher the next time they open `/`.
605
- try {
606
- const usage = loadUsage()
607
- const name = candidate.name
608
- const next = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
609
- saveUsage(next)
610
- } catch {
611
- /* usage recording is best-effort */
612
- }
613
- return { text: `/${candidate.name} ` }
614
- },
615
- }
616
- // inputTriggers is resolved at apply scope (see top of apply); fail loudly
617
- // instead of silently dropping the fuzzy `/` source.
618
- if (inputTriggers === undefined || typeof inputTriggers.registerSource !== 'function') {
619
- console.warn('[dsh-skill-picker] inputTriggers service unavailable; fuzzy / completion disabled (⚡ panel still works)')
620
- return
621
- }
622
- const unregister = inputTriggers.registerSource(source)
686
+ window.__dshSkillPickerTrack = trackPick
623
687
  return () => {
624
- unregister()
625
- namesCache.clear()
626
- lexiconListeners.clear()
688
+ if (window.__dshSkillPickerFuzzy === fuzzyMatch) delete window.__dshSkillPickerFuzzy
689
+ if (window.__dshSkillPickerTrack === trackPick) delete window.__dshSkillPickerTrack
627
690
  }
628
- }, 'dsh-skill-picker: fuzzy / source')
691
+ }, 'dsh-skill-picker: fuzzy matcher for official / source')
629
692
  }
package/src/index.js CHANGED
@@ -22,6 +22,8 @@ import { readFile, readdir } from 'node:fs/promises'
22
22
  import os from 'node:os'
23
23
  import path from 'node:path'
24
24
 
25
+ import { healUiSkillPatches } from './patch-ui-skill.js'
26
+
25
27
  /** Required services: the route registry and the prompt band. */
26
28
  export const inject = ['webServer', 'systemPrompt']
27
29
 
@@ -138,4 +140,19 @@ export function apply(ctx) {
138
140
  order: SECTION_ORDER,
139
141
  text: SKILL_PICKER_GUIDANCE,
140
142
  }), 'dsh-skill-picker: prompt section')
143
+
144
+ // Self-healing patch for the official ui-skill package: keeps the `/`
145
+ // completion's skill group ordered above commands and its matching fuzzy
146
+ // across DSH upgrades. Runs once per boot; idempotent, backed up, and
147
+ // never allowed to take the host down.
148
+ ctx.effect(() => {
149
+ healUiSkillPatches().then((report) => {
150
+ if (report.files.length > 0) {
151
+ console.log('[dsh-skill-picker] ui-skill patch report:', JSON.stringify(report))
152
+ }
153
+ }).catch((error) => {
154
+ console.warn('[dsh-skill-picker] ui-skill patch failed:', error)
155
+ })
156
+ return () => {}
157
+ }, 'dsh-skill-picker: ui-skill self-heal patch')
141
158
  }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * dsh-skill-picker — host-side self-healing patch for the official
3
+ * `@deepseek-ai/dsh-client-ui-skill` package.
4
+ *
5
+ * The picker upgrades the official `/` completion in two ways that the
6
+ * official package does not provide out of the box:
7
+ *
8
+ * 1. `order: 2 → -1` — the skill group sorts ABOVE the command group
9
+ * (commands register with the default order 0; lower = higher in the
10
+ * official menu).
11
+ * 2. fuzzy+pinyin candidates — the official prefix-only matcher
12
+ * (`skill.name.startsWith(query)`) is replaced by the picker's
13
+ * `window.__dshSkillPickerFuzzy` matcher when the picker is mounted
14
+ * (single source group, same list, upgraded matching).
15
+ *
16
+ * Every DSH boot this module scans every profile under `$DSH_HOME/profiles`
17
+ * (default `~/.dsh/profiles`) for an installed ui-skill `lib/client.js` —
18
+ * either the user's local patched copy (`local/dsh-client-ui-skill`) or the
19
+ * plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) — and
20
+ * re-applies both patches when DSH upgrades overwrote them. The original file
21
+ * is backed up once as `<file>.dsh-skill-picker.bak` before the first write.
22
+ * All operations are idempotent and never throw: a missing profile, package
23
+ * or read failure is reported and skipped so a broken patch can never take
24
+ * the host down.
25
+ *
26
+ * @module dsh-skill-picker/patch-ui-skill
27
+ */
28
+
29
+ import { readFile, writeFile, copyFile, readdir, access } from 'node:fs/promises'
30
+ import os from 'node:os'
31
+ import path from 'node:path'
32
+
33
+ /** Marker that the candidates patch is already in place. */
34
+ const FUZZY_MARKER = '__dshSkillPickerFuzzy'
35
+
36
+ /** Marker that the pick-tracking patch is already in place. */
37
+ const TRACK_MARKER = '__dshSkillPickerTrack'
38
+
39
+ /** The self-healing patches, in application order. */
40
+ export const PATCHES = [
41
+ {
42
+ id: 'order',
43
+ title: 'skill group order 2 → -1 (above commands)',
44
+ isApplied(text) {
45
+ return /name: "skill",[\s\S]*?order:\s*-1,/.test(text)
46
+ },
47
+ apply(text) {
48
+ return text.replace(/(name: "skill",\s*order: )2,/, '$1-1,')
49
+ },
50
+ },
51
+ {
52
+ id: 'fuzzy-candidates',
53
+ title: 'prefix matcher → fuzzy+pinyin matcher',
54
+ isApplied(text) {
55
+ return text.includes(FUZZY_MARKER)
56
+ },
57
+ apply(text) {
58
+ return text.replace(
59
+ /(\t*)return skills\.filter\(\(skill\) => skill\.name\.startsWith\(query\)\)\.map\(\(skill\) => \(\{/,
60
+ (match, indent) =>
61
+ `${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\n` +
62
+ `${indent}const matcher = typeof window.${FUZZY_MARKER} === "function" ? window.${FUZZY_MARKER}(skills, query) : skills.filter((skill) => skill.name.startsWith(query));\n` +
63
+ `${indent}return matcher.map((skill) => ({`,
64
+ )
65
+ },
66
+ },
67
+ {
68
+ id: 'pick-tracking',
69
+ title: 'record usage when picked from the official / menu',
70
+ isApplied(text) {
71
+ return text.includes(TRACK_MARKER)
72
+ },
73
+ apply(text) {
74
+ return text.replace(
75
+ /(\t*)onPick\(\{ candidate \}\) \{\n(\t*)return \{ text: `\/\$\{candidate\.name\} ` \};\n(\t*)\}/,
76
+ (match, i1, i2, i3) =>
77
+ `${i1}onPick({ candidate }) {\n` +
78
+ `${i2} // dsh-skill-picker patch: usage tracking (self-healed)\n` +
79
+ `${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\n` +
80
+ `${i2} return { text: \`/\${candidate.name} \` };\n` +
81
+ `${i3}}`,
82
+ )
83
+ },
84
+ },
85
+ ]
86
+
87
+ /** Resolve the DSH home directory, mirroring the official convention. */
88
+ export function dshHome() {
89
+ return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
90
+ }
91
+
92
+ /**
93
+ * Enumerate every installed ui-skill `lib/client.js` across all profiles:
94
+ * the user's local patched copy first (that is what the profile actually
95
+ * loads when linked), then the plain npm install. Deduplicated by real path.
96
+ * Never throws — a missing profiles dir yields [].
97
+ * @returns {Promise<string[]>} candidate file paths.
98
+ */
99
+ export async function uiSkillClientPaths() {
100
+ const profilesDir = path.join(dshHome(), 'profiles')
101
+ let profiles
102
+ try {
103
+ profiles = await readdir(profilesDir, { withFileTypes: true })
104
+ } catch {
105
+ return []
106
+ }
107
+ const seen = new Set()
108
+ const found = []
109
+ for (const entry of profiles) {
110
+ if (!entry.isDirectory()) continue
111
+ const candidates = [
112
+ path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'),
113
+ path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'),
114
+ ]
115
+ for (const candidate of candidates) {
116
+ try {
117
+ await access(candidate)
118
+ const real = await import('node:fs/promises').then(({ realpath }) => realpath(candidate))
119
+ if (!seen.has(real)) {
120
+ seen.add(real)
121
+ found.push(candidate)
122
+ }
123
+ } catch {
124
+ /* not present at this location */
125
+ }
126
+ }
127
+ }
128
+ return found
129
+ }
130
+
131
+ /**
132
+ * Apply both patches to one ui-skill client.js. Idempotent: already-applied
133
+ * patches are reported as skipped; the original file is backed up once before
134
+ * the first modification. Never throws for a patch that does not match (it is
135
+ * reported as `noop`), only for actual I/O failures.
136
+ * @param {string} file - absolute path to the target client.js.
137
+ * @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}
138
+ */
139
+ export async function patchUiSkillFile(file) {
140
+ const text = await readFile(file, 'utf8')
141
+ const result = { file, patched: [], skipped: [], noop: [] }
142
+ let next = text
143
+ for (const patch of PATCHES) {
144
+ if (patch.isApplied(next)) {
145
+ result.skipped.push(patch.id)
146
+ continue
147
+ }
148
+ const candidate = patch.apply(next)
149
+ if (candidate === next) {
150
+ result.noop.push(patch.id)
151
+ continue
152
+ }
153
+ next = candidate
154
+ result.patched.push(patch.id)
155
+ }
156
+ if (result.patched.length === 0) return result
157
+ const backup = `${file}.dsh-skill-picker.bak`
158
+ try {
159
+ await access(backup)
160
+ } catch {
161
+ await copyFile(file, backup)
162
+ }
163
+ await writeFile(file, next, 'utf8')
164
+ return result
165
+ }
166
+
167
+ /**
168
+ * Self-heal entry point: scan all profiles, patch every ui-skill copy found,
169
+ * and return one combined report. Never throws — each failure is collected
170
+ * into `errors` so the host boot is never taken down by a broken patch.
171
+ * @returns {Promise<{files: Array, errors: string[]}>}
172
+ */
173
+ export async function healUiSkillPatches() {
174
+ const files = await uiSkillClientPaths()
175
+ const filesReport = []
176
+ const errors = []
177
+ for (const file of files) {
178
+ try {
179
+ filesReport.push(await patchUiSkillFile(file))
180
+ } catch (error) {
181
+ errors.push(`${file}: ${String(error?.message ?? error)}`)
182
+ }
183
+ }
184
+ return { files: filesReport, errors }
185
+ }