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/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: { profileDirPath: string }): () => void {
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',
@@ -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
+ })