dsh-plugin-capabilities 0.1.5 → 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.
Files changed (51) hide show
  1. package/README.md +16 -2
  2. package/lib/client.js +936 -220
  3. package/lib/client.js.map +4 -4
  4. package/lib/index.js +813 -38
  5. package/lib/index.js.map +4 -4
  6. package/market/mcp.json +164 -0
  7. package/market/skills.json +36 -0
  8. package/package.json +3 -1
  9. package/skills/find-skills/LICENSE +21 -0
  10. package/skills/find-skills/SKILL.md +141 -0
  11. package/skills/skill-creator/LICENSE.txt +202 -0
  12. package/skills/skill-creator/SKILL.md +485 -0
  13. package/skills/skill-creator/agents/analyzer.md +274 -0
  14. package/skills/skill-creator/agents/comparator.md +202 -0
  15. package/skills/skill-creator/agents/grader.md +223 -0
  16. package/skills/skill-creator/assets/eval_review.html +146 -0
  17. package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  18. package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  19. package/skills/skill-creator/references/schemas.md +430 -0
  20. package/skills/skill-creator/scripts/__init__.py +0 -0
  21. package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  22. package/skills/skill-creator/scripts/generate_report.py +326 -0
  23. package/skills/skill-creator/scripts/improve_description.py +247 -0
  24. package/skills/skill-creator/scripts/package_skill.py +136 -0
  25. package/skills/skill-creator/scripts/quick_validate.py +103 -0
  26. package/skills/skill-creator/scripts/run_eval.py +310 -0
  27. package/skills/skill-creator/scripts/run_loop.py +328 -0
  28. package/skills/skill-creator/scripts/utils.py +47 -0
  29. package/src/client/CapabilitiesSection.tsx +11 -7
  30. package/src/client/MarketTab.tsx +263 -0
  31. package/src/client/McpTab.tsx +198 -0
  32. package/src/client/SkillsTab.tsx +261 -26
  33. package/src/client/css.ts +42 -0
  34. package/src/client/index.ts +58 -5
  35. package/src/client/locales.ts +112 -2
  36. package/src/index.ts +76 -20
  37. package/src/market.test.ts +57 -0
  38. package/src/market.ts +166 -0
  39. package/src/opener.ts +26 -0
  40. package/src/packaged-skills.test.ts +40 -0
  41. package/src/repos.test.ts +213 -0
  42. package/src/repos.ts +207 -0
  43. package/src/routes.ts +336 -6
  44. package/src/skills.test.ts +45 -1
  45. package/src/skills.ts +22 -1
  46. package/src/smoke.test.ts +61 -2
  47. package/src/state.test.ts +64 -0
  48. package/src/state.ts +109 -0
  49. package/src/tar.test.ts +100 -0
  50. package/src/tar.ts +154 -0
  51. package/src/types.ts +11 -1
