dsh-plugin-capabilities 0.1.6 → 0.2.1
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 +18 -2
- package/lib/client.js +936 -220
- package/lib/client.js.map +4 -4
- package/lib/index.js +817 -41
- package/lib/index.js.map +4 -4
- package/market/mcp.json +164 -0
- package/market/skills.json +36 -0
- package/package.json +2 -1
- package/src/client/CapabilitiesSection.tsx +11 -7
- package/src/client/MarketTab.tsx +263 -0
- package/src/client/McpTab.tsx +198 -0
- package/src/client/SkillsTab.tsx +261 -26
- package/src/client/css.ts +42 -0
- package/src/client/index.ts +58 -5
- package/src/client/locales.ts +112 -2
- package/src/index.ts +59 -19
- package/src/market.test.ts +57 -0
- package/src/market.ts +166 -0
- package/src/opener.ts +34 -0
- package/src/repos.test.ts +213 -0
- package/src/repos.ts +207 -0
- package/src/routes.ts +340 -6
- package/src/skills.test.ts +45 -1
- package/src/skills.ts +22 -1
- package/src/smoke.test.ts +54 -1
- package/src/state.test.ts +64 -0
- package/src/state.ts +109 -0
- package/src/tar.test.ts +100 -0
- package/src/tar.ts +154 -0
- package/src/types.ts +11 -1
package/src/routes.ts
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
/** HTTP routes bridging the Settings UI to the capabilities manager. */
|
|
2
2
|
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
import { mkdirSync } from 'node:fs'
|
|
4
5
|
import { scanAllMcp } from './agents.ts'
|
|
5
6
|
import { readJsonBody, sameOrigin, sendJson } from './http.ts'
|
|
7
|
+
import { loadMarketIndex, type MarketMcpServer } from './market.ts'
|
|
8
|
+
import { openDirectory } from './opener.ts'
|
|
9
|
+
import { addGitRepo, addLocalRepo, rootExists } from './repos.ts'
|
|
6
10
|
import { dshLaunch, restartOwnedByShell, scheduleRestart, trustedRestartRequest } from './restart.ts'
|
|
7
|
-
import { deleteSkill, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
|
|
11
|
+
import { deleteSkill, setSkillPolicy, userSkillsDir, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
|
|
12
|
+
import { findRootByUrl, loadState, pluginStateDir, removeSkillRoot, type SkillRootEntry } from './state.ts'
|
|
8
13
|
import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
|
|
9
|
-
import type { CapabilitiesHost } from './types.ts'
|
|
14
|
+
import type { CapabilitiesHost, HostSkill } from './types.ts'
|
|
10
15
|
|
|
11
16
|
/** Only this source is writable from the Settings page (provider rank 400). */
|
|
12
17
|
const EDITABLE_SOURCE = 'user-dsh'
|
|
13
18
|
|
|
19
|
+
/** One catalog skill as the browser sees it (editable/dir flags added). */
|
|
20
|
+
type SkillRow = HostSkill & { editable: boolean; dir?: string; policyEditable: boolean }
|
|
21
|
+
|
|
22
|
+
function toSkillRow(skill: HostSkill): SkillRow {
|
|
23
|
+
const dir = skill.resourceBase?.kind === 'directory' ? skill.resourceBase.path : undefined
|
|
24
|
+
return { ...skill, editable: skill.source === EDITABLE_SOURCE, ...(dir !== undefined ? { dir } : {}), policyEditable: dir !== undefined }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One registered repository plus a liveness flag (roots can go stale). */
|
|
28
|
+
function toRootView(entry: SkillRootEntry): SkillRootEntry & { live: boolean } {
|
|
29
|
+
return { ...entry, live: entry.roots.every(root => rootExists(root)) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CapabilitiesRoutesConfig {
|
|
33
|
+
profileDirPath: string
|
|
34
|
+
/** Remount the host-plane skill provider after root-set changes. */
|
|
35
|
+
remountProvider: () => Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
/** Register the manager's routes; returns the disposer removing them all. */
|
|
15
|
-
export function mountCapabilitiesRoutes(host: CapabilitiesHost, config:
|
|
39
|
+
export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: CapabilitiesRoutesConfig): () => void {
|
|
16
40
|
const disposers = [
|
|
17
41
|
host.webServer.register({
|
|
18
42
|
kind: 'exact',
|
|
@@ -25,9 +49,7 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profil
|
|
|
25
49
|
}
|
|
26
50
|
try {
|
|
27
51
|
const skills = await host.skills.list()
|
|
28
|
-
sendJson(response, 200, {
|
|
29
|
-
skills: skills.map(skill => ({ ...skill, editable: skill.source === EDITABLE_SOURCE })),
|
|
30
|
-
})
|
|
52
|
+
sendJson(response, 200, { skills: skills.map(toSkillRow) })
|
|
31
53
|
} catch (error) {
|
|
32
54
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
33
55
|
}
|
|
@@ -47,6 +69,10 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profil
|
|
|
47
69
|
const name = url.searchParams.get('name') ?? ''
|
|
48
70
|
try {
|
|
49
71
|
const definition = await host.skills.get(name)
|
|
72
|
+
if (definition === undefined) {
|
|
73
|
+
sendJson(response, 404, { error: 'skill not found' })
|
|
74
|
+
return
|
|
75
|
+
}
|
|
50
76
|
sendJson(response, 200, { name: definition.name, content: definition.content })
|
|
51
77
|
} catch (error) {
|
|
52
78
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
@@ -114,6 +140,314 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profil
|
|
|
114
140
|
},
|
|
115
141
|
}),
|
|
116
142
|
|
|
143
|
+
host.webServer.register({
|
|
144
|
+
kind: 'exact',
|
|
145
|
+
path: '/dsh-plugin-capabilities/skill/policy',
|
|
146
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
147
|
+
if (request.method !== 'POST') {
|
|
148
|
+
response.writeHead(405, { allow: 'POST' })
|
|
149
|
+
response.end()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (!sameOrigin(request)) {
|
|
153
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const body = (await readJsonBody(request)) as { name?: unknown; enabled?: unknown }
|
|
158
|
+
if (typeof body.name !== 'string' || typeof body.enabled !== 'boolean') {
|
|
159
|
+
sendJson(response, 400, { error: 'name and enabled are required' })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const definition = await host.skills.get(body.name)
|
|
163
|
+
if (definition === undefined) {
|
|
164
|
+
sendJson(response, 404, { error: 'skill not found' })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (definition.path === undefined) {
|
|
168
|
+
sendJson(response, 422, { error: 'skill has no file on disk (runtime-registered)' })
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
setSkillPolicy(definition.path, body.enabled)
|
|
172
|
+
sendJson(response, 200, { ok: true })
|
|
173
|
+
} catch (error) {
|
|
174
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
|
|
179
|
+
host.webServer.register({
|
|
180
|
+
kind: 'exact',
|
|
181
|
+
path: '/dsh-plugin-capabilities/open',
|
|
182
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
183
|
+
if (request.method !== 'POST') {
|
|
184
|
+
response.writeHead(405, { allow: 'POST' })
|
|
185
|
+
response.end()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (!sameOrigin(request)) {
|
|
189
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const body = (await readJsonBody(request)) as { target?: unknown; name?: unknown; id?: unknown }
|
|
194
|
+
if (typeof body.target !== 'string') {
|
|
195
|
+
sendJson(response, 400, { error: 'target is required' })
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
// Targets are resolved server-side; the browser never supplies a
|
|
199
|
+
// raw path, so this cannot be turned into an arbitrary open.
|
|
200
|
+
let dir: string | undefined
|
|
201
|
+
if (body.target === 'user-skills') {
|
|
202
|
+
dir = userSkillsDir()
|
|
203
|
+
// 用户还没建过任何技能时该目录不存在;「打开技能目录」应按需创建而非报错。
|
|
204
|
+
mkdirSync(dir, { recursive: true })
|
|
205
|
+
} else if (body.target === 'plugin-state') {
|
|
206
|
+
dir = pluginStateDir()
|
|
207
|
+
mkdirSync(dir, { recursive: true })
|
|
208
|
+
} else if (body.target === 'skill') {
|
|
209
|
+
if (typeof body.name !== 'string') {
|
|
210
|
+
sendJson(response, 400, { error: 'name is required' })
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const definition = await host.skills.get(body.name)
|
|
214
|
+
if (definition === undefined) {
|
|
215
|
+
sendJson(response, 404, { error: 'skill not found' })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
dir = definition.path !== undefined
|
|
219
|
+
? definition.path.replace(/[/\\]SKILL\.md$/, '').replace(/[/\\][^/\\]+\.md$/, '')
|
|
220
|
+
: definition.resourceBase?.kind === 'directory' ? definition.resourceBase.path : undefined
|
|
221
|
+
} else if (body.target === 'root') {
|
|
222
|
+
if (typeof body.id !== 'string') {
|
|
223
|
+
sendJson(response, 400, { error: 'id is required' })
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const entry = loadState().skillRoots.find(row => row.id === body.id)
|
|
227
|
+
if (entry === undefined) {
|
|
228
|
+
sendJson(response, 404, { error: 'repository not found' })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
dir = entry.materialDir ?? entry.path ?? entry.roots[0]
|
|
232
|
+
} else {
|
|
233
|
+
sendJson(response, 400, { error: 'unknown target' })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if (dir === undefined || !openDirectory(dir)) {
|
|
237
|
+
sendJson(response, 422, { error: 'directory is not available on disk' })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
sendJson(response, 200, { ok: true })
|
|
241
|
+
} catch (error) {
|
|
242
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
|
|
247
|
+
host.webServer.register({
|
|
248
|
+
kind: 'exact',
|
|
249
|
+
path: '/dsh-plugin-capabilities/roots',
|
|
250
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
251
|
+
if (request.method !== 'GET') {
|
|
252
|
+
response.writeHead(405, { allow: 'GET' })
|
|
253
|
+
response.end()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
sendJson(response, 200, { roots: loadState().skillRoots.map(toRootView) })
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
|
|
260
|
+
host.webServer.register({
|
|
261
|
+
kind: 'exact',
|
|
262
|
+
path: '/dsh-plugin-capabilities/roots/add',
|
|
263
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
264
|
+
if (request.method !== 'POST') {
|
|
265
|
+
response.writeHead(405, { allow: 'POST' })
|
|
266
|
+
response.end()
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (!sameOrigin(request)) {
|
|
270
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const body = (await readJsonBody(request)) as { kind?: unknown; path?: unknown; url?: unknown }
|
|
275
|
+
if (body.kind !== 'local' && body.kind !== 'git') {
|
|
276
|
+
sendJson(response, 400, { error: 'kind must be local or git' })
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const entry = body.kind === 'local'
|
|
280
|
+
? typeof body.path === 'string' && body.path.trim() !== ''
|
|
281
|
+
? await addLocalRepo(body.path)
|
|
282
|
+
: undefined
|
|
283
|
+
: typeof body.url === 'string' && body.url.trim() !== ''
|
|
284
|
+
? await addGitRepo(body.url)
|
|
285
|
+
: undefined
|
|
286
|
+
if (entry === undefined) {
|
|
287
|
+
sendJson(response, 400, { error: body.kind === 'local' ? 'path is required' : 'url is required' })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
await config.remountProvider()
|
|
291
|
+
sendJson(response, 200, { ok: true, root: toRootView(entry) })
|
|
292
|
+
} catch (error) {
|
|
293
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
|
|
298
|
+
host.webServer.register({
|
|
299
|
+
kind: 'exact',
|
|
300
|
+
path: '/dsh-plugin-capabilities/roots/remove',
|
|
301
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
302
|
+
if (request.method !== 'POST') {
|
|
303
|
+
response.writeHead(405, { allow: 'POST' })
|
|
304
|
+
response.end()
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if (!sameOrigin(request)) {
|
|
308
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const body = (await readJsonBody(request)) as { id?: unknown }
|
|
313
|
+
if (typeof body.id !== 'string') {
|
|
314
|
+
sendJson(response, 400, { error: 'id is required' })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const ok = removeSkillRoot(body.id)
|
|
318
|
+
if (!ok) {
|
|
319
|
+
sendJson(response, 404, { error: 'repository not found' })
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
await config.remountProvider()
|
|
323
|
+
sendJson(response, 200, { ok: true })
|
|
324
|
+
} catch (error) {
|
|
325
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
}),
|
|
329
|
+
|
|
330
|
+
host.webServer.register({
|
|
331
|
+
kind: 'exact',
|
|
332
|
+
path: '/dsh-plugin-capabilities/market/skills',
|
|
333
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
334
|
+
if (request.method !== 'GET') {
|
|
335
|
+
response.writeHead(405, { allow: 'GET' })
|
|
336
|
+
response.end()
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const index = await loadMarketIndex('skills')
|
|
340
|
+
if (index === null) {
|
|
341
|
+
sendJson(response, 502, { error: 'market index unavailable (offline?)' })
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
sendJson(response, 200, {
|
|
345
|
+
source: index.source,
|
|
346
|
+
repos: (index.skills ?? []).map(repo => ({ ...repo, installedId: findRootByUrl(repo.url)?.id ?? null })),
|
|
347
|
+
})
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
|
|
351
|
+
host.webServer.register({
|
|
352
|
+
kind: 'exact',
|
|
353
|
+
path: '/dsh-plugin-capabilities/market/mcp',
|
|
354
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
355
|
+
if (request.method !== 'GET') {
|
|
356
|
+
response.writeHead(405, { allow: 'GET' })
|
|
357
|
+
response.end()
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
const index = await loadMarketIndex('mcp')
|
|
361
|
+
if (index === null) {
|
|
362
|
+
sendJson(response, 502, { error: 'market index unavailable (offline?)' })
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
const existing = new Set(listMcp(config.profileDirPath).map(row => row.serverName))
|
|
366
|
+
sendJson(response, 200, {
|
|
367
|
+
source: index.source,
|
|
368
|
+
servers: (index.servers ?? []).map(server => ({ ...server, installed: existing.has(server.id) })),
|
|
369
|
+
})
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
|
|
373
|
+
host.webServer.register({
|
|
374
|
+
kind: 'exact',
|
|
375
|
+
path: '/dsh-plugin-capabilities/market/skills/install',
|
|
376
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
377
|
+
if (request.method !== 'POST') {
|
|
378
|
+
response.writeHead(405, { allow: 'POST' })
|
|
379
|
+
response.end()
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
if (!sameOrigin(request)) {
|
|
383
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const body = (await readJsonBody(request)) as { url?: unknown }
|
|
388
|
+
if (typeof body.url !== 'string') {
|
|
389
|
+
sendJson(response, 400, { error: 'url is required' })
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
const entry = await addGitRepo(body.url)
|
|
393
|
+
await config.remountProvider()
|
|
394
|
+
sendJson(response, 200, { ok: true, root: toRootView(entry) })
|
|
395
|
+
} catch (error) {
|
|
396
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
|
|
401
|
+
host.webServer.register({
|
|
402
|
+
kind: 'exact',
|
|
403
|
+
path: '/dsh-plugin-capabilities/market/mcp/install',
|
|
404
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
405
|
+
if (request.method !== 'POST') {
|
|
406
|
+
response.writeHead(405, { allow: 'POST' })
|
|
407
|
+
response.end()
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
if (!sameOrigin(request)) {
|
|
411
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const body = (await readJsonBody(request)) as { id?: unknown }
|
|
416
|
+
if (typeof body.id !== 'string') {
|
|
417
|
+
sendJson(response, 400, { error: 'id is required' })
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const index = await loadMarketIndex('mcp')
|
|
421
|
+
const server = index?.servers?.find((row: MarketMcpServer) => row.id === body.id)
|
|
422
|
+
if (server === undefined) {
|
|
423
|
+
sendJson(response, 404, { error: 'server not found in the market index' })
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
if (listMcp(config.profileDirPath).some(row => row.serverName === server.id)) {
|
|
427
|
+
sendJson(response, 409, { error: 'already installed' })
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
const input: McpInput = {
|
|
431
|
+
id: '',
|
|
432
|
+
serverName: server.id,
|
|
433
|
+
transport: server.transport,
|
|
434
|
+
...(server.transport === 'stdio'
|
|
435
|
+
? { command: server.command, args: server.args, env: undefined }
|
|
436
|
+
: { url: server.url }),
|
|
437
|
+
}
|
|
438
|
+
const invalid = validateMcpInput(input)
|
|
439
|
+
if (invalid !== null) {
|
|
440
|
+
sendJson(response, 400, { error: invalid })
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
const id = upsertMcp(config.profileDirPath, input)
|
|
444
|
+
sendJson(response, 200, { ok: true, id, restartNeeded: true })
|
|
445
|
+
} catch (error) {
|
|
446
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
|
|
117
451
|
host.webServer.register({
|
|
118
452
|
kind: 'exact',
|
|
119
453
|
path: '/dsh-plugin-capabilities/mcp',
|
package/src/skills.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync, mkdirSync
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
-
import { deleteSkill, serializeSkill, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
|
|
5
|
+
import { deleteSkill, serializeSkill, setSkillPolicy, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
|
|
6
6
|
|
|
7
7
|
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-skills-'))
|
|
8
8
|
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
@@ -71,3 +71,47 @@ describe('writeSkill / deleteSkill', () => {
|
|
|
71
71
|
expect(existsSync(join(root, 'skills', 'flat.txt'))).toBe(true)
|
|
72
72
|
})
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
describe('setSkillPolicy', () => {
|
|
76
|
+
const file = join(root, 'policy', 'SKILL.md')
|
|
77
|
+
const original = '---\nname: pol\ndescription: "A skill"\nwhenToUse: "later"\nlicense: MIT\n---\n\nBody stays.\n'
|
|
78
|
+
|
|
79
|
+
it('disables by adding both keys and keeps everything else byte-for-byte', () => {
|
|
80
|
+
mkdirSync(join(root, 'policy'), { recursive: true })
|
|
81
|
+
writeFileSync(file, original)
|
|
82
|
+
setSkillPolicy(file, false)
|
|
83
|
+
const text = readFileSync(file, 'utf8')
|
|
84
|
+
expect(text).toContain('disable-model-invocation: true')
|
|
85
|
+
expect(text).toContain('user-invocable: false')
|
|
86
|
+
expect(text).toContain('name: pol')
|
|
87
|
+
expect(text).toContain('whenToUse: "later"')
|
|
88
|
+
expect(text).toContain('license: MIT')
|
|
89
|
+
expect(text).toContain('Body stays.')
|
|
90
|
+
// Idempotent: disabling twice does not duplicate the keys.
|
|
91
|
+
setSkillPolicy(file, false)
|
|
92
|
+
expect(readFileSync(file, 'utf8').match(/disable-model-invocation/g)).toHaveLength(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('re-enables by removing both keys', () => {
|
|
96
|
+
setSkillPolicy(file, true)
|
|
97
|
+
const text = readFileSync(file, 'utf8')
|
|
98
|
+
expect(text).not.toContain('disable-model-invocation')
|
|
99
|
+
expect(text).not.toContain('user-invocable')
|
|
100
|
+
expect(text).toContain('license: MIT')
|
|
101
|
+
expect(text).toContain('Body stays.')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('preserves CRLF files and rejects frontmatter-less files', () => {
|
|
105
|
+
const crlf = join(root, 'policy-crlf', 'SKILL.md')
|
|
106
|
+
mkdirSync(join(root, 'policy-crlf'), { recursive: true })
|
|
107
|
+
writeFileSync(crlf, '---\r\nname: crlf\r\ndescription: d\r\n---\r\n\r\nbody\r\n')
|
|
108
|
+
setSkillPolicy(crlf, false)
|
|
109
|
+
const text = readFileSync(crlf, 'utf8')
|
|
110
|
+
expect(text).toContain('disable-model-invocation: true\r')
|
|
111
|
+
expect(text).toContain('user-invocable: false\r')
|
|
112
|
+
|
|
113
|
+
const bare = join(root, 'policy-bare.md')
|
|
114
|
+
writeFileSync(bare, 'no frontmatter here')
|
|
115
|
+
expect(() => setSkillPolicy(bare, false)).toThrow(/frontmatter/)
|
|
116
|
+
})
|
|
117
|
+
})
|
package/src/skills.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* land in the catalog without any restart.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
|
|
@@ -79,3 +79,24 @@ export function deleteSkill(name: string, dshHome?: string): boolean {
|
|
|
79
79
|
rmSync(dir, { recursive: true, force: true })
|
|
80
80
|
return true
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Flip one skill file's load policy by editing only the two invocation keys
|
|
85
|
+
* in its frontmatter — `disable-model-invocation: true` plus
|
|
86
|
+
* `user-invocable: false` together take the skill out of both the model and
|
|
87
|
+
* the human catalogs on every standard-provider surface; enabling removes
|
|
88
|
+
* both keys (defaults permit both surfaces). Works on any file-backed skill
|
|
89
|
+
* (user, project, agent, bundled, custom); the rest of the file, including
|
|
90
|
+
* custom keys, ordering, and body, survives byte-for-byte.
|
|
91
|
+
*/
|
|
92
|
+
export function setSkillPolicy(file: string, enabled: boolean): void {
|
|
93
|
+
const text = readFileSync(file, 'utf8')
|
|
94
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
|
|
95
|
+
if (match === null) throw new Error('skill file has no frontmatter block')
|
|
96
|
+
const newline = text.includes('\r\n---') ? '\r\n' : '\n'
|
|
97
|
+
const body = text.slice(match[0].length)
|
|
98
|
+
const lines = match[1].split(/\r?\n/)
|
|
99
|
+
const kept = lines.filter(line => !/^(disable-model-invocation|user-invocable):/.test(line))
|
|
100
|
+
if (!enabled) kept.push('disable-model-invocation: true', 'user-invocable: false')
|
|
101
|
+
writeFileSync(file, `---${newline}${kept.join(newline)}${newline}---${body}`, 'utf8')
|
|
102
|
+
}
|
package/src/smoke.test.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn, execFile } from 'node:child_process'
|
|
11
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { tmpdir, homedir } from 'node:os'
|
|
13
13
|
import { dirname, join } from 'node:path'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
@@ -153,5 +153,58 @@ describe.skipIf(process.env.DSH_DESKTOP_PLUGIN_SMOKE !== '1' || !guard || !nodeO
|
|
|
153
153
|
expect(servers.servers.some(row => row.serverName === 'smokeweb')).toBe(true)
|
|
154
154
|
const patch = readFileSync(join(smokeRoot, 'profiles', 'web', 'cordis.patch.yml'), 'utf8')
|
|
155
155
|
expect(patch).toContain('@deepseek-ai/dsh-mcp-client')
|
|
156
|
+
|
|
157
|
+
// 6. Local skill repository: register a folder, provider remounts, the
|
|
158
|
+
// skill enters the catalog under source "custom".
|
|
159
|
+
const repoDir = join(smokeRoot, 'my-repo')
|
|
160
|
+
mkdirSync(join(repoDir, 'repo-skill'), { recursive: true })
|
|
161
|
+
writeFileSync(join(repoDir, 'repo-skill', 'SKILL.md'), '---\nname: repo-skill\ndescription: from a local repo\n---\n\nhi')
|
|
162
|
+
const addRoot = await post('/dsh-plugin-capabilities/roots/add', { kind: 'local', path: repoDir })
|
|
163
|
+
expect(addRoot.status).toBe(200)
|
|
164
|
+
expect(readFileSync(join(smokeRoot, 'dsh-plugin-capabilities', 'state.json'), 'utf8')).toContain('my-repo') // roots recorded
|
|
165
|
+
let repoSeen = false
|
|
166
|
+
for (let tick = 0; tick < 10 && !repoSeen; tick++) {
|
|
167
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
168
|
+
const again = await fetch(`${base}/dsh-plugin-capabilities/skills`).then(r => r.json()) as { skills: Array<{ name: string; source: string; policyEditable: boolean }> }
|
|
169
|
+
repoSeen = again.skills.some(skill => skill.name === 'repo-skill' && skill.source === 'custom' && skill.policyEditable)
|
|
170
|
+
}
|
|
171
|
+
expect(repoSeen).toBe(true)
|
|
172
|
+
|
|
173
|
+
// 7. Policy toggle flips the invocation flags through the watched file.
|
|
174
|
+
const policyOff = await post('/dsh-plugin-capabilities/skill/policy', { name: 'repo-skill', enabled: false })
|
|
175
|
+
expect(policyOff.status).toBe(200)
|
|
176
|
+
expect(readFileSync(join(repoDir, 'repo-skill', 'SKILL.md'), 'utf8')).toContain('disable-model-invocation: true')
|
|
177
|
+
let policySeen = false
|
|
178
|
+
for (let tick = 0; tick < 10 && !policySeen; tick++) {
|
|
179
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
180
|
+
const again = await fetch(`${base}/dsh-plugin-capabilities/skills`).then(r => r.json()) as { skills: Array<{ name: string; invocation: { modelInvocable: boolean; userInvocable: boolean } }> }
|
|
181
|
+
const row = again.skills.find(skill => skill.name === 'repo-skill')
|
|
182
|
+
policySeen = row !== undefined && !row.invocation.modelInvocable && !row.invocation.userInvocable
|
|
183
|
+
}
|
|
184
|
+
expect(policySeen).toBe(true)
|
|
185
|
+
const policyRestore = await post('/dsh-plugin-capabilities/skill/policy', { name: 'repo-skill', enabled: true })
|
|
186
|
+
expect(policyRestore.status).toBe(200)
|
|
187
|
+
|
|
188
|
+
// 8. Market: MCP index serves (remote or bundled fallback) and one-click
|
|
189
|
+
// install writes a profile row.
|
|
190
|
+
const marketIndex = await fetch(`${base}/dsh-plugin-capabilities/market/mcp`)
|
|
191
|
+
expect(marketIndex.status).toBe(200)
|
|
192
|
+
const marketBody = await marketIndex.json() as { servers: Array<{ id: string; installed: boolean }> }
|
|
193
|
+
const memory = marketBody.servers.find(server => server.id === 'memory')
|
|
194
|
+
expect(memory).toBeDefined()
|
|
195
|
+
if (memory !== undefined && !memory.installed) {
|
|
196
|
+
const install = await post('/dsh-plugin-capabilities/market/mcp/install', { id: 'memory' })
|
|
197
|
+
expect(install.status).toBe(200)
|
|
198
|
+
const after = await fetch(`${base}/dsh-plugin-capabilities/mcp`).then(r => r.json()) as { servers: Array<{ serverName: string }> }
|
|
199
|
+
expect(after.servers.some(row => row.serverName === 'memory')).toBe(true)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 9. Open-folder route resolves server-side and answers ok; the skills
|
|
203
|
+
// home is created on demand, so a fresh DSH_HOME opens (not 422s).
|
|
204
|
+
const open = await post('/dsh-plugin-capabilities/open', { target: 'user-skills' })
|
|
205
|
+
expect(open.status).toBe(200)
|
|
206
|
+
expect(existsSync(join(smokeRoot, 'skills'))).toBe(true)
|
|
207
|
+
const openBad = await post('/dsh-plugin-capabilities/open', { target: 'root', id: 'does-not-exist' })
|
|
208
|
+
expect(openBad.status).toBe(404)
|
|
156
209
|
})
|
|
157
210
|
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
import { addSkillRoot, findRootByUrl, loadState, materialDirFor, newEntryId, removeSkillRoot, saveState } from './state.ts'
|
|
6
|
+
|
|
7
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-state-'))
|
|
8
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
9
|
+
|
|
10
|
+
describe('state round-trip', () => {
|
|
11
|
+
it('returns empty for missing and corrupt files', () => {
|
|
12
|
+
expect(loadState(join(root, 'missing'))).toEqual({ skillRoots: [] })
|
|
13
|
+
const corrupt = join(root, 'corrupt')
|
|
14
|
+
mkdirSync(join(corrupt, 'dsh-plugin-capabilities'), { recursive: true })
|
|
15
|
+
writeFileSync(join(corrupt, 'dsh-plugin-capabilities', 'state.json'), '{oops')
|
|
16
|
+
expect(loadState(corrupt)).toEqual({ skillRoots: [] })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('adds, finds by url, and removes entries', () => {
|
|
20
|
+
const home = join(root, 'home')
|
|
21
|
+
const entry = addSkillRoot({ id: newEntryId('git'), kind: 'git', label: 'a/b', url: 'a/b', roots: [join(root, 'x')] }, home)
|
|
22
|
+
expect(loadState(home).skillRoots).toHaveLength(1)
|
|
23
|
+
expect(findRootByUrl('a/b', home)?.id).toBe(entry.id)
|
|
24
|
+
expect(findRootByUrl('other/repo', home)).toBeUndefined()
|
|
25
|
+
|
|
26
|
+
expect(removeSkillRoot(entry.id, home)).toBe(true)
|
|
27
|
+
expect(removeSkillRoot(entry.id, home)).toBe(false)
|
|
28
|
+
expect(loadState(home).skillRoots).toHaveLength(0)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('drops malformed entries on load instead of failing', () => {
|
|
32
|
+
const home = join(root, 'home-malformed')
|
|
33
|
+
saveState({
|
|
34
|
+
skillRoots: [
|
|
35
|
+
{ id: 'ok', kind: 'local', label: 'ok', roots: [], addedAt: 1 },
|
|
36
|
+
{ nope: true } as never,
|
|
37
|
+
null as never,
|
|
38
|
+
],
|
|
39
|
+
}, home)
|
|
40
|
+
const loaded = loadState(home)
|
|
41
|
+
expect(loaded.skillRoots.map(entry => entry.id)).toEqual(['ok'])
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('removeSkillRoot with link material', () => {
|
|
46
|
+
it('deletes the junction wrapper without touching the linked source', () => {
|
|
47
|
+
const home = join(root, 'home-link')
|
|
48
|
+
const source = join(root, 'linked-source')
|
|
49
|
+
mkdirSync(source, { recursive: true })
|
|
50
|
+
writeFileSync(join(source, 'SKILL.md'), '---\nname: keep\n---\n')
|
|
51
|
+
|
|
52
|
+
const id = newEntryId('local')
|
|
53
|
+
const material = materialDirFor(id, home)
|
|
54
|
+
mkdirSync(material, { recursive: true })
|
|
55
|
+
symlinkSync(source, join(material, 'skill'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
56
|
+
addSkillRoot({ id, kind: 'local', label: 'keep', path: source, roots: [material], materialDir: material }, home)
|
|
57
|
+
|
|
58
|
+
expect(removeSkillRoot(id, home)).toBe(true)
|
|
59
|
+
expect(existsSync(material)).toBe(false)
|
|
60
|
+
expect(existsSync(join(source, 'SKILL.md'))).toBe(true)
|
|
61
|
+
// State file persisted without the entry.
|
|
62
|
+
expect(readFileSync(join(home, 'dsh-plugin-capabilities', 'state.json'), 'utf8')).not.toContain('linked-source')
|
|
63
|
+
})
|
|
64
|
+
})
|