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/README.md +18 -2
- package/lib/client.js +936 -220
- package/lib/client.js.map +4 -4
- package/lib/index.js +817 -41
- package/lib/index.js.map +4 -4
- package/market/mcp.json +164 -0
- package/market/skills.json +36 -0
- package/package.json +2 -1
- package/src/client/CapabilitiesSection.tsx +11 -7
- package/src/client/MarketTab.tsx +263 -0
- package/src/client/McpTab.tsx +198 -0
- package/src/client/SkillsTab.tsx +261 -26
- package/src/client/css.ts +42 -0
- package/src/client/index.ts +58 -5
- package/src/client/locales.ts +112 -2
- package/src/index.ts +59 -19
- package/src/market.test.ts +57 -0
- package/src/market.ts +166 -0
- package/src/opener.ts +34 -0
- package/src/repos.test.ts +213 -0
- package/src/repos.ts +207 -0
- package/src/routes.ts +340 -6
- package/src/skills.test.ts +45 -1
- package/src/skills.ts +22 -1
- package/src/smoke.test.ts +54 -1
- package/src/state.test.ts +64 -0
- package/src/state.ts +109 -0
- package/src/tar.test.ts +100 -0
- package/src/tar.ts +154 -0
- package/src/types.ts +11 -1
|
@@ -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
|
+
}
|