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.
- package/README.md +16 -2
- package/lib/client.js +936 -220
- package/lib/client.js.map +4 -4
- package/lib/index.js +813 -38
- package/lib/index.js.map +4 -4
- package/market/mcp.json +164 -0
- package/market/skills.json +36 -0
- package/package.json +3 -1
- package/skills/find-skills/LICENSE +21 -0
- package/skills/find-skills/SKILL.md +141 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/SKILL.md +485 -0
- package/skills/skill-creator/agents/analyzer.md +274 -0
- package/skills/skill-creator/agents/comparator.md +202 -0
- package/skills/skill-creator/agents/grader.md +223 -0
- package/skills/skill-creator/assets/eval_review.html +146 -0
- package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/skills/skill-creator/references/schemas.md +430 -0
- package/skills/skill-creator/scripts/__init__.py +0 -0
- package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/skills/skill-creator/scripts/generate_report.py +326 -0
- package/skills/skill-creator/scripts/improve_description.py +247 -0
- package/skills/skill-creator/scripts/package_skill.py +136 -0
- package/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/skills/skill-creator/scripts/run_eval.py +310 -0
- package/skills/skill-creator/scripts/run_loop.py +328 -0
- package/skills/skill-creator/scripts/utils.py +47 -0
- 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 +76 -20
- package/src/market.test.ts +57 -0
- package/src/market.ts +166 -0
- package/src/opener.ts +26 -0
- package/src/packaged-skills.test.ts +40 -0
- package/src/repos.test.ts +213 -0
- package/src/repos.ts +207 -0
- package/src/routes.ts +336 -6
- package/src/skills.test.ts +45 -1
- package/src/skills.ts +22 -1
- package/src/smoke.test.ts +61 -2
- 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
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
|
+
}
|
package/src/tar.test.ts
ADDED
|
@@ -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<
|
|
37
|
+
get(name: string, options?: { cwd?: string }): Promise<HostSkillDefinition | undefined>
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/** Host context carrying both services this plugin injects. */
|