dsh-skill-picker 0.5.21 → 0.5.23
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 +437 -8
- package/lib/client.js.map +4 -4
- package/lib/index.js +322 -14
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +411 -4
- package/src/client/shared-state.js +189 -0
- package/src/index.js +248 -5
- package/src/skill-ops.js +233 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared picker state — the pinned list and the usage history, kept in ONE place
|
|
3
|
+
* instead of per browser origin.
|
|
4
|
+
*
|
|
5
|
+
* `localStorage` is scoped to an origin, so the desktop app (`dsh-app://app`),
|
|
6
|
+
* the web UI (`http://127.0.0.1:3080`) and a phone each kept a private copy: pin
|
|
7
|
+
* a skill on the desktop and the web UI never saw it. The host stores the list
|
|
8
|
+
* once per DSH home (`$DSH_HOME/dsh-skill-picker-state.json`) and this module
|
|
9
|
+
* mirrors it into `localStorage`.
|
|
10
|
+
*
|
|
11
|
+
* Why mirror instead of replacing `localStorage`: the ranking runs during render
|
|
12
|
+
* and reads its inputs SYNCHRONOUSLY, so the data path has to stay synchronous.
|
|
13
|
+
* The mirror keeps that true — the host copy is the source of truth, and
|
|
14
|
+
* `localStorage` is the synchronous cache of it. Every local write is pushed
|
|
15
|
+
* back, so both ends converge.
|
|
16
|
+
*
|
|
17
|
+
* Every function here is best-effort: a missing route or an offline host must
|
|
18
|
+
* leave the picker working exactly as it did before, on local data alone.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Same keys the panel reads synchronously. */
|
|
22
|
+
export const PINNED_KEY = 'dsh-skill-picker:pinned'
|
|
23
|
+
export const USAGE_KEY = 'dsh-skill-picker:usage'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set once this browser has completed a sync against the shared file.
|
|
27
|
+
*
|
|
28
|
+
* The first sync of a browser that already had pins MUST union instead of
|
|
29
|
+
* adopting: whichever client happens to boot first would otherwise overwrite
|
|
30
|
+
* the file, and the next client would adopt that and silently lose its own
|
|
31
|
+
* pins. After the first sync this browser follows the file, so a pin removed
|
|
32
|
+
* on one client really disappears from the others.
|
|
33
|
+
*/
|
|
34
|
+
const SYNCED_KEY = 'dsh-skill-picker:shared-synced'
|
|
35
|
+
|
|
36
|
+
/** Host route (same origin as the GUI in both the desktop app and the web UI). */
|
|
37
|
+
const ENDPOINT = '/dsh-skill-picker/state'
|
|
38
|
+
|
|
39
|
+
/** Fired after a pull changes the mirror, so open surfaces re-read it. */
|
|
40
|
+
export const SHARED_STATE_EVENT = 'dsh-skill-picker:shared-state'
|
|
41
|
+
|
|
42
|
+
/** Read the mirrored pinned list; never throws. */
|
|
43
|
+
function readLocalPinned() {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(localStorage.getItem(PINNED_KEY) ?? '[]')
|
|
46
|
+
return Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string') : []
|
|
47
|
+
} catch {
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read the mirrored usage history; never throws. */
|
|
53
|
+
function readLocalUsage() {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(localStorage.getItem(USAGE_KEY) ?? '{}')
|
|
56
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
57
|
+
} catch {
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Write the mirror; never throws. */
|
|
63
|
+
function writeLocal(state) {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.setItem(PINNED_KEY, JSON.stringify(state?.pinned ?? []))
|
|
66
|
+
localStorage.setItem(USAGE_KEY, JSON.stringify(state?.usage ?? {}))
|
|
67
|
+
} catch {
|
|
68
|
+
/* storage unavailable — the host copy is still authoritative */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether a state object carries anything worth adopting. */
|
|
73
|
+
function isEmptyState(state) {
|
|
74
|
+
if (state === null || state === undefined || typeof state !== 'object') return true
|
|
75
|
+
const pinned = Array.isArray(state.pinned) ? state.pinned : []
|
|
76
|
+
const usage = state.usage !== null && typeof state.usage === 'object' ? state.usage : {}
|
|
77
|
+
return pinned.length === 0 && Object.keys(usage).length === 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether this browser has ever completed a sync. */
|
|
81
|
+
function hasSynced() {
|
|
82
|
+
try {
|
|
83
|
+
return localStorage.getItem(SYNCED_KEY) === '1'
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Remember that this browser is now following the shared file. */
|
|
90
|
+
function markSynced() {
|
|
91
|
+
try {
|
|
92
|
+
localStorage.setItem(SYNCED_KEY, '1')
|
|
93
|
+
} catch {
|
|
94
|
+
/* private mode: it simply unions again next time, which is the safe side */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Push the local mirror to the shared file.
|
|
99
|
+
*
|
|
100
|
+
* @param options.migrate - first-run mode: the host unions the incoming state
|
|
101
|
+
* with whatever is already stored (instead of overwriting it) and returns the
|
|
102
|
+
* result, which is then adopted locally. Use it only when the shared file is
|
|
103
|
+
* known to be absent.
|
|
104
|
+
* @returns whether the host accepted the write.
|
|
105
|
+
*/
|
|
106
|
+
export async function pushSharedState({ migrate = false } = {}) {
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(`${ENDPOINT}${migrate ? '?migrate=1' : ''}`, {
|
|
109
|
+
method: 'PUT',
|
|
110
|
+
headers: { 'content-type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({ pinned: readLocalPinned(), usage: readLocalUsage() }),
|
|
112
|
+
})
|
|
113
|
+
if (!response.ok) return false
|
|
114
|
+
const body = await response.json()
|
|
115
|
+
if (body?.ok !== true) return false
|
|
116
|
+
if (migrate) writeLocal(body.state)
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pull the shared file into the local mirror.
|
|
125
|
+
*
|
|
126
|
+
* When this browser has never synced before and already holds pins/usage, the
|
|
127
|
+
* pull goes through the UNIONING migration instead of a plain adopt — otherwise
|
|
128
|
+
* whichever client boots first overwrites the file and the next one silently
|
|
129
|
+
* loses its own pins. Once a browser has synced, it follows the file, so a pin
|
|
130
|
+
* removed on another client really disappears here too.
|
|
131
|
+
*
|
|
132
|
+
* @returns whether the mirror was refreshed from the host.
|
|
133
|
+
*/
|
|
134
|
+
export async function pullSharedState() {
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(ENDPOINT, { headers: { accept: 'application/json' } })
|
|
137
|
+
if (!response.ok) return false
|
|
138
|
+
const body = await response.json()
|
|
139
|
+
if (body?.ok !== true) return false
|
|
140
|
+
|
|
141
|
+
const localHasData = !isEmptyState({ pinned: readLocalPinned(), usage: readLocalUsage() })
|
|
142
|
+
|
|
143
|
+
// Nothing shared yet: this browser seeds it.
|
|
144
|
+
if (body.state === null || body.state === undefined) {
|
|
145
|
+
const pushed = await pushSharedState({ migrate: true })
|
|
146
|
+
if (pushed) markSynced()
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// First sync on a browser that has its own data: union, never overwrite.
|
|
151
|
+
if (!hasSynced() && localHasData) {
|
|
152
|
+
const pushed = await pushSharedState({ migrate: true })
|
|
153
|
+
if (pushed) {
|
|
154
|
+
markSynced()
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The shared file exists but is empty, and this browser is the only one
|
|
160
|
+
// still holding data: push it rather than blanking the mirror.
|
|
161
|
+
if (isEmptyState(body.state) && localHasData) {
|
|
162
|
+
const pushed = await pushSharedState({ migrate: true })
|
|
163
|
+
if (pushed) markSynced()
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
writeLocal(body.state)
|
|
168
|
+
markSynced()
|
|
169
|
+
return true
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pull, then tell open surfaces to re-read the mirror.
|
|
177
|
+
* @returns whether anything was pulled.
|
|
178
|
+
*/
|
|
179
|
+
export async function syncSharedState() {
|
|
180
|
+
const pulled = await pullSharedState()
|
|
181
|
+
if (pulled) {
|
|
182
|
+
try {
|
|
183
|
+
window.dispatchEvent(new Event(SHARED_STATE_EVENT))
|
|
184
|
+
} catch {
|
|
185
|
+
/* an environment without window cannot have listeners either */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return pulled
|
|
189
|
+
}
|
package/src/index.js
CHANGED
|
@@ -18,12 +18,23 @@
|
|
|
18
18
|
* @module dsh-skill-picker
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile, readdir } from 'node:fs/promises'
|
|
21
|
+
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
|
22
22
|
import os from 'node:os'
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
|
|
25
25
|
import { isDirectoryEntry } from './dir-entry.js'
|
|
26
|
-
import { healUiSkillPatches, revertUiSkillPatches } from './patch-ui-skill.js'
|
|
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
|
}
|
|
@@ -253,6 +277,216 @@ export function reportUiSkillPatches(report, io = console) {
|
|
|
253
277
|
}
|
|
254
278
|
}
|
|
255
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Shared picker state: the pinned list and the usage history, stored ONCE per
|
|
282
|
+
* DSH home instead of per browser origin.
|
|
283
|
+
*
|
|
284
|
+
* Why it exists: `localStorage` is scoped to an origin, so the desktop app
|
|
285
|
+
* (`dsh-app://app`), the web UI (`http://127.0.0.1:3080`) and a phone all kept
|
|
286
|
+
* their own copy — pin a skill on the desktop and the web UI never sees it.
|
|
287
|
+
* Both halves now read and write this one file.
|
|
288
|
+
*
|
|
289
|
+
* @returns the absolute path of the shared state file.
|
|
290
|
+
*/
|
|
291
|
+
function sharedStateFile() {
|
|
292
|
+
return path.join(dshHome(), 'dsh-skill-picker-state.json')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Coerce anything into `{ pinned: string[], usage: {name: {count, lastUsed}} }`. */
|
|
296
|
+
function normalizeSharedState(value) {
|
|
297
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
298
|
+
const pinned = Array.isArray(source.pinned)
|
|
299
|
+
? [...new Set(source.pinned.filter((name) => typeof name === 'string' && name !== ''))]
|
|
300
|
+
: []
|
|
301
|
+
const usage = {}
|
|
302
|
+
const rawUsage = source.usage !== null && typeof source.usage === 'object' ? source.usage : {}
|
|
303
|
+
for (const [name, entry] of Object.entries(rawUsage)) {
|
|
304
|
+
if (typeof name !== 'string' || name === '') continue
|
|
305
|
+
const record = entry !== null && typeof entry === 'object' ? entry : {}
|
|
306
|
+
const count = Number.isFinite(record.count) ? Math.max(0, Math.trunc(record.count)) : 0
|
|
307
|
+
const lastUsed = Number.isFinite(record.lastUsed) ? Math.max(0, Math.trunc(record.lastUsed)) : 0
|
|
308
|
+
if (count === 0 && lastUsed === 0) continue
|
|
309
|
+
usage[name] = { count, lastUsed }
|
|
310
|
+
}
|
|
311
|
+
return { pinned, usage }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Read the shared state, or null when the file does not exist yet. */
|
|
315
|
+
async function readSharedState() {
|
|
316
|
+
try {
|
|
317
|
+
return normalizeSharedState(JSON.parse(await readFile(sharedStateFile(), 'utf8')))
|
|
318
|
+
} catch {
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Write the shared state atomically (temp + rename), creating the home if needed. */
|
|
324
|
+
async function writeSharedState(state) {
|
|
325
|
+
const normal = normalizeSharedState(state)
|
|
326
|
+
const file = sharedStateFile()
|
|
327
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
328
|
+
const tmp = `${file}.tmp`
|
|
329
|
+
await writeFile(tmp, `${JSON.stringify(normal, null, 2)}\n`, 'utf8')
|
|
330
|
+
await rename(tmp, file)
|
|
331
|
+
return normal
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* First-run migration: union both ends instead of letting whichever browser
|
|
336
|
+
* happened to write first win, so nobody silently loses their pins.
|
|
337
|
+
*/
|
|
338
|
+
function mergeSharedState(a, b) {
|
|
339
|
+
const left = normalizeSharedState(a)
|
|
340
|
+
const right = normalizeSharedState(b)
|
|
341
|
+
const pinned = [...left.pinned]
|
|
342
|
+
for (const name of right.pinned) if (!pinned.includes(name)) pinned.push(name)
|
|
343
|
+
const usage = { ...left.usage }
|
|
344
|
+
for (const [name, entry] of Object.entries(right.usage)) {
|
|
345
|
+
const previous = usage[name]
|
|
346
|
+
usage[name] = previous === undefined
|
|
347
|
+
? entry
|
|
348
|
+
: { count: Math.max(previous.count, entry.count), lastUsed: Math.max(previous.lastUsed, entry.lastUsed) }
|
|
349
|
+
}
|
|
350
|
+
return normalizeSharedState({ pinned, usage })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Read a JSON request body with a hard cap, so a bad client cannot grow memory. */
|
|
354
|
+
async function readJsonBody(req) {
|
|
355
|
+
const chunks = []
|
|
356
|
+
let size = 0
|
|
357
|
+
for await (const chunk of req) {
|
|
358
|
+
size += chunk.length
|
|
359
|
+
if (size > 1_000_000) throw new Error('shared state body too large')
|
|
360
|
+
chunks.push(chunk)
|
|
361
|
+
}
|
|
362
|
+
if (chunks.length === 0) return {}
|
|
363
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* `GET /dsh-skill-picker/state` → `{ ok, state }` (`state: null` before the
|
|
368
|
+
* first write). `PUT` with `{ pinned, usage }` → the stored state; `?migrate=1`
|
|
369
|
+
* unions the incoming state with the stored one and returns the result.
|
|
370
|
+
*/
|
|
371
|
+
async function handleSharedState(req, res, url) {
|
|
372
|
+
const json = (code, payload) => {
|
|
373
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
374
|
+
res.end(JSON.stringify(payload))
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
378
|
+
const incoming = normalizeSharedState(await readJsonBody(req))
|
|
379
|
+
const migrate = url.searchParams.get('migrate') === '1'
|
|
380
|
+
const stored = migrate ? await readSharedState() : null
|
|
381
|
+
const next = stored === null ? await writeSharedState(incoming) : await writeSharedState(mergeSharedState(stored, incoming))
|
|
382
|
+
json(200, { ok: true, state: next })
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
json(200, { ok: true, state: await readSharedState() })
|
|
386
|
+
} catch (error) {
|
|
387
|
+
json(500, { ok: false, error: String(error?.message ?? error) })
|
|
388
|
+
}
|
|
389
|
+
}
|
|
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
|
+
|
|
256
490
|
/**
|
|
257
491
|
* Mount the skills route and the prompt section.
|
|
258
492
|
* @param ctx - context carrying webServer and systemPrompt.
|
|
@@ -261,8 +495,17 @@ export function apply(ctx) {
|
|
|
261
495
|
ctx.effect(() => {
|
|
262
496
|
const handler = async (req, res) => {
|
|
263
497
|
try {
|
|
498
|
+
const url = new URL(req.url ?? '/', 'http://dsh')
|
|
499
|
+
if (url.pathname === '/dsh-skill-picker/state') {
|
|
500
|
+
await handleSharedState(req, res, url)
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
if (url.pathname === '/dsh-skill-picker/skill') {
|
|
504
|
+
await handleSkillOp(req, res, url)
|
|
505
|
+
return
|
|
506
|
+
}
|
|
264
507
|
// cwd query carries the active session's workspace root from the client.
|
|
265
|
-
const cwd =
|
|
508
|
+
const cwd = url.searchParams.get('cwd') ?? undefined
|
|
266
509
|
const skills = await scanSkills(cwd)
|
|
267
510
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
268
511
|
res.end(JSON.stringify({ ok: true, complete: true, skills }))
|