dsh-plugin-capabilities 0.1.6 → 0.2.0

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/src/routes.ts CHANGED
@@ -3,16 +3,39 @@
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import { scanAllMcp } from './agents.ts'
5
5
  import { readJsonBody, sameOrigin, sendJson } from './http.ts'
6
+ import { loadMarketIndex, type MarketMcpServer } from './market.ts'
7
+ import { openDirectory } from './opener.ts'
8
+ import { addGitRepo, addLocalRepo, rootExists } from './repos.ts'
6
9
  import { dshLaunch, restartOwnedByShell, scheduleRestart, trustedRestartRequest } from './restart.ts'
7
- import { deleteSkill, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
10
+ import { deleteSkill, setSkillPolicy, userSkillsDir, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
11
+ import { findRootByUrl, loadState, pluginStateDir, removeSkillRoot, type SkillRootEntry } from './state.ts'
8
12
  import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
9
- import type { CapabilitiesHost } from './types.ts'
13
+ import type { CapabilitiesHost, HostSkill } from './types.ts'
10
14
 
11
15
  /** Only this source is writable from the Settings page (provider rank 400). */
12
16
  const EDITABLE_SOURCE = 'user-dsh'
13
17
 
18
+ /** One catalog skill as the browser sees it (editable/dir flags added). */
19
+ type SkillRow = HostSkill & { editable: boolean; dir?: string; policyEditable: boolean }
20
+
21
+ function toSkillRow(skill: HostSkill): SkillRow {
22
+ const dir = skill.resourceBase?.kind === 'directory' ? skill.resourceBase.path : undefined
23
+ return { ...skill, editable: skill.source === EDITABLE_SOURCE, ...(dir !== undefined ? { dir } : {}), policyEditable: dir !== undefined }
24
+ }
25
+
26
+ /** One registered repository plus a liveness flag (roots can go stale). */
27
+ function toRootView(entry: SkillRootEntry): SkillRootEntry & { live: boolean } {
28
+ return { ...entry, live: entry.roots.every(root => rootExists(root)) }
29
+ }
30
+
31
+ export interface CapabilitiesRoutesConfig {
32
+ profileDirPath: string
33
+ /** Remount the host-plane skill provider after root-set changes. */
34
+ remountProvider: () => Promise<void>
35
+ }
36
+
14
37
  /** Register the manager's routes; returns the disposer removing them all. */
15
- export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profileDirPath: string }): () => void {
38
+ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: CapabilitiesRoutesConfig): () => void {
16
39
  const disposers = [
17
40
  host.webServer.register({
18
41
  kind: 'exact',
@@ -25,9 +48,7 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profil
25
48
  }
26
49
  try {
27
50
  const skills = await host.skills.list()
28
- sendJson(response, 200, {
29
- skills: skills.map(skill => ({ ...skill, editable: skill.source === EDITABLE_SOURCE })),
30
- })
51
+ sendJson(response, 200, { skills: skills.map(toSkillRow) })
31
52
  } catch (error) {
32
53
  sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
33
54
  }
@@ -47,6 +68,10 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profil
47
68
  const name = url.searchParams.get('name') ?? ''
48
69
  try {
49
70
  const definition = await host.skills.get(name)
71
+ if (definition === undefined) {
72
+ sendJson(response, 404, { error: 'skill not found' })
73
+ return
74
+ }
50
75
  sendJson(response, 200, { name: definition.name, content: definition.content })
51
76
  } catch (error) {
52
77
  sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
@@ -114,6 +139,311 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profil
114
139
  },
115
140
  }),
116
141
 
142
+ host.webServer.register({
143
+ kind: 'exact',
144
+ path: '/dsh-plugin-capabilities/skill/policy',
145
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
146
+ if (request.method !== 'POST') {
147
+ response.writeHead(405, { allow: 'POST' })
148
+ response.end()
149
+ return
150
+ }
151
+ if (!sameOrigin(request)) {
152
+ sendJson(response, 403, { error: 'untrusted origin' })
153
+ return
154
+ }
155
+ try {
156
+ const body = (await readJsonBody(request)) as { name?: unknown; enabled?: unknown }
157
+ if (typeof body.name !== 'string' || typeof body.enabled !== 'boolean') {
158
+ sendJson(response, 400, { error: 'name and enabled are required' })
159
+ return
160
+ }
161
+ const definition = await host.skills.get(body.name)
162
+ if (definition === undefined) {
163
+ sendJson(response, 404, { error: 'skill not found' })
164
+ return
165
+ }
166
+ if (definition.path === undefined) {
167
+ sendJson(response, 422, { error: 'skill has no file on disk (runtime-registered)' })
168
+ return
169
+ }
170
+ setSkillPolicy(definition.path, body.enabled)
171
+ sendJson(response, 200, { ok: true })
172
+ } catch (error) {
173
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
174
+ }
175
+ },
176
+ }),
177
+
178
+ host.webServer.register({
179
+ kind: 'exact',
180
+ path: '/dsh-plugin-capabilities/open',
181
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
182
+ if (request.method !== 'POST') {
183
+ response.writeHead(405, { allow: 'POST' })
184
+ response.end()
185
+ return
186
+ }
187
+ if (!sameOrigin(request)) {
188
+ sendJson(response, 403, { error: 'untrusted origin' })
189
+ return
190
+ }
191
+ try {
192
+ const body = (await readJsonBody(request)) as { target?: unknown; name?: unknown; id?: unknown }
193
+ if (typeof body.target !== 'string') {
194
+ sendJson(response, 400, { error: 'target is required' })
195
+ return
196
+ }
197
+ // Targets are resolved server-side; the browser never supplies a
198
+ // raw path, so this cannot be turned into an arbitrary open.
199
+ let dir: string | undefined
200
+ if (body.target === 'user-skills') {
201
+ dir = userSkillsDir()
202
+ } else if (body.target === 'plugin-state') {
203
+ dir = pluginStateDir()
204
+ } else if (body.target === 'skill') {
205
+ if (typeof body.name !== 'string') {
206
+ sendJson(response, 400, { error: 'name is required' })
207
+ return
208
+ }
209
+ const definition = await host.skills.get(body.name)
210
+ if (definition === undefined) {
211
+ sendJson(response, 404, { error: 'skill not found' })
212
+ return
213
+ }
214
+ dir = definition.path !== undefined
215
+ ? definition.path.replace(/[/\\]SKILL\.md$/, '').replace(/[/\\][^/\\]+\.md$/, '')
216
+ : definition.resourceBase?.kind === 'directory' ? definition.resourceBase.path : undefined
217
+ } else if (body.target === 'root') {
218
+ if (typeof body.id !== 'string') {
219
+ sendJson(response, 400, { error: 'id is required' })
220
+ return
221
+ }
222
+ const entry = loadState().skillRoots.find(row => row.id === body.id)
223
+ if (entry === undefined) {
224
+ sendJson(response, 404, { error: 'repository not found' })
225
+ return
226
+ }
227
+ dir = entry.materialDir ?? entry.path ?? entry.roots[0]
228
+ } else {
229
+ sendJson(response, 400, { error: 'unknown target' })
230
+ return
231
+ }
232
+ if (dir === undefined || !openDirectory(dir)) {
233
+ sendJson(response, 422, { error: 'directory is not available on disk' })
234
+ return
235
+ }
236
+ sendJson(response, 200, { ok: true })
237
+ } catch (error) {
238
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
239
+ }
240
+ },
241
+ }),
242
+
243
+ host.webServer.register({
244
+ kind: 'exact',
245
+ path: '/dsh-plugin-capabilities/roots',
246
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
247
+ if (request.method !== 'GET') {
248
+ response.writeHead(405, { allow: 'GET' })
249
+ response.end()
250
+ return
251
+ }
252
+ sendJson(response, 200, { roots: loadState().skillRoots.map(toRootView) })
253
+ },
254
+ }),
255
+
256
+ host.webServer.register({
257
+ kind: 'exact',
258
+ path: '/dsh-plugin-capabilities/roots/add',
259
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
260
+ if (request.method !== 'POST') {
261
+ response.writeHead(405, { allow: 'POST' })
262
+ response.end()
263
+ return
264
+ }
265
+ if (!sameOrigin(request)) {
266
+ sendJson(response, 403, { error: 'untrusted origin' })
267
+ return
268
+ }
269
+ try {
270
+ const body = (await readJsonBody(request)) as { kind?: unknown; path?: unknown; url?: unknown }
271
+ if (body.kind !== 'local' && body.kind !== 'git') {
272
+ sendJson(response, 400, { error: 'kind must be local or git' })
273
+ return
274
+ }
275
+ const entry = body.kind === 'local'
276
+ ? typeof body.path === 'string' && body.path.trim() !== ''
277
+ ? await addLocalRepo(body.path)
278
+ : undefined
279
+ : typeof body.url === 'string' && body.url.trim() !== ''
280
+ ? await addGitRepo(body.url)
281
+ : undefined
282
+ if (entry === undefined) {
283
+ sendJson(response, 400, { error: body.kind === 'local' ? 'path is required' : 'url is required' })
284
+ return
285
+ }
286
+ await config.remountProvider()
287
+ sendJson(response, 200, { ok: true, root: toRootView(entry) })
288
+ } catch (error) {
289
+ sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) })
290
+ }
291
+ },
292
+ }),
293
+
294
+ host.webServer.register({
295
+ kind: 'exact',
296
+ path: '/dsh-plugin-capabilities/roots/remove',
297
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
298
+ if (request.method !== 'POST') {
299
+ response.writeHead(405, { allow: 'POST' })
300
+ response.end()
301
+ return
302
+ }
303
+ if (!sameOrigin(request)) {
304
+ sendJson(response, 403, { error: 'untrusted origin' })
305
+ return
306
+ }
307
+ try {
308
+ const body = (await readJsonBody(request)) as { id?: unknown }
309
+ if (typeof body.id !== 'string') {
310
+ sendJson(response, 400, { error: 'id is required' })
311
+ return
312
+ }
313
+ const ok = removeSkillRoot(body.id)
314
+ if (!ok) {
315
+ sendJson(response, 404, { error: 'repository not found' })
316
+ return
317
+ }
318
+ await config.remountProvider()
319
+ sendJson(response, 200, { ok: true })
320
+ } catch (error) {
321
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
322
+ }
323
+ },
324
+ }),
325
+
326
+ host.webServer.register({
327
+ kind: 'exact',
328
+ path: '/dsh-plugin-capabilities/market/skills',
329
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
330
+ if (request.method !== 'GET') {
331
+ response.writeHead(405, { allow: 'GET' })
332
+ response.end()
333
+ return
334
+ }
335
+ const index = await loadMarketIndex('skills')
336
+ if (index === null) {
337
+ sendJson(response, 502, { error: 'market index unavailable (offline?)' })
338
+ return
339
+ }
340
+ sendJson(response, 200, {
341
+ source: index.source,
342
+ repos: (index.skills ?? []).map(repo => ({ ...repo, installedId: findRootByUrl(repo.url)?.id ?? null })),
343
+ })
344
+ },
345
+ }),
346
+
347
+ host.webServer.register({
348
+ kind: 'exact',
349
+ path: '/dsh-plugin-capabilities/market/mcp',
350
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
351
+ if (request.method !== 'GET') {
352
+ response.writeHead(405, { allow: 'GET' })
353
+ response.end()
354
+ return
355
+ }
356
+ const index = await loadMarketIndex('mcp')
357
+ if (index === null) {
358
+ sendJson(response, 502, { error: 'market index unavailable (offline?)' })
359
+ return
360
+ }
361
+ const existing = new Set(listMcp(config.profileDirPath).map(row => row.serverName))
362
+ sendJson(response, 200, {
363
+ source: index.source,
364
+ servers: (index.servers ?? []).map(server => ({ ...server, installed: existing.has(server.id) })),
365
+ })
366
+ },
367
+ }),
368
+
369
+ host.webServer.register({
370
+ kind: 'exact',
371
+ path: '/dsh-plugin-capabilities/market/skills/install',
372
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
373
+ if (request.method !== 'POST') {
374
+ response.writeHead(405, { allow: 'POST' })
375
+ response.end()
376
+ return
377
+ }
378
+ if (!sameOrigin(request)) {
379
+ sendJson(response, 403, { error: 'untrusted origin' })
380
+ return
381
+ }
382
+ try {
383
+ const body = (await readJsonBody(request)) as { url?: unknown }
384
+ if (typeof body.url !== 'string') {
385
+ sendJson(response, 400, { error: 'url is required' })
386
+ return
387
+ }
388
+ const entry = await addGitRepo(body.url)
389
+ await config.remountProvider()
390
+ sendJson(response, 200, { ok: true, root: toRootView(entry) })
391
+ } catch (error) {
392
+ sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) })
393
+ }
394
+ },
395
+ }),
396
+
397
+ host.webServer.register({
398
+ kind: 'exact',
399
+ path: '/dsh-plugin-capabilities/market/mcp/install',
400
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
401
+ if (request.method !== 'POST') {
402
+ response.writeHead(405, { allow: 'POST' })
403
+ response.end()
404
+ return
405
+ }
406
+ if (!sameOrigin(request)) {
407
+ sendJson(response, 403, { error: 'untrusted origin' })
408
+ return
409
+ }
410
+ try {
411
+ const body = (await readJsonBody(request)) as { id?: unknown }
412
+ if (typeof body.id !== 'string') {
413
+ sendJson(response, 400, { error: 'id is required' })
414
+ return
415
+ }
416
+ const index = await loadMarketIndex('mcp')
417
+ const server = index?.servers?.find((row: MarketMcpServer) => row.id === body.id)
418
+ if (server === undefined) {
419
+ sendJson(response, 404, { error: 'server not found in the market index' })
420
+ return
421
+ }
422
+ if (listMcp(config.profileDirPath).some(row => row.serverName === server.id)) {
423
+ sendJson(response, 409, { error: 'already installed' })
424
+ return
425
+ }
426
+ const input: McpInput = {
427
+ id: '',
428
+ serverName: server.id,
429
+ transport: server.transport,
430
+ ...(server.transport === 'stdio'
431
+ ? { command: server.command, args: server.args, env: undefined }
432
+ : { url: server.url }),
433
+ }
434
+ const invalid = validateMcpInput(input)
435
+ if (invalid !== null) {
436
+ sendJson(response, 400, { error: invalid })
437
+ return
438
+ }
439
+ const id = upsertMcp(config.profileDirPath, input)
440
+ sendJson(response, 200, { ok: true, id, restartNeeded: true })
441
+ } catch (error) {
442
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
443
+ }
444
+ },
445
+ }),
446
+
117
447
  host.webServer.register({
118
448
  kind: 'exact',
119
449
  path: '/dsh-plugin-capabilities/mcp',
@@ -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,57 @@ 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 (no GUI
203
+ // assertion beyond the route not failing headless).
204
+ const open = await post('/dsh-plugin-capabilities/open', { target: 'user-skills' })
205
+ expect([200, 422]).toContain(open.status)
206
+ const openBad = await post('/dsh-plugin-capabilities/open', { target: 'root', id: 'does-not-exist' })
207
+ expect(openBad.status).toBe(404)
156
208
  })
157
209
  })
@@ -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
+ })