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.
@@ -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 match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
60
- if (!match) return {}
65
+ const block = frontmatterBlock(content)
66
+ if (block === undefined) return {}
61
67
  const out = {}
62
- for (const line of match[1].split(/\r?\n/)) {
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,
@@ -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
- * Three layouts are covered (deduplicated by real path):
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
- return []
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
- + 'See https://github.com/a735624258/dsh-skill-picker/issues/7')
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
  }