dsh-skill-picker 0.5.10 → 0.5.12
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 +8 -3
- package/lib/client.js.map +2 -2
- package/lib/index.js +66 -6
- package/lib/index.js.map +2 -2
- package/package.json +3 -2
- package/src/client/index.jsx +22 -3
- package/src/index.js +142 -6
- package/src/patch-ui-skill.js +251 -251
package/src/patch-ui-skill.js
CHANGED
|
@@ -1,251 +1,251 @@
|
|
|
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` — the
|
|
18
|
-
* shared core root (`profiles/node_modules/@deepseek-ai/…`, used by global
|
|
19
|
-
* installs), the user's local patched copy (`local/dsh-client-ui-skill`), or
|
|
20
|
-
* the plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) — and
|
|
21
|
-
* re-applies both patches when DSH upgrades overwrote them. The original file
|
|
22
|
-
* is backed up once as `<file>.dsh-skill-picker.bak` before the first write.
|
|
23
|
-
* All operations are idempotent and never throw: a missing profile, package
|
|
24
|
-
* or read failure is reported and skipped so a broken patch can never take
|
|
25
|
-
* the host down.
|
|
26
|
-
*
|
|
27
|
-
* @module dsh-skill-picker/patch-ui-skill
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { readFile, writeFile, copyFile, readdir, access, realpath, rename } from 'node:fs/promises'
|
|
31
|
-
import { createRequire } from 'node:module'
|
|
32
|
-
import os from 'node:os'
|
|
33
|
-
import path from 'node:path'
|
|
34
|
-
|
|
35
|
-
import { isDirectoryEntry } from './dir-entry.js'
|
|
36
|
-
|
|
37
|
-
/** Marker that the candidates patch is already in place. */
|
|
38
|
-
const FUZZY_MARKER = '__dshSkillPickerFuzzy'
|
|
39
|
-
|
|
40
|
-
/** Marker that the pick-tracking patch is already in place. */
|
|
41
|
-
const TRACK_MARKER = '__dshSkillPickerTrack'
|
|
42
|
-
|
|
43
|
-
/** The self-healing patches, in application order. */
|
|
44
|
-
export const PATCHES = [
|
|
45
|
-
{
|
|
46
|
-
id: 'order',
|
|
47
|
-
title: 'skill group order 2 → -1 (above commands)',
|
|
48
|
-
isApplied(text) {
|
|
49
|
-
return /name: "skill",[\s\S]*?order:\s*-1,/.test(text)
|
|
50
|
-
},
|
|
51
|
-
apply(text) {
|
|
52
|
-
return text.replace(/(name: "skill",\s*order: )2,/, '$1-1,')
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
id: 'fuzzy-candidates',
|
|
57
|
-
title: 'prefix/rank matcher → fuzzy+pinyin matcher',
|
|
58
|
-
isApplied(text) {
|
|
59
|
-
return text.includes(FUZZY_MARKER)
|
|
60
|
-
},
|
|
61
|
-
apply(text) {
|
|
62
|
-
// 官方实现随版本变过两次,两个形态都要认(2026-09-11 实测):
|
|
63
|
-
// 0.1.2-alpha.x : return skills.filter((skill) => skill.name.startsWith(query)).map(...)
|
|
64
|
-
// 0.1.5-rc.x : return (0, _xxx.rankByName)(skills, query).map(...) ← 改成 async candidates
|
|
65
|
-
// 每组捕获:$1 = 缩进,$2 = 官方那一段表达式(原样保留做 fallback)。
|
|
66
|
-
const ANCHORS = [
|
|
67
|
-
/(\t*)return (\(0, [\w.$]+\.rankByName\)\(skills, query\)|rankByName\(skills, query\))\.map\(\(skill\) => \(\{/,
|
|
68
|
-
/(\t*)return (skills\.filter\(\(skill\) => skill\.name\.startsWith\(query\)\))\.map\(\(skill\) => \(\{/,
|
|
69
|
-
]
|
|
70
|
-
for (const re of ANCHORS) {
|
|
71
|
-
const m = text.match(re)
|
|
72
|
-
if (!m) continue
|
|
73
|
-
const [, indent, official] = m
|
|
74
|
-
return text.replace(
|
|
75
|
-
re,
|
|
76
|
-
`${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\n` +
|
|
77
|
-
`${indent}const officialMatcher = ${official};\n` +
|
|
78
|
-
`${indent}const matcher = typeof window.${FUZZY_MARKER} === "function" ? window.${FUZZY_MARKER}(skills, query) : officialMatcher;\n` +
|
|
79
|
-
`${indent}return matcher.map((skill) => ({`,
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
return text
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
id: 'pick-tracking',
|
|
87
|
-
title: 'record usage when picked from the official / menu',
|
|
88
|
-
isApplied(text) {
|
|
89
|
-
return text.includes(TRACK_MARKER)
|
|
90
|
-
},
|
|
91
|
-
apply(text) {
|
|
92
|
-
return text.replace(
|
|
93
|
-
/(\t*)onPick\(\{ candidate \}\) \{\n(\t*)return \{ text: `\/\$\{candidate\.name\} ` \};\n(\t*)\}/,
|
|
94
|
-
(match, i1, i2, i3) =>
|
|
95
|
-
`${i1}onPick({ candidate }) {\n` +
|
|
96
|
-
`${i2} // dsh-skill-picker patch: usage tracking (self-healed)\n` +
|
|
97
|
-
`${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\n` +
|
|
98
|
-
`${i2} return { text: \`/\${candidate.name} \` };\n` +
|
|
99
|
-
`${i3}}`,
|
|
100
|
-
)
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
]
|
|
104
|
-
|
|
105
|
-
/** Resolve the DSH home directory, mirroring the official convention. */
|
|
106
|
-
export function dshHome() {
|
|
107
|
-
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Enumerate every installed ui-skill `lib/client.js` across all profiles.
|
|
112
|
-
*
|
|
113
|
-
* Three layouts are covered (deduplicated by real path):
|
|
114
|
-
* 1. the **shared core root** `profiles/node_modules/@deepseek-ai/…` — used by
|
|
115
|
-
* a global `npm i -g @deepseek-ai/dsh`, where the official package is NOT
|
|
116
|
-
* below any single profile (issue #7 — missing this made the whole patch
|
|
117
|
-
* silently no-op for those installs);
|
|
118
|
-
* 2. the user's local patched copy (`profiles/<p>/local/dsh-client-ui-skill`)
|
|
119
|
-
* — that is what a linked profile actually loads;
|
|
120
|
-
* 3. the plain npm install inside a profile
|
|
121
|
-
* (`profiles/<p>/node_modules/@deepseek-ai/…`).
|
|
122
|
-
*
|
|
123
|
-
* As a defensive extra, each profile is also asked through Node's own resolver
|
|
124
|
-
* (`createRequire().resolve()`), so a layout we did not enumerate still works.
|
|
125
|
-
* Never throws — a missing profiles dir yields [].
|
|
126
|
-
* @returns {Promise<string[]>} candidate file paths.
|
|
127
|
-
*/
|
|
128
|
-
export async function uiSkillClientPaths() {
|
|
129
|
-
const profilesDir = path.join(dshHome(), 'profiles')
|
|
130
|
-
let profiles
|
|
131
|
-
try {
|
|
132
|
-
profiles = await readdir(profilesDir, { withFileTypes: true })
|
|
133
|
-
} catch {
|
|
134
|
-
return []
|
|
135
|
-
}
|
|
136
|
-
const seen = new Set()
|
|
137
|
-
const found = []
|
|
138
|
-
|
|
139
|
-
/** Add one candidate if it exists; deduplicate by real path. */
|
|
140
|
-
const collect = async (candidate) => {
|
|
141
|
-
try {
|
|
142
|
-
await access(candidate)
|
|
143
|
-
const real = await realpath(candidate)
|
|
144
|
-
if (seen.has(real)) return
|
|
145
|
-
seen.add(real)
|
|
146
|
-
found.push(candidate)
|
|
147
|
-
} catch {
|
|
148
|
-
/* not present at this location */
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Ask Node's resolver where this profile would load the package from. */
|
|
153
|
-
const collectByResolve = async (profileDir) => {
|
|
154
|
-
try {
|
|
155
|
-
const require = createRequire(path.join(profileDir, 'package.json'))
|
|
156
|
-
await collect(require.resolve('@deepseek-ai/dsh-client-ui-skill/lib/client.js'))
|
|
157
|
-
} catch {
|
|
158
|
-
/* not resolvable from this profile */
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// 1. shared core root (global installs) — see the doc comment above.
|
|
163
|
-
await collect(path.join(profilesDir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
164
|
-
|
|
165
|
-
for (const entry of profiles) {
|
|
166
|
-
// `node_modules` is a sibling of the profiles, not a profile itself.
|
|
167
|
-
if (entry.name === 'node_modules') continue
|
|
168
|
-
// A profile directory may itself be reached through a link; follow it
|
|
169
|
-
// instead of relying on the lstat-based dirent (see dir-entry.js).
|
|
170
|
-
if (!(await isDirectoryEntry(profilesDir, entry))) continue
|
|
171
|
-
// 2. the user's local patched copy (what a linked profile actually loads).
|
|
172
|
-
await collect(path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
173
|
-
// 3. the plain npm install inside the profile.
|
|
174
|
-
await collect(path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
175
|
-
// Defensive: whatever Node itself would resolve (dupes removed above).
|
|
176
|
-
await collectByResolve(path.join(profilesDir, entry.name))
|
|
177
|
-
}
|
|
178
|
-
return found
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Apply both patches to one ui-skill client.js. Idempotent: already-applied
|
|
183
|
-
* patches are reported as skipped; the original file is backed up once before
|
|
184
|
-
* the first modification. Never throws for a patch that does not match (it is
|
|
185
|
-
* reported as `noop`), only for actual I/O failures.
|
|
186
|
-
* @param {string} file - absolute path to the target client.js.
|
|
187
|
-
* @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}
|
|
188
|
-
*/
|
|
189
|
-
export async function patchUiSkillFile(file) {
|
|
190
|
-
const text = await readFile(file, 'utf8')
|
|
191
|
-
const result = { file, patched: [], skipped: [], noop: [] }
|
|
192
|
-
let next = text
|
|
193
|
-
for (const patch of PATCHES) {
|
|
194
|
-
if (patch.isApplied(next)) {
|
|
195
|
-
result.skipped.push(patch.id)
|
|
196
|
-
continue
|
|
197
|
-
}
|
|
198
|
-
const candidate = patch.apply(next)
|
|
199
|
-
if (candidate === next) {
|
|
200
|
-
result.noop.push(patch.id)
|
|
201
|
-
continue
|
|
202
|
-
}
|
|
203
|
-
next = candidate
|
|
204
|
-
result.patched.push(patch.id)
|
|
205
|
-
}
|
|
206
|
-
if (result.patched.length === 0) return result
|
|
207
|
-
const backup = `${file}.dsh-skill-picker.bak`
|
|
208
|
-
try {
|
|
209
|
-
await access(backup)
|
|
210
|
-
} catch {
|
|
211
|
-
await copyFile(file, backup)
|
|
212
|
-
}
|
|
213
|
-
// Write through a temp file + rename instead of an in-place `writeFile`:
|
|
214
|
-
// - pnpm installs are HARDLINKED to a shared content-addressable store, so
|
|
215
|
-
// writing in place would mutate that shared inode and silently change
|
|
216
|
-
// every other project using the same package version. rename() swaps the
|
|
217
|
-
// directory entry instead, leaving the shared inode untouched.
|
|
218
|
-
// - rename is also atomic, so an interrupted boot cannot leave a torn file.
|
|
219
|
-
const tmp = `${file}.dsh-skill-picker.tmp`
|
|
220
|
-
await writeFile(tmp, next, 'utf8')
|
|
221
|
-
await rename(tmp, file)
|
|
222
|
-
return result
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Self-heal entry point: scan all profiles, patch every ui-skill copy found,
|
|
227
|
-
* and return one combined report. Never throws — each failure is collected
|
|
228
|
-
* into `errors` so the host boot is never taken down by a broken patch.
|
|
229
|
-
* @returns {Promise<{files: Array, errors: string[]}>}
|
|
230
|
-
*/
|
|
231
|
-
export async function healUiSkillPatches() {
|
|
232
|
-
const files = await uiSkillClientPaths()
|
|
233
|
-
const filesReport = []
|
|
234
|
-
const errors = []
|
|
235
|
-
for (const file of files) {
|
|
236
|
-
try {
|
|
237
|
-
filesReport.push(await patchUiSkillFile(file))
|
|
238
|
-
} catch (error) {
|
|
239
|
-
errors.push(`${file}: ${String(error?.message ?? error)}`)
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
// Loud on the empty case. Silence here was the most confusing part of
|
|
243
|
-
// issue #7: "found 0 targets" and "everything already applied" used to print
|
|
244
|
-
// identically, so nobody could tell the patch had never run at all.
|
|
245
|
-
if (files.length === 0) {
|
|
246
|
-
console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '
|
|
247
|
-
+ `${path.join(dshHome(), 'profiles')} — fuzzy+pinyin matching will NOT be applied. `
|
|
248
|
-
+ 'See https://github.com/a735624258/dsh-skill-picker/issues/7')
|
|
249
|
-
}
|
|
250
|
-
return { files: filesReport, errors }
|
|
251
|
-
}
|
|
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` — the
|
|
18
|
+
* shared core root (`profiles/node_modules/@deepseek-ai/…`, used by global
|
|
19
|
+
* installs), the user's local patched copy (`local/dsh-client-ui-skill`), or
|
|
20
|
+
* the plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) — and
|
|
21
|
+
* re-applies both patches when DSH upgrades overwrote them. The original file
|
|
22
|
+
* is backed up once as `<file>.dsh-skill-picker.bak` before the first write.
|
|
23
|
+
* All operations are idempotent and never throw: a missing profile, package
|
|
24
|
+
* or read failure is reported and skipped so a broken patch can never take
|
|
25
|
+
* the host down.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-skill-picker/patch-ui-skill
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFile, writeFile, copyFile, readdir, access, realpath, rename } from 'node:fs/promises'
|
|
31
|
+
import { createRequire } from 'node:module'
|
|
32
|
+
import os from 'node:os'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
|
|
35
|
+
import { isDirectoryEntry } from './dir-entry.js'
|
|
36
|
+
|
|
37
|
+
/** Marker that the candidates patch is already in place. */
|
|
38
|
+
const FUZZY_MARKER = '__dshSkillPickerFuzzy'
|
|
39
|
+
|
|
40
|
+
/** Marker that the pick-tracking patch is already in place. */
|
|
41
|
+
const TRACK_MARKER = '__dshSkillPickerTrack'
|
|
42
|
+
|
|
43
|
+
/** The self-healing patches, in application order. */
|
|
44
|
+
export const PATCHES = [
|
|
45
|
+
{
|
|
46
|
+
id: 'order',
|
|
47
|
+
title: 'skill group order 2 → -1 (above commands)',
|
|
48
|
+
isApplied(text) {
|
|
49
|
+
return /name: "skill",[\s\S]*?order:\s*-1,/.test(text)
|
|
50
|
+
},
|
|
51
|
+
apply(text) {
|
|
52
|
+
return text.replace(/(name: "skill",\s*order: )2,/, '$1-1,')
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'fuzzy-candidates',
|
|
57
|
+
title: 'prefix/rank matcher → fuzzy+pinyin matcher',
|
|
58
|
+
isApplied(text) {
|
|
59
|
+
return text.includes(FUZZY_MARKER)
|
|
60
|
+
},
|
|
61
|
+
apply(text) {
|
|
62
|
+
// 官方实现随版本变过两次,两个形态都要认(2026-09-11 实测):
|
|
63
|
+
// 0.1.2-alpha.x : return skills.filter((skill) => skill.name.startsWith(query)).map(...)
|
|
64
|
+
// 0.1.5-rc.x : return (0, _xxx.rankByName)(skills, query).map(...) ← 改成 async candidates
|
|
65
|
+
// 每组捕获:$1 = 缩进,$2 = 官方那一段表达式(原样保留做 fallback)。
|
|
66
|
+
const ANCHORS = [
|
|
67
|
+
/(\t*)return (\(0, [\w.$]+\.rankByName\)\(skills, query\)|rankByName\(skills, query\))\.map\(\(skill\) => \(\{/,
|
|
68
|
+
/(\t*)return (skills\.filter\(\(skill\) => skill\.name\.startsWith\(query\)\))\.map\(\(skill\) => \(\{/,
|
|
69
|
+
]
|
|
70
|
+
for (const re of ANCHORS) {
|
|
71
|
+
const m = text.match(re)
|
|
72
|
+
if (!m) continue
|
|
73
|
+
const [, indent, official] = m
|
|
74
|
+
return text.replace(
|
|
75
|
+
re,
|
|
76
|
+
`${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\n` +
|
|
77
|
+
`${indent}const officialMatcher = ${official};\n` +
|
|
78
|
+
`${indent}const matcher = typeof window.${FUZZY_MARKER} === "function" ? window.${FUZZY_MARKER}(skills, query) : officialMatcher;\n` +
|
|
79
|
+
`${indent}return matcher.map((skill) => ({`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return text
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'pick-tracking',
|
|
87
|
+
title: 'record usage when picked from the official / menu',
|
|
88
|
+
isApplied(text) {
|
|
89
|
+
return text.includes(TRACK_MARKER)
|
|
90
|
+
},
|
|
91
|
+
apply(text) {
|
|
92
|
+
return text.replace(
|
|
93
|
+
/(\t*)onPick\(\{ candidate \}\) \{\n(\t*)return \{ text: `\/\$\{candidate\.name\} ` \};\n(\t*)\}/,
|
|
94
|
+
(match, i1, i2, i3) =>
|
|
95
|
+
`${i1}onPick({ candidate }) {\n` +
|
|
96
|
+
`${i2} // dsh-skill-picker patch: usage tracking (self-healed)\n` +
|
|
97
|
+
`${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\n` +
|
|
98
|
+
`${i2} return { text: \`/\${candidate.name} \` };\n` +
|
|
99
|
+
`${i3}}`,
|
|
100
|
+
)
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/** Resolve the DSH home directory, mirroring the official convention. */
|
|
106
|
+
export function dshHome() {
|
|
107
|
+
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Enumerate every installed ui-skill `lib/client.js` across all profiles.
|
|
112
|
+
*
|
|
113
|
+
* Three layouts are covered (deduplicated by real path):
|
|
114
|
+
* 1. the **shared core root** `profiles/node_modules/@deepseek-ai/…` — used by
|
|
115
|
+
* a global `npm i -g @deepseek-ai/dsh`, where the official package is NOT
|
|
116
|
+
* below any single profile (issue #7 — missing this made the whole patch
|
|
117
|
+
* silently no-op for those installs);
|
|
118
|
+
* 2. the user's local patched copy (`profiles/<p>/local/dsh-client-ui-skill`)
|
|
119
|
+
* — that is what a linked profile actually loads;
|
|
120
|
+
* 3. the plain npm install inside a profile
|
|
121
|
+
* (`profiles/<p>/node_modules/@deepseek-ai/…`).
|
|
122
|
+
*
|
|
123
|
+
* As a defensive extra, each profile is also asked through Node's own resolver
|
|
124
|
+
* (`createRequire().resolve()`), so a layout we did not enumerate still works.
|
|
125
|
+
* Never throws — a missing profiles dir yields [].
|
|
126
|
+
* @returns {Promise<string[]>} candidate file paths.
|
|
127
|
+
*/
|
|
128
|
+
export async function uiSkillClientPaths() {
|
|
129
|
+
const profilesDir = path.join(dshHome(), 'profiles')
|
|
130
|
+
let profiles
|
|
131
|
+
try {
|
|
132
|
+
profiles = await readdir(profilesDir, { withFileTypes: true })
|
|
133
|
+
} catch {
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
136
|
+
const seen = new Set()
|
|
137
|
+
const found = []
|
|
138
|
+
|
|
139
|
+
/** Add one candidate if it exists; deduplicate by real path. */
|
|
140
|
+
const collect = async (candidate) => {
|
|
141
|
+
try {
|
|
142
|
+
await access(candidate)
|
|
143
|
+
const real = await realpath(candidate)
|
|
144
|
+
if (seen.has(real)) return
|
|
145
|
+
seen.add(real)
|
|
146
|
+
found.push(candidate)
|
|
147
|
+
} catch {
|
|
148
|
+
/* not present at this location */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ask Node's resolver where this profile would load the package from. */
|
|
153
|
+
const collectByResolve = async (profileDir) => {
|
|
154
|
+
try {
|
|
155
|
+
const require = createRequire(path.join(profileDir, 'package.json'))
|
|
156
|
+
await collect(require.resolve('@deepseek-ai/dsh-client-ui-skill/lib/client.js'))
|
|
157
|
+
} catch {
|
|
158
|
+
/* not resolvable from this profile */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 1. shared core root (global installs) — see the doc comment above.
|
|
163
|
+
await collect(path.join(profilesDir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
164
|
+
|
|
165
|
+
for (const entry of profiles) {
|
|
166
|
+
// `node_modules` is a sibling of the profiles, not a profile itself.
|
|
167
|
+
if (entry.name === 'node_modules') continue
|
|
168
|
+
// A profile directory may itself be reached through a link; follow it
|
|
169
|
+
// instead of relying on the lstat-based dirent (see dir-entry.js).
|
|
170
|
+
if (!(await isDirectoryEntry(profilesDir, entry))) continue
|
|
171
|
+
// 2. the user's local patched copy (what a linked profile actually loads).
|
|
172
|
+
await collect(path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
173
|
+
// 3. the plain npm install inside the profile.
|
|
174
|
+
await collect(path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
175
|
+
// Defensive: whatever Node itself would resolve (dupes removed above).
|
|
176
|
+
await collectByResolve(path.join(profilesDir, entry.name))
|
|
177
|
+
}
|
|
178
|
+
return found
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Apply both patches to one ui-skill client.js. Idempotent: already-applied
|
|
183
|
+
* patches are reported as skipped; the original file is backed up once before
|
|
184
|
+
* the first modification. Never throws for a patch that does not match (it is
|
|
185
|
+
* reported as `noop`), only for actual I/O failures.
|
|
186
|
+
* @param {string} file - absolute path to the target client.js.
|
|
187
|
+
* @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}
|
|
188
|
+
*/
|
|
189
|
+
export async function patchUiSkillFile(file) {
|
|
190
|
+
const text = await readFile(file, 'utf8')
|
|
191
|
+
const result = { file, patched: [], skipped: [], noop: [] }
|
|
192
|
+
let next = text
|
|
193
|
+
for (const patch of PATCHES) {
|
|
194
|
+
if (patch.isApplied(next)) {
|
|
195
|
+
result.skipped.push(patch.id)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
const candidate = patch.apply(next)
|
|
199
|
+
if (candidate === next) {
|
|
200
|
+
result.noop.push(patch.id)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
next = candidate
|
|
204
|
+
result.patched.push(patch.id)
|
|
205
|
+
}
|
|
206
|
+
if (result.patched.length === 0) return result
|
|
207
|
+
const backup = `${file}.dsh-skill-picker.bak`
|
|
208
|
+
try {
|
|
209
|
+
await access(backup)
|
|
210
|
+
} catch {
|
|
211
|
+
await copyFile(file, backup)
|
|
212
|
+
}
|
|
213
|
+
// Write through a temp file + rename instead of an in-place `writeFile`:
|
|
214
|
+
// - pnpm installs are HARDLINKED to a shared content-addressable store, so
|
|
215
|
+
// writing in place would mutate that shared inode and silently change
|
|
216
|
+
// every other project using the same package version. rename() swaps the
|
|
217
|
+
// directory entry instead, leaving the shared inode untouched.
|
|
218
|
+
// - rename is also atomic, so an interrupted boot cannot leave a torn file.
|
|
219
|
+
const tmp = `${file}.dsh-skill-picker.tmp`
|
|
220
|
+
await writeFile(tmp, next, 'utf8')
|
|
221
|
+
await rename(tmp, file)
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Self-heal entry point: scan all profiles, patch every ui-skill copy found,
|
|
227
|
+
* and return one combined report. Never throws — each failure is collected
|
|
228
|
+
* into `errors` so the host boot is never taken down by a broken patch.
|
|
229
|
+
* @returns {Promise<{files: Array, errors: string[]}>}
|
|
230
|
+
*/
|
|
231
|
+
export async function healUiSkillPatches() {
|
|
232
|
+
const files = await uiSkillClientPaths()
|
|
233
|
+
const filesReport = []
|
|
234
|
+
const errors = []
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
try {
|
|
237
|
+
filesReport.push(await patchUiSkillFile(file))
|
|
238
|
+
} catch (error) {
|
|
239
|
+
errors.push(`${file}: ${String(error?.message ?? error)}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Loud on the empty case. Silence here was the most confusing part of
|
|
243
|
+
// issue #7: "found 0 targets" and "everything already applied" used to print
|
|
244
|
+
// identically, so nobody could tell the patch had never run at all.
|
|
245
|
+
if (files.length === 0) {
|
|
246
|
+
console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '
|
|
247
|
+
+ `${path.join(dshHome(), 'profiles')} — fuzzy+pinyin matching will NOT be applied. `
|
|
248
|
+
+ 'See https://github.com/a735624258/dsh-skill-picker/issues/7')
|
|
249
|
+
}
|
|
250
|
+
return { files: filesReport, errors }
|
|
251
|
+
}
|