dsh-plugin-capabilities 0.1.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.
@@ -0,0 +1,73 @@
1
+ import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync, mkdirSync } 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 { deleteSkill, serializeSkill, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
6
+
7
+ const root = mkdtempSync(join(tmpdir(), 'dsh-caps-skills-'))
8
+ afterAll(() => rmSync(root, { recursive: true, force: true }))
9
+
10
+ const base: SkillInput = {
11
+ name: 'my-skill',
12
+ description: 'Greets politely',
13
+ modelInvocable: true,
14
+ userInvocable: true,
15
+ content: 'Always say please.',
16
+ }
17
+
18
+ describe('serializeSkill', () => {
19
+ it('writes required frontmatter and omits default policy keys', () => {
20
+ const text = serializeSkill(base)
21
+ expect(text).toContain('name: my-skill')
22
+ expect(text).toContain(`description: "Greets politely"`)
23
+ expect(text).toContain('Always say please.')
24
+ expect(text).not.toContain('disable-model-invocation')
25
+ expect(text).not.toContain('user-invocable')
26
+ })
27
+ it('writes non-default policy keys and whenToUse', () => {
28
+ const text = serializeSkill({ ...base, modelInvocable: false, userInvocable: false, whenToUse: 'When greeting' })
29
+ expect(text).toContain('disable-model-invocation: true')
30
+ expect(text).toContain('user-invocable: false')
31
+ expect(text).toContain('whenToUse: "When greeting"')
32
+ })
33
+ it('escapes quotes and newlines in descriptions (YAML double-quoted)', () => {
34
+ const text = serializeSkill({ ...base, description: 'line1\n"quoted" \\ back' })
35
+ expect(text).toContain('description: "line1\\n\\"quoted\\" \\\\ back"')
36
+ })
37
+ })
38
+
39
+ describe('validateSkillInput', () => {
40
+ it('rejects bad names, empty descriptions, oversized bodies', () => {
41
+ expect(validateSkillInput({ ...base, name: 'Bad_Name' })).toContain('kebab')
42
+ expect(validateSkillInput({ ...base, name: '../escape' })).toContain('kebab')
43
+ expect(validateSkillInput({ ...base, description: ' ' })).toContain('description')
44
+ expect(validateSkillInput({ ...base, content: 'x'.repeat(256 * 1024 + 1) })).toContain('content')
45
+ })
46
+ it('accepts a valid skill', () => {
47
+ expect(validateSkillInput(base)).toBeNull()
48
+ })
49
+ })
50
+
51
+ describe('writeSkill / deleteSkill', () => {
52
+ it('round-trips into the user skills root and deletes by exact directory', () => {
53
+ const file = writeSkill(base, root)
54
+ expect(file).toBe(join(root, 'skills', 'my-skill', 'SKILL.md'))
55
+ expect(readFileSync(file, 'utf8')).toBe(serializeSkill(base))
56
+
57
+ expect(deleteSkill('my-skill', root)).toBe(true)
58
+ expect(existsSync(join(root, 'skills', 'my-skill'))).toBe(false)
59
+ expect(deleteSkill('my-skill', root)).toBe(false)
60
+ })
61
+ it('never touches sibling directories on delete', () => {
62
+ mkdirSync(join(root, 'skills', 'other'), { recursive: true })
63
+ writeFileSync(join(root, 'skills', 'other', 'SKILL.md'), '---\nname: other\ndescription: d\n---\n\nx')
64
+ expect(deleteSkill('my-skill', root)).toBe(false)
65
+ expect(existsSync(join(root, 'skills', 'other', 'SKILL.md'))).toBe(true)
66
+ })
67
+ it('rejects non-directory skill names in delete', () => {
68
+ mkdirSync(join(root, 'skills', 'flat'), { recursive: true })
69
+ writeFileSync(join(root, 'skills', 'flat.txt'), 'stray')
70
+ expect(deleteSkill('..', root)).toBe(false)
71
+ expect(existsSync(join(root, 'skills', 'flat.txt'))).toBe(true)
72
+ })
73
+ })
package/src/skills.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Skill catalog plumbing: frontmatter serialization for user-root SKILL.md
3
+ * files plus create/update/delete against `$DSH_HOME/skills`. Discovery is the
4
+ * host's business — the filesystem provider watches the directory, so writes
5
+ * land in the catalog without any restart.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+
12
+ /** Host skill name grammar (dsh-skill's SKILL_NAME). */
13
+ export const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
14
+
15
+ /** One skill write request from the browser. */
16
+ export interface SkillInput {
17
+ name: string
18
+ description: string
19
+ whenToUse?: string
20
+ modelInvocable: boolean
21
+ userInvocable: boolean
22
+ content: string
23
+ }
24
+
25
+ /** The user-owned skill root this plugin writes into (provider rank 400). */
26
+ export function userSkillsDir(dshHome: string | undefined = process.env.DSH_HOME): string {
27
+ return join(dshHome ?? join(homedir(), '.dsh'), 'skills')
28
+ }
29
+
30
+ /** YAML double-quoted scalar (JSON string syntax is valid YAML 1.2). */
31
+ function quote(value: string): string {
32
+ return JSON.stringify(value)
33
+ }
34
+
35
+ /** Frontmatter + body for one skill file. Policy keys only when non-default. */
36
+ export function serializeSkill(input: SkillInput): string {
37
+ const lines = [
38
+ `name: ${input.name}`,
39
+ `description: ${quote(input.description)}`,
40
+ ]
41
+ if (input.whenToUse !== undefined && input.whenToUse !== '') lines.push(`whenToUse: ${quote(input.whenToUse)}`)
42
+ if (!input.modelInvocable) lines.push('disable-model-invocation: true')
43
+ if (!input.userInvocable) lines.push('user-invocable: false')
44
+ const body = input.content.replace(/\r\n/g, '\n').trim()
45
+ return `---\n${lines.join('\n')}\n---\n\n${body}\n`
46
+ }
47
+
48
+ /** Validate one write request; returns the rejection reason or null. */
49
+ export function validateSkillInput(input: SkillInput): string | null {
50
+ if (!SKILL_NAME_RE.test(input.name)) return 'name must be kebab-case (a-z, 0-9, dashes)'
51
+ if (input.description.trim() === '') return 'description is required'
52
+ if (input.description.length > 1024) return 'description too long (max 1024)'
53
+ if (input.whenToUse !== undefined && input.whenToUse.length > 2048) return 'whenToUse too long (max 2048)'
54
+ if (input.content.length > 256 * 1024) return 'content too large (max 256 KiB)'
55
+ return null
56
+ }
57
+
58
+ /** Directory holding one user skill's SKILL.md; name grammar blocks traversal. */
59
+ function skillDir(name: string, dshHome?: string): string {
60
+ return join(userSkillsDir(dshHome), name)
61
+ }
62
+
63
+ /** Create or update a user skill. Returns the written path. */
64
+ export function writeSkill(input: SkillInput, dshHome?: string): string {
65
+ const dir = skillDir(input.name, dshHome)
66
+ mkdirSync(dir, { recursive: true })
67
+ const file = join(dir, 'SKILL.md')
68
+ writeFileSync(file, serializeSkill(input), 'utf8')
69
+ return file
70
+ }
71
+
72
+ /** Delete a user skill directory. Returns false when it does not exist. */
73
+ export function deleteSkill(name: string, dshHome?: string): boolean {
74
+ if (!SKILL_NAME_RE.test(name)) return false
75
+ const dir = skillDir(name, dshHome)
76
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return false
77
+ // Only ever remove the exact directory this name resolves to under the
78
+ // skills root; the regex already pins it to one safe path segment.
79
+ rmSync(dir, { recursive: true, force: true })
80
+ return true
81
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * dsh-plugin-capabilities end-to-end smoke: real source dsh, temp DSH_HOME,
3
+ * install this package into the web profile, boot `dsh web`, then probe the
4
+ * manager's routes, including a real skill write and MCP row write.
5
+ *
6
+ * Gate: DSH_DESKTOP_PLUGIN_SMOKE=1. Requires deepseek-harness checked out
7
+ * beside this repo and a host that permits capturing child-process output.
8
+ */
9
+
10
+ import { spawn, execFile } from 'node:child_process'
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
12
+ import { tmpdir, homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { afterAll, describe, expect, it } from 'vitest'
16
+
17
+ const srcDir = fileURLToPath(new URL('.', import.meta.url))
18
+ const pluginDir = join(srcDir, '..')
19
+ // Layout convention: this repo and deepseek-harness/ sit side by side under
20
+ // the same parent directory (src → repo → parent).
21
+ const repoRoot = join(srcDir, '..', '..', 'deepseek-harness')
22
+ const guard = existsSync(join(repoRoot, 'apps', 'cli', 'src', 'bin.ts'))
23
+ const [nodeMajor, nodeMinor] = process.version.slice(1).split('.').map(Number)
24
+ const nodeOk = (nodeMajor === 22 && nodeMinor >= 19) || nodeMajor >= 24
25
+
26
+ const smokeRoot = mkdtempSync(join(tmpdir(), 'dsh-plugin-capabilities-smoke-'))
27
+ const dshBin = join(repoRoot, 'apps', 'cli', 'src', 'bin.ts')
28
+
29
+ /** Test env with a CLEAN PATH (vitest prepends ancestor .bin dirs). */
30
+ function smokeEnv(dshHome: string): NodeJS.ProcessEnv {
31
+ const systemBins = [
32
+ process.env.npm_config_prefix,
33
+ join(homedir(), 'AppData', 'Roaming', 'npm'),
34
+ dirname(process.execPath),
35
+ ].filter((value): value is string => typeof value === 'string' && value !== '')
36
+ const pathValue = [...systemBins, 'C:\\Windows\\system32', 'C:\\Windows'].join(';')
37
+ return { ...process.env, DSH_HOME: dshHome, PATH: pathValue }
38
+ }
39
+
40
+ function dsh(args: string[], env: NodeJS.ProcessEnv): Promise<{ code: number | null; out: string }> {
41
+ return new Promise((resolve) => {
42
+ execFile(process.execPath, ['--import', 'tsx/esm', dshBin, ...args], {
43
+ cwd: repoRoot,
44
+ env: { ...process.env, ...env },
45
+ }, (error, stdout, stderr) => {
46
+ const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1
47
+ resolve({ code, out: `${stdout}\n${stderr}` })
48
+ })
49
+ })
50
+ }
51
+
52
+ function bootWeb(dshHome: string): Promise<{ port: number }> {
53
+ return new Promise((resolve, reject) => {
54
+ // `dsh web` is a hardcoded alias for `--profile web` and rejects any
55
+ // parent --profile (deepseek-harness apps/cli/src/args.ts).
56
+ const child = spawn(process.execPath, ['--import', 'tsx/esm', dshBin, 'web', '--port', '0', '--host', '127.0.0.1'], {
57
+ cwd: repoRoot,
58
+ env: { ...smokeEnv(dshHome), DSH_DESKTOP: '' },
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ })
61
+ let buffer = ''
62
+ const timer = setTimeout(() => {
63
+ child.kill()
64
+ reject(new Error(`timed out waiting for dsh web URL line; output:\n${buffer.slice(-4000)}`))
65
+ }, 120_000)
66
+ const onData = (chunk: Buffer): void => {
67
+ buffer += chunk.toString()
68
+ const match = /dsh web: http:\/\/127\.0\.0\.1:(\d+)/.exec(buffer)
69
+ if (match !== null) {
70
+ clearTimeout(timer)
71
+ child.stdout?.off('data', onData)
72
+ child.stderr?.off('data', onData)
73
+ resolve({ port: Number(match[1]) })
74
+ }
75
+ }
76
+ child.stdout?.on('data', onData)
77
+ child.stderr?.on('data', onData)
78
+ child.on('error', (error) => {
79
+ clearTimeout(timer)
80
+ reject(error)
81
+ })
82
+ afterAll(() => { try { child.kill() } catch { /* already gone */ } })
83
+ })
84
+ }
85
+
86
+ describe.skipIf(process.env.DSH_DESKTOP_PLUGIN_SMOKE !== '1' || !guard || !nodeOk)('dsh-plugin-capabilities smoke', () => {
87
+ afterAll(() => {
88
+ if (smokeRoot.startsWith(tmpdir()) && smokeRoot.includes('dsh-plugin-capabilities-smoke-')) {
89
+ rmSync(smokeRoot, { recursive: true, force: true })
90
+ }
91
+ })
92
+
93
+ it('installs, boots web, lists skills, writes a skill and an MCP row', { timeout: 240_000 }, async () => {
94
+ const env = smokeEnv(smokeRoot)
95
+
96
+ // 1. Install this package into profile "web".
97
+ const install = await dsh(['plugin', '--profile', 'web', 'add', `file:${pluginDir}`], env)
98
+ if (install.code !== 0) console.log('[smoke] FULL dsh output:\n' + install.out)
99
+ expect(install.code, install.out).toBe(0)
100
+
101
+ // 2. Boot `dsh web --port 0`.
102
+ const { port } = await bootWeb(smokeRoot)
103
+ const base = `http://127.0.0.1:${port}`
104
+ const origin = { Origin: base, 'Content-Type': 'application/json' }
105
+ const post = (path: string, body: unknown): Promise<Response> =>
106
+ fetch(base + path, { method: 'POST', headers: origin, body: JSON.stringify(body) })
107
+
108
+ // 3. Skills list serves the catalog with the editable flag.
109
+ const skillsResponse = await fetch(`${base}/dsh-plugin-capabilities/skills`)
110
+ expect(skillsResponse.status).toBe(200)
111
+ const skillsBody = await skillsResponse.json() as { skills?: Array<{ name: string; source: string; editable: boolean }> }
112
+ expect(Array.isArray(skillsBody.skills)).toBe(true)
113
+
114
+ // 4. Write a user skill; the watched root picks it up into the catalog.
115
+ const save = await post('/dsh-plugin-capabilities/skill/save', {
116
+ name: 'smoke-skill',
117
+ description: 'created by the capabilities smoke',
118
+ modelInvocable: true,
119
+ userInvocable: true,
120
+ content: 'Say smoke.',
121
+ })
122
+ expect(save.status).toBe(200)
123
+ const file = join(smokeRoot, 'skills', 'smoke-skill', 'SKILL.md')
124
+ expect(existsSync(file)).toBe(true)
125
+ expect(readFileSync(file, 'utf8')).toContain('name: smoke-skill')
126
+ const listed = await fetch(`${base}/dsh-plugin-capabilities/skills`).then(r => r.json()) as { skills: Array<{ name: string; editable: boolean }> }
127
+ // Watcher invalidation is asynchronous; poll briefly for the new skill.
128
+ const deadline = Date.now() + 15_000
129
+ let seen = listed.skills.some(skill => skill.name === 'smoke-skill' && skill.editable)
130
+ while (!seen && Date.now() < deadline) {
131
+ await new Promise(resolve => setTimeout(resolve, 1000))
132
+ const again = await fetch(`${base}/dsh-plugin-capabilities/skills`).then(r => r.json()) as { skills: Array<{ name: string; editable: boolean }> }
133
+ seen = again.skills.some(skill => skill.name === 'smoke-skill' && skill.editable)
134
+ }
135
+ expect(seen).toBe(true)
136
+
137
+ // 5. MCP row lands in the profile patch and lists back.
138
+ const mcpSave = await post('/dsh-plugin-capabilities/mcp/save', {
139
+ id: '',
140
+ serverName: 'smokeweb',
141
+ transport: 'streamable-http',
142
+ url: 'http://127.0.0.1:9/mcp',
143
+ })
144
+ expect(mcpSave.status).toBe(200)
145
+ const servers = await fetch(`${base}/dsh-plugin-capabilities/mcp`).then(r => r.json()) as { servers: Array<{ serverName: string }> }
146
+ expect(servers.servers.some(row => row.serverName === 'smokeweb')).toBe(true)
147
+ const patch = readFileSync(join(smokeRoot, 'profiles', 'web', 'cordis.patch.yml'), 'utf8')
148
+ expect(patch).toContain('@deepseek-ai/dsh-mcp-client')
149
+ })
150
+ })
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ /** Shared types across the capabilities manager modules. */
2
+
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+
5
+ /** The webServer service subset this plugin consumes (structural). */
6
+ export interface WebServerService {
7
+ register(route: {
8
+ kind: 'exact' | 'prefix'
9
+ path: string
10
+ handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>
11
+ }): () => void
12
+ }
13
+
14
+ /** One skill as the host registry reports it (SkillSummary subset). */
15
+ export interface HostSkill {
16
+ readonly name: string
17
+ readonly description: string
18
+ readonly whenToUse?: string
19
+ readonly invocation: { modelInvocable: boolean; userInvocable: boolean }
20
+ readonly source: string
21
+ readonly provider: string
22
+ }
23
+
24
+ /** The skills service subset this plugin consumes (structural). */
25
+ export interface SkillsService {
26
+ list(options?: { cwd?: string }): Promise<HostSkill[]>
27
+ get(name: string, options?: { cwd?: string }): Promise<{ name: string; content: string }>
28
+ }
29
+
30
+ /** Host context carrying both services this plugin injects. */
31
+ export interface CapabilitiesHost {
32
+ webServer: WebServerService
33
+ skills: SkillsService
34
+ }