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.
@@ -0,0 +1,213 @@
1
+ import { gzipSync } from 'node:zlib'
2
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } 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 { addGitRepo, addLocalRepo, detectSkillRoots, parseGitHubSource } from './repos.ts'
7
+ import { loadState, removeSkillRoot } from './state.ts'
8
+
9
+ const root = mkdtempSync(join(tmpdir(), 'dsh-caps-repos-'))
10
+ afterAll(() => rmSync(root, { recursive: true, force: true }))
11
+
12
+ const skill = (name: string): string => `---\nname: ${name}\ndescription: d ${name}\n---\n\nbody\n`
13
+
14
+ /** Tar a staging directory (with one top-level component) like codeload. */
15
+ function tarballOf(staging: string): Buffer {
16
+ const files: Array<{ name: string; body: Buffer; dir: boolean }> = []
17
+ const walk = (dir: string, prefix: string): void => {
18
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
19
+ const child = join(dir, entry.name)
20
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`
21
+ if (entry.isDirectory()) {
22
+ files.push({ name: `${rel}/`, body: Buffer.alloc(0), dir: true })
23
+ walk(child, rel)
24
+ } else {
25
+ files.push({ name: rel, body: readFileSync(child), dir: false })
26
+ }
27
+ }
28
+ }
29
+ walk(staging, '')
30
+ const blocks: Buffer[] = []
31
+ for (const entry of files) {
32
+ const header = Buffer.alloc(512)
33
+ header.write(entry.name, 0, 'utf8')
34
+ header.write('0000644\0', 100, 'utf8')
35
+ header.write('0000000\0', 108, 'utf8')
36
+ header.write('0000000\0', 116, 'utf8')
37
+ header.write(entry.body.length.toString(8).padStart(11, '0') + '\0', 124, 'utf8')
38
+ header.write('00000000000\0', 136, 'utf8')
39
+ header.write(' ', 148, 'utf8')
40
+ header.write(entry.dir ? '5' : '0', 156, 'utf8')
41
+ header.write('ustar\0', 257, 'utf8')
42
+ header.write('00', 263, 'utf8')
43
+ let sum = 0
44
+ for (const byte of header) sum += byte
45
+ header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'utf8')
46
+ const pad = (512 - (entry.body.length % 512)) % 512
47
+ blocks.push(Buffer.concat([header, entry.body, Buffer.alloc(pad)]))
48
+ }
49
+ blocks.push(Buffer.alloc(1024))
50
+ return gzipSync(Buffer.concat(blocks))
51
+ }
52
+
53
+ describe('parseGitHubSource', () => {
54
+ it('accepts the common URL shapes', () => {
55
+ expect(parseGitHubSource('https://github.com/anthropics/skills')).toMatchObject({ owner: 'anthropics', repo: 'skills', label: 'anthropics/skills' })
56
+ expect(parseGitHubSource('https://github.com/anthropics/skills.git')).toMatchObject({ repo: 'skills' })
57
+ expect(parseGitHubSource('anthropics/skills')).toMatchObject({ owner: 'anthropics' })
58
+ expect(parseGitHubSource('https://github.com/anthropics/skills/tree/v2')).toMatchObject({ ref: 'v2' })
59
+ expect(parseGitHubSource('https://github.com/anthropics/skills#dev')).toMatchObject({ ref: 'dev' })
60
+ expect(parseGitHubSource('https://github.com/anthropics/skills/tree/v2')?.tarballUrl).toBe('https://codeload.github.com/anthropics/skills/tar.gz/v2')
61
+ expect(parseGitHubSource('https://github.com/anthropics/skills')?.tarballUrl).toBe('https://codeload.github.com/anthropics/skills/tar.gz/HEAD')
62
+ })
63
+ it('rejects non-GitHub input', () => {
64
+ expect(parseGitHubSource('https://gitlab.com/a/b')).toBeNull()
65
+ expect(parseGitHubSource('not a url')).toBeNull()
66
+ expect(parseGitHubSource('')).toBeNull()
67
+ })
68
+ })
69
+
70
+ describe('detectSkillRoots', () => {
71
+ it('detects a single skill at the checkout root', () => {
72
+ const dir = join(root, 'single')
73
+ mkdirSync(dir, { recursive: true })
74
+ writeFileSync(join(dir, 'SKILL.md'), skill('single'))
75
+ expect(detectSkillRoots(dir)).toEqual({ roots: [], single: true })
76
+ })
77
+
78
+ it('detects skills as immediate children', () => {
79
+ const dir = join(root, 'collection')
80
+ mkdirSync(join(dir, 'one'), { recursive: true })
81
+ writeFileSync(join(dir, 'one', 'SKILL.md'), skill('one'))
82
+ expect(detectSkillRoots(dir)).toEqual({ roots: [dir], single: false })
83
+ })
84
+
85
+ it('detects a nested skills/ wrapper and mixed layouts', () => {
86
+ const dir = join(root, 'nested')
87
+ mkdirSync(join(dir, 'skills', 'alpha'), { recursive: true })
88
+ writeFileSync(join(dir, 'skills', 'alpha', 'SKILL.md'), skill('alpha'))
89
+ mkdirSync(join(dir, 'top'), { recursive: true })
90
+ writeFileSync(join(dir, 'top', 'SKILL.md'), skill('top'))
91
+ writeFileSync(join(dir, 'README.md'), 'not a skill')
92
+ const detected = detectSkillRoots(dir)
93
+ expect(detected.single).toBe(false)
94
+ // top/ is discovered through the checkout root; skills/ registers on its own.
95
+ expect(detected.roots).toContain(dir)
96
+ expect(detected.roots).toContain(join(dir, 'skills'))
97
+ })
98
+
99
+ it('returns empty for a repo without skills', () => {
100
+ const dir = join(root, 'empty')
101
+ mkdirSync(join(dir, 'src'), { recursive: true })
102
+ writeFileSync(join(dir, 'src', 'main.ts'), 'x')
103
+ expect(detectSkillRoots(dir)).toEqual({ roots: [], single: false })
104
+ })
105
+ })
106
+
107
+ describe('addLocalRepo', () => {
108
+ it('registers a collection folder in place', async () => {
109
+ const home = join(root, 'home-local')
110
+ const source = join(root, 'local-collection')
111
+ mkdirSync(join(source, 'greet'), { recursive: true })
112
+ writeFileSync(join(source, 'greet', 'SKILL.md'), skill('greet'))
113
+ const entry = await addLocalRepo(source, home)
114
+ expect(entry.kind).toBe('local')
115
+ expect(entry.roots).toEqual([source])
116
+ expect(loadState(home).skillRoots).toHaveLength(1)
117
+ expect(existsSync(join(home, 'dsh-plugin-capabilities', 'state.json'))).toBe(true)
118
+ })
119
+
120
+ it('wraps a single-skill folder in a junction material dir', async () => {
121
+ const home = join(root, 'home-single')
122
+ const source = join(root, 'local-single')
123
+ mkdirSync(source, { recursive: true })
124
+ writeFileSync(join(source, 'SKILL.md'), skill('solo'))
125
+ const entry = await addLocalRepo(source, home)
126
+ expect(entry.roots).toHaveLength(1)
127
+ // The scan root is the material dir; its single child is a link to source.
128
+ const children = readdirSync(entry.roots[0])
129
+ expect(children).toHaveLength(1)
130
+ expect(readFileSync(join(entry.roots[0], children[0], 'SKILL.md'), 'utf8')).toContain('solo')
131
+
132
+ // Removing the entry deletes the material dir but never the source.
133
+ expect(removeSkillRoot(entry.id, home)).toBe(true)
134
+ expect(existsSync(entry.roots[0])).toBe(false)
135
+ expect(existsSync(join(source, 'SKILL.md'))).toBe(true)
136
+ })
137
+
138
+ it('rejects a path without skills', async () => {
139
+ const source = join(root, 'no-skills')
140
+ mkdirSync(source, { recursive: true })
141
+ writeFileSync(join(source, 'notes.txt'), 'x')
142
+ await expect(addLocalRepo(source, join(root, 'home-noskills'))).rejects.toThrow(/no SKILL\.md/)
143
+ })
144
+ })
145
+
146
+ describe('addGitRepo', () => {
147
+ /** Fake codeload: serves a tarball built from a callback-defined checkout. */
148
+ const fakeFetcher = (build: (checkout: string) => void): typeof fetch => {
149
+ return (async () => {
150
+ const staging = join(root, `staging-${Math.random().toString(36).slice(2)}`)
151
+ mkdirSync(join(staging, 'owner-repo-sha'), { recursive: true })
152
+ build(join(staging, 'owner-repo-sha'))
153
+ const archive = tarballOf(staging)
154
+ rmSync(staging, { recursive: true, force: true })
155
+ return {
156
+ ok: true,
157
+ arrayBuffer: async () => archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength),
158
+ } as unknown as Response
159
+ }) as unknown as typeof fetch
160
+ }
161
+
162
+ it('downloads, extracts, detects, and persists a collection repo', async () => {
163
+ const home = join(root, 'home-git')
164
+ const fetcher = fakeFetcher((checkout) => {
165
+ mkdirSync(join(checkout, 'brainstorming'), { recursive: true })
166
+ writeFileSync(join(checkout, 'brainstorming', 'SKILL.md'), skill('brainstorming'))
167
+ })
168
+ const entry = await addGitRepo('https://github.com/obra/superpowers', { dshHome: home, fetcher })
169
+ expect(entry.kind).toBe('git')
170
+ expect(entry.label).toBe('obra/superpowers')
171
+ expect(entry.roots).toHaveLength(1)
172
+ expect(readFileSync(join(entry.roots[0], 'brainstorming', 'SKILL.md'), 'utf8')).toContain('brainstorming')
173
+ expect(loadState(home).skillRoots.map(row => row.label)).toContain('obra/superpowers')
174
+ })
175
+
176
+ it('registers a single-skill repo through the material dir', async () => {
177
+ const home = join(root, 'home-git-single')
178
+ const fetcher = fakeFetcher((checkout) => {
179
+ writeFileSync(join(checkout, 'SKILL.md'), skill('solo-repo'))
180
+ })
181
+ const entry = await addGitRepo('someone/solorepo', { dshHome: home, fetcher })
182
+ expect(entry.roots).toHaveLength(1)
183
+ // The material dir is the scan root; the checkout is its one child.
184
+ const children = readdirSync(entry.roots[0])
185
+ expect(children).toEqual(['repo'])
186
+ expect(readFileSync(join(entry.roots[0], 'repo', 'SKILL.md'), 'utf8')).toContain('solo-repo')
187
+ })
188
+
189
+ it('rejects re-registering the same repo', async () => {
190
+ const home = join(root, 'home-git-dup')
191
+ const fetcher = fakeFetcher((checkout) => {
192
+ mkdirSync(join(checkout, 'a'), { recursive: true })
193
+ writeFileSync(join(checkout, 'a', 'SKILL.md'), skill('a'))
194
+ })
195
+ await addGitRepo('anthropics/skills', { dshHome: home, fetcher })
196
+ await expect(addGitRepo('https://github.com/anthropics/skills', { dshHome: home, fetcher })).rejects.toThrow(/already registered/)
197
+ })
198
+
199
+ it('fails cleanly when the repo holds no skills', async () => {
200
+ const home = join(root, 'home-git-empty')
201
+ const fetcher = fakeFetcher((checkout) => {
202
+ writeFileSync(join(checkout, 'README.md'), 'no skills here')
203
+ })
204
+ await expect(addGitRepo('someone/noskills', { dshHome: home, fetcher })).rejects.toThrow(/no SKILL\.md/)
205
+ // The failed attempt leaves no entry behind.
206
+ expect(loadState(home).skillRoots).toHaveLength(0)
207
+ })
208
+
209
+ it('propagates download failures', async () => {
210
+ const failing = (async () => ({ ok: false, status: 404 })) as unknown as typeof fetch
211
+ await expect(addGitRepo('a/missing', { dshHome: join(root, 'home-git-404'), fetcher: failing })).rejects.toThrow(/HTTP 404/)
212
+ })
213
+ })
package/src/repos.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Custom skill repositories the user registers from the Settings page:
3
+ * a local directory (scanned in place, zero-copy) or a GitHub repo (tarball
4
+ * download into the plugin's state dir, no git dependency). Detection adapts
5
+ * to the three layouts skill collections actually use — one skill at the
6
+ * repo root, skills as immediate children, or a nested `skills/`-style
7
+ * folder — and registers the matching scan roots for the filesystem
8
+ * provider.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync } from 'node:fs'
12
+ import { basename, join, resolve } from 'node:path'
13
+ import { addSkillRoot, findRootByUrl, materialDirFor, newEntryId, type SkillRootEntry } from './state.ts'
14
+ import { extractTarGz } from './tar.ts'
15
+
16
+ /** One GitHub source, parsed from whatever URL shape the user pasted. */
17
+ export interface GitHubSource {
18
+ owner: string
19
+ repo: string
20
+ ref?: string
21
+ /** `owner/repo`, the display label and dedupe key. */
22
+ label: string
23
+ tarballUrl: string
24
+ }
25
+
26
+ const GITHUB_URL_RE = /^(?:https?:\/\/)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:\/(?:tree|archive)\/([^/#?]+?)(?:\.tar\.gz)?)?(?:#([^/?#]+))?(?:[/?#].*)?$/
27
+ const GITHUB_SHORT_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/
28
+
29
+ /** Parse a GitHub reference; returns null for anything unrecognizable. */
30
+ export function parseGitHubSource(input: string): GitHubSource | null {
31
+ const trimmed = input.trim()
32
+ if (trimmed === '') return null
33
+ let owner = ''
34
+ let repo = ''
35
+ let ref: string | undefined
36
+ let match = GITHUB_URL_RE.exec(trimmed)
37
+ if (match !== null) {
38
+ // Capture order: owner, repo, /tree/<ref>, #<ref>.
39
+ const treeRef = match[3] !== undefined ? match[3] : undefined
40
+ const hashRef = match[4] !== undefined ? match[4] : undefined
41
+ owner = match[1]
42
+ repo = match[2]
43
+ ref = treeRef ?? hashRef
44
+ } else {
45
+ match = GITHUB_SHORT_RE.exec(trimmed)
46
+ if (match === null) return null
47
+ ;[, owner, repo] = match
48
+ ref = undefined
49
+ }
50
+ // `owner/repo/tree/<ref>` may URL-encode slashes in the ref — keep as-is;
51
+ // codeload addresses refs by name, branches with slashes included.
52
+ const decodedRef = ref !== undefined ? decodeURIComponent(ref) : undefined
53
+ const label = `${owner}/${repo}`
54
+ const tarballUrl = `https://codeload.github.com/${owner}/${repo}/tar.gz/${decodedRef ?? 'HEAD'}`
55
+ return { owner, repo, ref: decodedRef, label, tarballUrl }
56
+ }
57
+
58
+ /** Where skill layouts were found inside one checkout. */
59
+ export interface DetectedRoots {
60
+ /** Scan roots to register (empty + single=false means “no skills”). */
61
+ roots: string[]
62
+ /** The checkout itself is one skill (SKILL.md at its root). */
63
+ single: boolean
64
+ }
65
+
66
+ /** A plausible flat skill file: markdown with frontmatter. */
67
+ function looksLikeSkillFile(file: string): boolean {
68
+ try {
69
+ return readFileSync(file, 'utf8').startsWith('---')
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ /** Does one directory directly hold skills (bundle children or flat .md)? */
76
+ function holdsSkills(dir: string): boolean {
77
+ let entries: string[]
78
+ try {
79
+ entries = readdirSync(dir)
80
+ } catch {
81
+ return false
82
+ }
83
+ if (existsSync(join(dir, 'SKILL.md'))) return true
84
+ for (const name of entries) {
85
+ if (name.endsWith('.md') && looksLikeSkillFile(join(dir, name))) return true
86
+ }
87
+ for (const name of entries) {
88
+ const child = join(dir, name)
89
+ try {
90
+ if (statSync(child).isDirectory() && existsSync(join(child, 'SKILL.md'))) return true
91
+ } catch {
92
+ // racing deletion or unreadable entry: ignore
93
+ }
94
+ }
95
+ return false
96
+ }
97
+
98
+ /**
99
+ * Find the scan roots for one checkout: the checkout itself when its
100
+ * immediate children are skills, plus any child directory that only makes
101
+ * sense as a collection wrapper (`skills/`, `document-skills/`, …). A
102
+ * checkout with SKILL.md at its own root is one single skill — the caller
103
+ * must give the provider its PARENT as the scan root.
104
+ */
105
+ export function detectSkillRoots(checkout: string): DetectedRoots {
106
+ if (existsSync(join(checkout, 'SKILL.md'))) return { roots: [], single: true }
107
+ const roots: string[] = []
108
+ if (holdsSkills(checkout)) roots.push(checkout)
109
+ let entries: string[]
110
+ try {
111
+ entries = readdirSync(checkout)
112
+ } catch {
113
+ return { roots, single: false }
114
+ }
115
+ for (const name of entries) {
116
+ if (name.startsWith('.')) continue
117
+ const child = join(checkout, name)
118
+ try {
119
+ if (!statSync(child).isDirectory()) continue
120
+ } catch {
121
+ continue
122
+ }
123
+ if (existsSync(join(child, 'SKILL.md'))) continue // already covered by the parent root
124
+ if (holdsSkills(child)) roots.push(child)
125
+ }
126
+ return { roots, single: false }
127
+ }
128
+
129
+ /** Register a local directory as a skill repository. */
130
+ export async function addLocalRepo(path: string, dshHome?: string): Promise<SkillRootEntry> {
131
+ const resolved = resolve(path.trim().replace(/^"|"$/g, ''))
132
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
133
+ throw new Error(`not a directory: ${resolved}`)
134
+ }
135
+ const detected = detectSkillRoots(resolved)
136
+ if (!detected.single && detected.roots.length === 0) {
137
+ throw new Error('no SKILL.md found under that path (expected a skill folder or a folder of skill folders)')
138
+ }
139
+ if (detected.single) {
140
+ // One skill living at the given path: the provider scans a ROOT, so wrap
141
+ // the path in a dedicated directory holding a single link to it.
142
+ const id = newEntryId('local')
143
+ const material = materialDirFor(id, dshHome)
144
+ rmSync(material, { recursive: true, force: true })
145
+ mkdirSync(material, { recursive: true })
146
+ try {
147
+ symlinkSync(resolved, join(material, 'skill'), process.platform === 'win32' ? 'junction' : 'dir')
148
+ } catch {
149
+ rmSync(material, { recursive: true, force: true })
150
+ throw new Error('single-skill local folders need a directory link; try adding their parent folder instead')
151
+ }
152
+ return addSkillRoot({ id, kind: 'local', label: basename(resolved), path: resolved, roots: [material], materialDir: material }, dshHome)
153
+ }
154
+ return addSkillRoot({ id: newEntryId('local'), kind: 'local', label: basename(resolved), path: resolved, roots: detected.roots }, dshHome)
155
+ }
156
+
157
+ /** Download cap for a repo tarball. */
158
+ const TARBALL_MAX_BYTES = 256 * 1024 * 1024
159
+
160
+ /** Register a GitHub repo: tarball download → extract → detect → persist. */
161
+ export async function addGitRepo(
162
+ url: string,
163
+ options: { dshHome?: string; fetcher?: typeof fetch } = {},
164
+ ): Promise<SkillRootEntry> {
165
+ const source = parseGitHubSource(url)
166
+ if (source === null) throw new Error('expected a GitHub repository URL or owner/repo')
167
+ const existing = findRootByUrl(source.label, options.dshHome)
168
+ if (existing !== undefined) throw new Error(`${source.label} is already registered`)
169
+
170
+ const doFetch = options.fetcher ?? fetch
171
+ const response = await doFetch(source.tarballUrl, { signal: AbortSignal.timeout(60_000) })
172
+ if (!response.ok) throw new Error(`download failed (HTTP ${response.status}) for ${source.tarballUrl}`)
173
+ const archive = Buffer.from(await response.arrayBuffer())
174
+ if (archive.byteLength > TARBALL_MAX_BYTES) throw new Error('repository tarball too large')
175
+
176
+ const id = newEntryId('git')
177
+ const material = materialDirFor(id, options.dshHome)
178
+ rmSync(material, { recursive: true, force: true })
179
+ const checkout = join(material, 'repo')
180
+ try {
181
+ mkdirSync(checkout, { recursive: true })
182
+ extractTarGz(archive, checkout, { stripComponents: 1 })
183
+ const detected = detectSkillRoots(checkout)
184
+ if (!detected.single && detected.roots.length === 0) {
185
+ throw new Error('no SKILL.md found in that repository')
186
+ }
187
+ // A single-skill repo is discovered through the material dir itself; a
188
+ // collection registers the checkout (plus nested collection wrappers).
189
+ const roots = detected.single ? [material] : detected.roots
190
+ return addSkillRoot(
191
+ { id, kind: 'git', label: source.label, url: source.label, ref: source.ref, roots, materialDir: material },
192
+ options.dshHome,
193
+ )
194
+ } catch (error) {
195
+ rmSync(material, { recursive: true, force: true })
196
+ throw error
197
+ }
198
+ }
199
+
200
+ /** Validate one scan root still exists; entries can go stale on disk. */
201
+ export function rootExists(path: string): boolean {
202
+ try {
203
+ return statSync(path).isDirectory()
204
+ } catch {
205
+ return false
206
+ }
207
+ }