package/src/state.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Plugin-owned state under `$DSH_HOME/dsh-plugin-capabilities/`: the custom
3
+ * skill repositories the user registered from the Settings page. Everything
4
+ * the provider mounts live (local paths, extracted GitHub checkouts) hangs
5
+ * off this file, so it stays small, JSON, and hand-recoverable.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { randomBytes } from 'node:crypto'
12
+
13
+ /** One user-registered custom skill repository. */
14
+ export interface SkillRootEntry {
15
+ /** Stable id (used by the remove route and directory names). */
16
+ id: string
17
+ /** `local` — a path on this machine; `git` — extracted from a GitHub URL. */
18
+ kind: 'local' | 'git'
19
+ /** Display label (repo `owner/name` or the local path's basename). */
20
+ label: string
21
+ /** The source URL (git entries). */
22
+ url?: string
23
+ /** Branch/tag/ref the tarball was pulled from (git entries). */
24
+ ref?: string
25
+ /** Local path the user pointed at (local entries). */
26
+ path?: string
27
+ /** Scan roots this entry contributes (single-skill repos get a wrapper dir). */
28
+ roots: string[]
29
+ /** Where this entry's downloaded/junctioned material lives, if any. */
30
+ materialDir?: string
31
+ addedAt: number
32
+ }
33
+
34
+ /** On-disk state document. */
35
+ export interface PluginState {
36
+ skillRoots: SkillRootEntry[]
37
+ }
38
+
39
+ /** The plugin's own directory under DSH_HOME (default `~/.dsh`). */
40
+ export function pluginStateDir(dshHome: string | undefined = process.env.DSH_HOME): string {
41
+ return join(dshHome ?? join(homedir(), '.dsh'), 'dsh-plugin-capabilities')
42
+ }
43
+
44
+ function statePath(dshHome?: string): string {
45
+ return join(pluginStateDir(dshHome), 'state.json')
46
+ }
47
+
48
+ function emptyState(): PluginState {
49
+ return { skillRoots: [] }
50
+ }
51
+
52
+ /** Load the state document; missing/corrupt files yield empty state. */
53
+ export function loadState(dshHome?: string): PluginState {
54
+ const path = statePath(dshHome)
55
+ if (!existsSync(path)) return emptyState()
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<PluginState>
58
+ if (!Array.isArray(parsed.skillRoots)) return emptyState()
59
+ const roots = parsed.skillRoots.filter((entry): entry is SkillRootEntry =>
60
+ typeof entry === 'object' && entry !== null && typeof entry.id === 'string' && Array.isArray(entry.roots))
61
+ return { skillRoots: roots }
62
+ } catch {
63
+ return emptyState()
64
+ }
65
+ }
66
+
67
+ /** Persist the state document (atomic-enough for a single-writer UI). */
68
+ export function saveState(state: PluginState, dshHome?: string): void {
69
+ const dir = pluginStateDir(dshHome)
70
+ mkdirSync(dir, { recursive: true })
71
+ writeFileSync(statePath(dshHome), JSON.stringify(state, null, 2) + '\n', 'utf8')
72
+ }
73
+
74
+ /** New unique entry id. */
75
+ export function newEntryId(kind: 'local' | 'git'): string {
76
+ return `${kind}-${randomBytes(4).toString('hex')}`
77
+ }
78
+
79
+ /** One entry's downloaded material (git extraction, junction wrappers). */
80
+ export function materialDirFor(entryId: string, dshHome?: string): string {
81
+ return join(pluginStateDir(dshHome), 'repos', entryId)
82
+ }
83
+
84
+ /** Append one entry and persist. Returns the stored entry. */
85
+ export function addSkillRoot(entry: Omit<SkillRootEntry, 'addedAt'>, dshHome?: string): SkillRootEntry {
86
+ const state = loadState(dshHome)
87
+ const stored: SkillRootEntry = { ...entry, addedAt: Date.now() }
88
+ state.skillRoots.push(stored)
89
+ saveState(state, dshHome)
90
+ return stored
91
+ }
92
+
93
+ /** Remove one entry by id and delete its material dir. Returns false when absent. */
94
+ export function removeSkillRoot(id: string, dshHome?: string): boolean {
95
+ const state = loadState(dshHome)
96
+ const at = state.skillRoots.findIndex(entry => entry.id === id)
97
+ if (at === -1) return false
98
+ const [removed] = state.skillRoots.splice(at, 1)
99
+ saveState(state, dshHome)
100
+ if (removed.materialDir !== undefined) {
101
+ rmSync(removed.materialDir, { recursive: true, force: true, maxRetries: 2 })
102
+ }
103
+ return true
104
+ }
105
+
106
+ /** Whether a git URL is already registered (market “installed” state). */
107
+ export function findRootByUrl(url: string, dshHome?: string): SkillRootEntry | undefined {
108
+ return loadState(dshHome).skillRoots.find(entry => entry.url === url)
109
+ }
@@ -0,0 +1,100 @@
1
+ import { gzipSync } from 'node:zlib'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterAll, describe, expect, it } from 'vitest'
6
+ import { extractTarGz } from './tar.ts'
7
+
8
+ const root = mkdtempSync(join(tmpdir(), 'dsh-caps-tar-'))
9
+ afterAll(() => rmSync(root, { recursive: true, force: true }))
10
+
11
+ /** One USTAR entry (the shape GitHub codeload emits); body null = directory. */
12
+ function tarEntry(name: string, body: Buffer | null, type = body === null ? '5' : '0'): Buffer {
13
+ const header = Buffer.alloc(512)
14
+ const size = body === null ? 0 : body.length
15
+ header.write(name, 0, 'utf8')
16
+ header.write('0000644\0', 100, 'utf8')
17
+ header.write('0000000\0', 108, 'utf8')
18
+ header.write('0000000\0', 116, 'utf8')
19
+ header.write(size.toString(8).padStart(11, '0') + '\0', 124, 'utf8')
20
+ header.write('00000000000\0', 136, 'utf8')
21
+ header.write(' ', 148, 'utf8')
22
+ header.write(type, 156, 'utf8')
23
+ header.write('ustar\0', 257, 'utf8')
24
+ header.write('00', 263, 'utf8')
25
+ let sum = 0
26
+ for (const byte of header) sum += byte
27
+ header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'utf8')
28
+ const pad = (512 - (size % 512)) % 512
29
+ return Buffer.concat([header, body === null ? Buffer.alloc(0) : body, Buffer.alloc(pad)])
30
+ }
31
+
32
+ function tarGz(entries: Buffer[]): Buffer {
33
+ return gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]))
34
+ }
35
+
36
+ describe('extractTarGz', () => {
37
+ it('extracts files and directories with stripComponents', () => {
38
+ const target = join(root, 'basic')
39
+ const archive = tarGz([
40
+ tarEntry('owner-repo-abc123/', null),
41
+ tarEntry('owner-repo-abc123/SKILL.md', Buffer.from('---\nname: x\n---\n')),
42
+ tarEntry('owner-repo-abc123/docs/', null),
43
+ tarEntry('owner-repo-abc123/docs/a.md', Buffer.from('hello')),
44
+ ])
45
+ const written = extractTarGz(archive, target, { stripComponents: 1 })
46
+ expect(written).toBe(4)
47
+ expect(readFileSync(join(target, 'SKILL.md'), 'utf8')).toContain('name: x')
48
+ expect(readFileSync(join(target, 'docs', 'a.md'), 'utf8')).toBe('hello')
49
+ })
50
+
51
+ it('rejects entries that escape the target directory', () => {
52
+ const target = join(root, 'escape')
53
+ mkdirSync(target, { recursive: true })
54
+ const guard = join(root, 'escape-guard.txt')
55
+ writeFileSync(guard, 'keep')
56
+ const archive = tarGz([tarEntry('../../escape-guard.txt', Buffer.from('pwn'))])
57
+ expect(() => extractTarGz(archive, target)).toThrow(/unsafe/)
58
+ expect(readFileSync(guard, 'utf8')).toBe('keep')
59
+ })
60
+
61
+ it('rejects absolute paths', () => {
62
+ const archive = tarGz([tarEntry('/etc/pwned', Buffer.from('x'))])
63
+ expect(() => extractTarGz(archive, join(root, 'absolute'))).toThrow(/unsafe/)
64
+ })
65
+
66
+ it('handles a long name delivered via the GNU L entry', () => {
67
+ const target = join(root, 'longname')
68
+ const long = `deep/${'a'.repeat(120)}/SKILL.md`
69
+ const archive = tarGz([
70
+ tarEntry('././@LongLink', Buffer.from(`${long}\0`), 'L'),
71
+ tarEntry('placeholder', Buffer.from('---\nname: long\n---\n')),
72
+ ])
73
+ extractTarGz(archive, target, { stripComponents: 1 })
74
+ expect(readFileSync(join(target, 'a'.repeat(120), 'SKILL.md'), 'utf8')).toContain('long')
75
+ })
76
+
77
+ it('skips symlink entries without writing them', () => {
78
+ const target = join(root, 'links')
79
+ const archive = tarGz([
80
+ tarEntry('pkg/', null),
81
+ tarEntry('pkg/link', null, '2'),
82
+ ])
83
+ extractTarGz(archive, target, { stripComponents: 1 })
84
+ expect(existsSync(join(target, 'link'))).toBe(false)
85
+ })
86
+
87
+ it('rejects a truncated archive', () => {
88
+ const archive = tarGz([tarEntry('pkg/SKILL.md', Buffer.from('x'.repeat(600)))])
89
+ const cut = archive.subarray(0, archive.length - 700)
90
+ expect(() => extractTarGz(cut, join(root, 'truncated'))).toThrow(/truncated|too large|end of file/)
91
+ })
92
+
93
+ it('handles multi-block file bodies', () => {
94
+ const target = join(root, 'big')
95
+ const body = Buffer.from('y'.repeat(1025))
96
+ const archive = tarGz([tarEntry('pkg/data.md', body)])
97
+ extractTarGz(archive, target, { stripComponents: 1 })
98
+ expect(readFileSync(join(target, 'data.md'), 'utf8')).toHaveLength(1025)
99
+ })
100
+ })
package/src/tar.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Minimal pure-JS tar.gz extraction (node zlib + a USTAR/GNU/PAX-path
3
+ * reader). GitHub codeload tarballs are all we ever unpack — regular files
4
+ * and directories, no links — so unsupported entry types are skipped rather
5
+ * than rejected, and every name is checked against escape before it touches
6
+ * the filesystem. No subprocess: the dsh sidecar must not spawn tar.
7
+ */
8
+
9
+ import { gunzipSync } from 'node:zlib'
10
+ import { mkdirSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+
13
+ /** Guard rails for untrusted archives. */
14
+ const LIMITS = {
15
+ /** Per-entry cap (a skill repo holds markdown and small assets). */
16
+ entryBytes: 64 * 1024 * 1024,
17
+ /** Whole-archive content cap. */
18
+ totalBytes: 256 * 1024 * 1024,
19
+ /** Entry count cap (also bounds loop time on corrupt input). */
20
+ entries: 20_000,
21
+ } as const
22
+
23
+ function octal(block: Buffer, offset: number, length: number): number {
24
+ const raw = block.toString('utf8', offset, offset + length).replace(/[\0 ]+$/, '')
25
+ return raw === '' ? 0 : Number.parseInt(raw, 8)
26
+ }
27
+
28
+ function field(block: Buffer, offset: number, length: number): string {
29
+ const at = block.indexOf(0, offset)
30
+ return block.toString('utf8', offset, Math.min(at === -1 ? offset + length : at, offset + length))
31
+ }
32
+
33
+ /** One parsed tar header. */
34
+ interface TarHeader {
35
+ name: string
36
+ size: number
37
+ type: string
38
+ }
39
+
40
+ function readHeader(block: Buffer): TarHeader | null {
41
+ // A zeroed block ends the archive (the checksum of an empty block is 0).
42
+ if (block.every(byte => byte === 0)) return null
43
+ const checksum = octal(block, 148, 8)
44
+ let sum = 0
45
+ for (let at = 0; at < 512; at++) sum += at >= 148 && at < 156 ? 32 : block[at]
46
+ if (sum !== checksum) return null
47
+ const magic = block.toString('utf8', 257, 257 + 6)
48
+ let name = field(block, 0, 100)
49
+ if (magic.startsWith('ustar')) {
50
+ const prefix = field(block, 345, 155)
51
+ if (prefix !== '') name = `${prefix}/${name}`
52
+ }
53
+ return { name, size: octal(block, 124, 12), type: block.toString('utf8', 156, 157) }
54
+ }
55
+
56
+ /** Resolve one entry name under the target, rejecting escapes. */
57
+ function safeJoin(target: string, name: string): string | null {
58
+ const normalized = name.replace(/\\/g, '/')
59
+ if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) return null
60
+ const parts: string[] = []
61
+ for (const part of normalized.split('/')) {
62
+ if (part === '' || part === '.') continue
63
+ if (part === '..') return null
64
+ parts.push(part)
65
+ }
66
+ if (parts.length === 0) return null
67
+ return join(target, ...parts)
68
+ }
69
+
70
+ /** PAX extended header records (`<len> key=value\n` lines) → key/value map. */
71
+ function paxRecords(content: Buffer): Map<string, string> {
72
+ const out = new Map<string, string>()
73
+ let cursor = 0
74
+ while (cursor < content.length) {
75
+ const spaceAt = content.indexOf(' ', cursor)
76
+ if (spaceAt === -1) break
77
+ const length = Number.parseInt(content.toString('utf8', cursor, spaceAt), 10)
78
+ if (!Number.isInteger(length) || length <= 0 || cursor + length > content.length) break
79
+ const record = content.toString('utf8', spaceAt + 1, cursor + length).trimEnd()
80
+ const eq = record.indexOf('=')
81
+ if (eq > 0) out.set(record.slice(0, eq), record.slice(eq + 1))
82
+ cursor += length
83
+ }
84
+ return out
85
+ }
86
+
87
+ /** Number of leading path components to drop (GitHub tarballs wrap one). */
88
+ export interface ExtractOptions {
89
+ stripComponents?: number
90
+ }
91
+
92
+ /**
93
+ * Extract a gzip'd tar buffer into `target` (created when absent). Returns
94
+ * the entry count written. Throws on structure errors and oversize archives.
95
+ */
96
+ export function extractTarGz(archive: Buffer, target: string, options: ExtractOptions = {}): number {
97
+ const tar = gunzipSync(archive)
98
+ const strip = options.stripComponents ?? 0
99
+ let offset = 0
100
+ let written = 0
101
+ let total = 0
102
+ let pendingLongName: string | undefined
103
+ let pendingPath: string | undefined
104
+ while (offset + 512 <= tar.length) {
105
+ const header = readHeader(tar.subarray(offset, offset + 512))
106
+ if (header === null) break
107
+ offset += 512
108
+ const contentEnd = offset + header.size
109
+ if (contentEnd > tar.length) throw new Error('truncated tar entry')
110
+ const content = tar.subarray(offset, contentEnd)
111
+ offset += Math.ceil(header.size / 512) * 512
112
+ if (header.size > LIMITS.entryBytes) throw new Error('tar entry too large')
113
+ total += header.size
114
+ if (total > LIMITS.totalBytes) throw new Error('tar archive too large')
115
+ if (++written > LIMITS.entries) throw new Error('too many tar entries')
116
+
117
+ // Metadata entries carry the name for the entry that follows.
118
+ if (header.type === 'L') {
119
+ pendingLongName = field(content, 0, content.length)
120
+ continue
121
+ }
122
+ if (header.type === 'x' || header.type === 'X') {
123
+ pendingPath = paxRecords(content).get('path')
124
+ continue
125
+ }
126
+ if (header.type === 'g') continue // global pax: nothing we need
127
+
128
+ let name = pendingLongName ?? pendingPath ?? header.name
129
+ pendingLongName = undefined
130
+ pendingPath = undefined
131
+
132
+ const parts = name.split('/')
133
+ if (parts.length <= strip) continue // nothing left after stripping
134
+ name = parts.slice(strip).join('/')
135
+ // The stripped top-level directory itself ("pkg/" → "") owns nothing.
136
+ if (name === '' || name === '/') continue
137
+
138
+ // Directories: type '5' or a trailing slash. Symlinks/hardlinks/other
139
+ // types are skipped — skill repos that depend on them degrade loudly
140
+ // elsewhere instead of planting links from untrusted input.
141
+ const isDir = header.type === '5' || (header.type === '0' || header.type === '\0') && name.endsWith('/')
142
+ if (header.type !== '0' && header.type !== '\0' && header.type !== '5') continue
143
+
144
+ const resolved = safeJoin(target, name)
145
+ if (resolved === null) throw new Error(`unsafe tar entry name: ${name}`)
146
+ if (isDir) {
147
+ mkdirSync(resolved, { recursive: true })
148
+ continue
149
+ }
150
+ mkdirSync(dirname(resolved), { recursive: true })
151
+ writeFileSync(resolved, content)
152
+ }
153
+ return written
154
+ }
package/src/types.ts CHANGED
@@ -19,12 +19,22 @@ export interface HostSkill {
19
19
  readonly invocation: { modelInvocable: boolean; userInvocable: boolean }
20
20
  readonly source: string
21
21
  readonly provider: string
22
+ /** Provider-specific base (directory skills carry their folder here). */
23
+ readonly resourceBase?: { kind: 'directory'; path: string } | { kind: 'url'; url: string } | { kind: 'opaque'; description: string }
24
+ }
25
+
26
+ /** Loaded skill definition subset the routes consume. */
27
+ export interface HostSkillDefinition {
28
+ readonly name: string
29
+ readonly content: string
30
+ readonly path?: string
31
+ readonly resourceBase?: HostSkill['resourceBase']
22
32
  }
23
33
 
24
34
  /** The skills service subset this plugin consumes (structural). */
25
35
  export interface SkillsService {
26
36
  list(options?: { cwd?: string }): Promise<HostSkill[]>
27
- get(name: string, options?: { cwd?: string }): Promise<{ name: string; content: string }>
37
+ get(name: string, options?: { cwd?: string }): Promise<HostSkillDefinition | undefined>
28
38
  }
29
39
 
30
40
  /** Host context carrying both services this plugin injects. */