dsh-skill-picker 0.5.11 → 0.5.13
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 +9 -2
- package/lib/client.js +43 -14
- package/lib/client.js.map +3 -3
- package/lib/index.js +97 -5
- package/lib/index.js.map +2 -2
- package/package.json +12 -8
- package/src/client/index.jsx +758 -717
- package/src/client/session-view.js +68 -0
- package/src/index.js +91 -3
- package/src/patch-ui-skill.js +110 -5
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session identity and workspace cwd for the picker's two skill sources.
|
|
3
|
+
*
|
|
4
|
+
* Both values come from the slot's framework standard props: the picker seats
|
|
5
|
+
* itself in `conversation.input.right`, which is a Session-scoped slot, so the
|
|
6
|
+
* kernel hands the component a `sessionId` plus the global `useSessions`
|
|
7
|
+
* selector hook. This module keeps that contract in one testable place.
|
|
8
|
+
*
|
|
9
|
+
* Why not read them from `ctx.sessions.list` (as this plugin did before
|
|
10
|
+
* 0.5.13)? Because the Session list snapshot is not a "which Session is
|
|
11
|
+
* current" source any more: since the 0.1.7 client its state is
|
|
12
|
+
* `{ ids, byId, phase, projectionsBySession }`, with no `current` cursor. The
|
|
13
|
+
* old lookup therefore resolved to `undefined` on every 0.1.7 install, the
|
|
14
|
+
* picker fell back to the host scan, and that scan — sent without `?cwd=` —
|
|
15
|
+
* could only see user-level roots. Project-level skills
|
|
16
|
+
* (`<workspace>/.agents/skills`) silently disappeared from the panel while
|
|
17
|
+
* user-level ones kept showing.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-skill-picker/client/session-view
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The active Session's identity, or `undefined` outside a Session scope.
|
|
24
|
+
*
|
|
25
|
+
* `sessionId` is the current standard prop. `props.session` is the carrier the
|
|
26
|
+
* older client lines handed to slot components; it stays as a fallback so the
|
|
27
|
+
* panel keeps working on 0.1.2/0.1.5 kernels too.
|
|
28
|
+
*
|
|
29
|
+
* @param props - the slot component's framework props.
|
|
30
|
+
*/
|
|
31
|
+
export function sessionIdOf(props) {
|
|
32
|
+
return props?.sessionId ?? props?.session?.sessionId
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Selector over the Session list state: the active Session's workspace cwd.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately reads `byId[sessionId]` — the only shape every supported kernel
|
|
39
|
+
* publishes — and normalizes "no row yet" / "row without cwd" / a non-string
|
|
40
|
+
* cwd to `''`, which is what the host route treats as "no project root".
|
|
41
|
+
*
|
|
42
|
+
* @param sessionId - the active Session identity, if any.
|
|
43
|
+
* @returns a `useSessions` selector producing the cwd string (possibly empty).
|
|
44
|
+
*/
|
|
45
|
+
export function selectWorkspaceCwd(sessionId) {
|
|
46
|
+
return (state) => {
|
|
47
|
+
const cwd = sessionId === undefined ? undefined : state?.byId?.[sessionId]?.cwd
|
|
48
|
+
return typeof cwd === 'string' ? cwd : ''
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the active Session's workspace cwd through the slot's global
|
|
54
|
+
* `useSessions` hook (framework seat owned by `ui-session`).
|
|
55
|
+
*
|
|
56
|
+
* The availability check is stable across renders — `props.useSessions` is a
|
|
57
|
+
* framework seat, never flipped per render — so the hook is either always
|
|
58
|
+
* called or never called for one mounted seat. A composition without the hook
|
|
59
|
+
* degrades to `''` (user-level scan) instead of failing the panel.
|
|
60
|
+
*
|
|
61
|
+
* @param props - the slot component's framework props.
|
|
62
|
+
* @returns the workspace cwd, or `''` when it cannot be resolved.
|
|
63
|
+
*/
|
|
64
|
+
export function useWorkspaceCwd(props) {
|
|
65
|
+
const useSessions = props?.useSessions
|
|
66
|
+
const cwd = typeof useSessions === 'function' ? useSessions(selectWorkspaceCwd(sessionIdOf(props))) : undefined
|
|
67
|
+
return typeof cwd === 'string' ? cwd : ''
|
|
68
|
+
}
|
package/src/index.js
CHANGED
|
@@ -54,12 +54,18 @@ function userAgentsSkillsDir() {
|
|
|
54
54
|
return path.join(agentsHome, 'skills')
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The YAML frontmatter block of a SKILL.md body, or undefined when absent. */
|
|
58
|
+
function frontmatterBlock(content) {
|
|
59
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
60
|
+
return match === null ? undefined : match[1]
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
/** Parse a SKILL.md frontmatter block into a key/value map (flat YAML subset). */
|
|
58
64
|
function parseFrontmatter(content) {
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
65
|
+
const block = frontmatterBlock(content)
|
|
66
|
+
if (block === undefined) return {}
|
|
61
67
|
const out = {}
|
|
62
|
-
for (const line of
|
|
68
|
+
for (const line of block.split(/\r?\n/)) {
|
|
63
69
|
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
|
64
70
|
if (!kv) continue
|
|
65
71
|
const value = kv[2].trim().replace(/^["']|["']$/g, '')
|
|
@@ -68,6 +74,81 @@ function parseFrontmatter(content) {
|
|
|
68
74
|
return out
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Whether the frontmatter carries `key` at all, whatever its value.
|
|
79
|
+
* `parseFrontmatter` drops empty values, so presence checks that must also
|
|
80
|
+
* see a bare `key:` line go through here instead.
|
|
81
|
+
*/
|
|
82
|
+
function hasFrontmatterKey(content, key) {
|
|
83
|
+
const block = frontmatterBlock(content)
|
|
84
|
+
if (block === undefined) return false
|
|
85
|
+
return block.split(/\r?\n/).some((line) => {
|
|
86
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
|
87
|
+
return kv !== null && kv[1] === key
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Legacy invocation spellings the official `dsh-skill-filesystem` provider
|
|
93
|
+
* rejects outright (`rejectLegacyInvocationKey`) — a skill carrying one is
|
|
94
|
+
* dropped from every surface, so the picker must drop it too.
|
|
95
|
+
*/
|
|
96
|
+
const LEGACY_INVOCATION_KEYS = ['disableModelInvocation', 'modelInvocable', 'userInvocable']
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read a frontmatter boolean with the official provider's semantics: YAML
|
|
100
|
+
* booleans plus the case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`
|
|
101
|
+
* and `1`/`0` forms. An absent key yields undefined ("surface permitted").
|
|
102
|
+
*/
|
|
103
|
+
function frontmatterBoolean(meta, key) {
|
|
104
|
+
if (!Object.hasOwn(meta, key)) return undefined
|
|
105
|
+
const value = meta[key]
|
|
106
|
+
if (typeof value === 'boolean') return value
|
|
107
|
+
if (value === 1 || value === '1') return true
|
|
108
|
+
if (value === 0 || value === '0') return false
|
|
109
|
+
if (typeof value === 'string') {
|
|
110
|
+
switch (value.toLowerCase()) {
|
|
111
|
+
case 'true':
|
|
112
|
+
case 'yes':
|
|
113
|
+
case 'on':
|
|
114
|
+
return true
|
|
115
|
+
case 'false':
|
|
116
|
+
case 'no':
|
|
117
|
+
case 'off':
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw new TypeError(`frontmatter field "${key}" must be a boolean`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a scanned skill may be offered by the picker, i.e. whether it is
|
|
126
|
+
* user-invocable. Mirrors `parseInvocationPolicy` in
|
|
127
|
+
* `@deepseek-ai/dsh-skill-filesystem`: `user-invocable: false` keeps the skill
|
|
128
|
+
* out of human-facing commands, a rejected spelling or a legacy invocation key
|
|
129
|
+
* drops it entirely.
|
|
130
|
+
*
|
|
131
|
+
* Why this exists (issue #10): the official `skills/list` Remote filters with
|
|
132
|
+
* `isUserInvocable` before answering, but this host's own fallback scan read
|
|
133
|
+
* name/description only — so the picker offered skills the official `/` menu
|
|
134
|
+
* hides, and picking one inserted a `/name` gesture that `dsh-tool-skill` then
|
|
135
|
+
* refused to load (a silent no-op).
|
|
136
|
+
*
|
|
137
|
+
* @param meta - parsed frontmatter map.
|
|
138
|
+
* @param content - the raw SKILL.md body (legacy keys are detected on it).
|
|
139
|
+
*/
|
|
140
|
+
function isUserInvocableSkill(meta, content) {
|
|
141
|
+
for (const legacy of LEGACY_INVOCATION_KEYS) {
|
|
142
|
+
if (hasFrontmatterKey(content, legacy)) return false
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
// Omitted → permitted; only an explicit `false` hides the skill.
|
|
146
|
+
return frontmatterBoolean(meta, 'user-invocable') !== false
|
|
147
|
+
} catch {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
71
152
|
/** Scan one skill directory into the map; never throws (missing dir is a no-op). */
|
|
72
153
|
async function scanSkillsDirInto(map, dir) {
|
|
73
154
|
let entries
|
|
@@ -88,6 +169,13 @@ async function scanSkillsDirInto(map, dir) {
|
|
|
88
169
|
continue
|
|
89
170
|
}
|
|
90
171
|
const meta = parseFrontmatter(content)
|
|
172
|
+
// The picker is a human-facing surface, so honour the invocation policy:
|
|
173
|
+
// `user-invocable: false` (and the legacy spellings the official provider
|
|
174
|
+
// rejects) must never be offered. The official `skills/list` Remote filters
|
|
175
|
+
// the same way — without this the fallback route listed skills that the
|
|
176
|
+
// official `/` menu hides and that the `/name` gesture then refuses to load
|
|
177
|
+
// (issue #10).
|
|
178
|
+
if (!isUserInvocableSkill(meta, content)) continue
|
|
91
179
|
// Later writes win, so project-level skills override same-named user skills.
|
|
92
180
|
map.set(meta.name ?? entry.name, {
|
|
93
181
|
name: meta.name ?? entry.name,
|
package/src/patch-ui-skill.js
CHANGED
|
@@ -107,10 +107,98 @@ export function dshHome() {
|
|
|
107
107
|
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** Subpath of the official skill UI inside any installation tree. */
|
|
111
|
+
const UI_SKILL_SUBPATH = ['node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js']
|
|
112
|
+
|
|
113
|
+
/** Whether a path exists (any type). Never throws. */
|
|
114
|
+
async function pathExists(candidate) {
|
|
115
|
+
try {
|
|
116
|
+
await access(candidate)
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Where the *running desktop build* keeps its own copy of the official skill UI.
|
|
125
|
+
*
|
|
126
|
+
* A packaged DSH Desktop ships that UI in one of two shapes (issue #9):
|
|
127
|
+
*
|
|
128
|
+
* - `resources/app/node_modules/…` — an **unpacked** build. The app is the
|
|
129
|
+
* installation-scoped owner, so this file is what gets served, and it is an
|
|
130
|
+
* ordinary writable file.
|
|
131
|
+
* - `resources/app.asar/dsh/node_modules/…` — a **packed** build. Electron's
|
|
132
|
+
* `fs` shim can *read* through the archive but cannot write into it, so this
|
|
133
|
+
* path is evidence for diagnosis only and is never a patch target.
|
|
134
|
+
*
|
|
135
|
+
* `process.resourcesPath` only exists inside Electron; a plain `dsh` CLI or web
|
|
136
|
+
* run returns empty lists and nothing changes for it.
|
|
137
|
+
* @returns {{writable: string[], packed: string[]}} candidate paths.
|
|
138
|
+
*/
|
|
139
|
+
export function desktopUiSkillPaths() {
|
|
140
|
+
const resources = process.resourcesPath
|
|
141
|
+
if (typeof resources !== 'string' || resources === '') return { writable: [], packed: [] }
|
|
142
|
+
return {
|
|
143
|
+
writable: [path.join(resources, 'app', ...UI_SKILL_SUBPATH)],
|
|
144
|
+
packed: [path.join(resources, 'app.asar', 'dsh', ...UI_SKILL_SUBPATH)],
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a profile manifest redirects the official skill UI to its own copy.
|
|
150
|
+
*
|
|
151
|
+
* This is the switch that decides which copy a desktop build serves: without a
|
|
152
|
+
* declared dependency the profile resolves the package from the installation
|
|
153
|
+
* (`resources/app` or `app.asar`), and every profile-local copy is dead weight.
|
|
154
|
+
* @param profileDir - absolute profile directory.
|
|
155
|
+
* @returns true when the manifest declares `@deepseek-ai/dsh-client-ui-skill`.
|
|
156
|
+
*/
|
|
157
|
+
async function profileRedirectsUiSkill(profileDir) {
|
|
158
|
+
try {
|
|
159
|
+
const manifest = JSON.parse(await readFile(path.join(profileDir, 'package.json'), 'utf8'))
|
|
160
|
+
const declared = { ...manifest?.dependencies, ...manifest?.devDependencies }
|
|
161
|
+
return typeof declared['@deepseek-ai/dsh-client-ui-skill'] === 'string'
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Explain why the patch targets found (or not found) may not be what the host
|
|
169
|
+
* actually serves, for a packed desktop build (issue #9).
|
|
170
|
+
*
|
|
171
|
+
* Silence here was the worst part of #9: patching a stale profile copy looks
|
|
172
|
+
* exactly like success, so the `/` menu kept its official matcher with no
|
|
173
|
+
* signal at all.
|
|
174
|
+
* @returns a sentence to append to a warning, or '' when there is nothing to say.
|
|
175
|
+
*/
|
|
176
|
+
async function packedDesktopNote() {
|
|
177
|
+
const { packed } = desktopUiSkillPaths()
|
|
178
|
+
if (packed.length === 0 || !(await pathExists(packed[0]))) return ''
|
|
179
|
+
const profilesDir = path.join(dshHome(), 'profiles')
|
|
180
|
+
let redirected = false
|
|
181
|
+
try {
|
|
182
|
+
for (const entry of await readdir(profilesDir, { withFileTypes: true })) {
|
|
183
|
+
if (entry.name === 'node_modules') continue
|
|
184
|
+
if (await profileRedirectsUiSkill(path.join(profilesDir, entry.name))) {
|
|
185
|
+
redirected = true
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* unreadable profile list: report the pessimistic case */
|
|
191
|
+
}
|
|
192
|
+
if (redirected) return ''
|
|
193
|
+
return ' This desktop build keeps the official skill UI inside `app.asar`, which cannot be written in place,'
|
|
194
|
+
+ ' and no profile redirects the package to a local copy — so the served `/` menu keeps the official matcher.'
|
|
195
|
+
+ ' Install a profile-local override (`"@deepseek-ai/dsh-client-ui-skill": "link:…"`) or run DSH from the CLI.'
|
|
196
|
+
}
|
|
197
|
+
|
|
110
198
|
/**
|
|
111
199
|
* Enumerate every installed ui-skill `lib/client.js` across all profiles.
|
|
112
200
|
*
|
|
113
|
-
*
|
|
201
|
+
* Four layouts are covered (deduplicated by real path):
|
|
114
202
|
* 1. the **shared core root** `profiles/node_modules/@deepseek-ai/…` — used by
|
|
115
203
|
* a global `npm i -g @deepseek-ai/dsh`, where the official package is NOT
|
|
116
204
|
* below any single profile (issue #7 — missing this made the whole patch
|
|
@@ -118,7 +206,11 @@ export function dshHome() {
|
|
|
118
206
|
* 2. the user's local patched copy (`profiles/<p>/local/dsh-client-ui-skill`)
|
|
119
207
|
* — that is what a linked profile actually loads;
|
|
120
208
|
* 3. the plain npm install inside a profile
|
|
121
|
-
* (`profiles/<p>/node_modules/@deepseek-ai/…`)
|
|
209
|
+
* (`profiles/<p>/node_modules/@deepseek-ai/…`);
|
|
210
|
+
* 4. the **running desktop build's own unpacked tree**
|
|
211
|
+
* (`resources/app/node_modules/@deepseek-ai/…`) — issue #9: a desktop
|
|
212
|
+
* install owns the package at installation scope, so its copy is the one
|
|
213
|
+
* served whenever no profile redirects the name.
|
|
122
214
|
*
|
|
123
215
|
* As a defensive extra, each profile is also asked through Node's own resolver
|
|
124
216
|
* (`createRequire().resolve()`), so a layout we did not enumerate still works.
|
|
@@ -131,7 +223,9 @@ export async function uiSkillClientPaths() {
|
|
|
131
223
|
try {
|
|
132
224
|
profiles = await readdir(profilesDir, { withFileTypes: true })
|
|
133
225
|
} catch {
|
|
134
|
-
|
|
226
|
+
// A desktop build can still be patchable through its own resource tree
|
|
227
|
+
// even when $DSH_HOME/profiles is absent or unreadable.
|
|
228
|
+
profiles = []
|
|
135
229
|
}
|
|
136
230
|
const seen = new Set()
|
|
137
231
|
const found = []
|
|
@@ -162,6 +256,10 @@ export async function uiSkillClientPaths() {
|
|
|
162
256
|
// 1. shared core root (global installs) — see the doc comment above.
|
|
163
257
|
await collect(path.join(profilesDir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
164
258
|
|
|
259
|
+
// 4. the running desktop build's own unpacked tree (issue #9): this is the
|
|
260
|
+
// copy a packaged Electron app serves when no profile redirects the name.
|
|
261
|
+
for (const candidate of desktopUiSkillPaths().writable) await collect(candidate)
|
|
262
|
+
|
|
165
263
|
for (const entry of profiles) {
|
|
166
264
|
// `node_modules` is a sibling of the profiles, not a profile itself.
|
|
167
265
|
if (entry.name === 'node_modules') continue
|
|
@@ -242,10 +340,17 @@ export async function healUiSkillPatches() {
|
|
|
242
340
|
// Loud on the empty case. Silence here was the most confusing part of
|
|
243
341
|
// issue #7: "found 0 targets" and "everything already applied" used to print
|
|
244
342
|
// identically, so nobody could tell the patch had never run at all.
|
|
343
|
+
const note = await packedDesktopNote()
|
|
245
344
|
if (files.length === 0) {
|
|
246
345
|
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
|
-
+
|
|
346
|
+
+ `${path.join(dshHome(), 'profiles')} — fuzzy+pinyin matching will NOT be applied.`
|
|
347
|
+
+ `${note} See https://github.com/a735624258/dsh-skill-picker/issues/9`)
|
|
348
|
+
} else if (note !== '') {
|
|
349
|
+
// Targets existed, so the old code stayed silent — but none of them is the
|
|
350
|
+
// copy the host serves. That is issue #9's silent failure: a "successful"
|
|
351
|
+
// patch run that changes nothing the user can see.
|
|
352
|
+
console.warn(`[dsh-skill-picker] ui-skill patch: patched ${files.length} profile copy/copies,`
|
|
353
|
+
+ ` but none of them is the one this host serves.${note}`)
|
|
249
354
|
}
|
|
250
355
|
return { files: filesReport, errors }
|
|
251
356
|
}
|