/** * Local skill library (`~/.dsh/skills`). * * The canvas offers every model-invocable host skill, but the interesting * long-running ones (deck/document pipelines) are user-installed. This module * is the host side of that story: it inspects the library, installs skills from * a git URL / archive URL / raw `SKILL.md` URL, installs an archive the user * uploaded through the canvas, and removes an installed skill. * * Install safety rules: * * - everything lands under the resolved skills root; every destination path is * re-checked with `path.relative` before it is written or deleted; * - an archive is extracted into a staging directory first and only promoted * when it actually contains a `SKILL.md` with a frontmatter `name`; * - archives are read through the same bounded ZIP reader as OOXML extraction, * and the expanded size is capped; * - `git` runs with a timeout in a non-interactive mode on a fixed executable * name (no shell), and its failure is reported as actionable copy. * * The filesystem skill provider watches `~/.dsh/skills`, so an install becomes * visible to `ctx.skills` without restarting the host. */ import { spawn } from 'node:child_process' import { existsSync, type Dirent } from 'node:fs' import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import path from 'node:path' import type { CanvasSkillLibrary, CanvasSkillLibraryEntry, } from './protocol.ts' import { readZipDirectory, readZipEntry, isZipDirectory } from './zip.ts' /** Hard cap on one downloaded archive / unpacked skill folder. */ export const MAX_SKILL_ARCHIVE_BYTES = 64 * 1024 * 1024 /** Cap on a single `SKILL.md` fetched from a raw URL. */ export const MAX_SKILL_MARKDOWN_BYTES = 2 * 1024 * 1024 /** Wall-clock cap for one install source. */ const INSTALL_TIMEOUT_MS = 180_000 /** Canonical install sources this plugin knows by name (one-click affordances). */ export const KNOWN_SKILL_SOURCES: ReadonlyArray<{ name: string; url: string }> = [ { name: 'image-to-editable-ppt', url: 'https://github.com/ningzimu/image-to-editable-ppt-skill' }, ] /** The upstream home of a known skill, when we know it. */ export function knownSkillUrl(name: string): string | undefined { return KNOWN_SKILL_SOURCES.find(entry => entry.name === name)?.url } /** Resolve the skills root: `DSH_HOME/skills` when known, else `~/.dsh/skills`. */ export function skillsRoot(dshHome?: string): string { const home = (dshHome ?? process.env.DSH_HOME ?? '').trim() const base = home === '' ? path.join(homedir(), '.dsh') : home return path.join(base, 'skills') } /** A user-facing failure carrying actionable copy. */ export class SkillStoreError extends Error { constructor(message: string) { super(message) this.name = 'SkillStoreError' } } /** Whether a name is a safe folder name for one skill. */ export function isValidSkillName(name: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) && name !== '.' && name !== '..' } /** * The public DSH skill-name grammar: lowercase kebab-case. * * This is stricter than {@link isValidSkillName} on purpose. The folder-name * check only keeps a destination path safe, while the host skill registry * publishes a bundle under its frontmatter `name` and *drops the whole skill* * when that name is not kebab-case — silently, with nothing but a host log line. * Every install and every locally discovered skill is checked against this * grammar so the canvas can never offer something the host would ignore. */ const DSH_SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ /** Whether a name is the kebab-case name the host skill registry loads. */ export function isDshSkillName(name: string): boolean { return DSH_SKILL_NAME.test(name) } /** Directory size in bytes (bounded walk), or 0 when it cannot be read. */ async function directorySize(root: string, depth = 0): Promise { if (depth > 6) return 0 let total = 0 let entries: Dirent[] try { entries = await readdir(root, { withFileTypes: true }) } catch { return 0 } for (const entry of entries) { const full = path.join(root, entry.name) if (entry.isDirectory()) { total += await directorySize(full, depth + 1); continue } if (!entry.isFile()) continue const info = await stat(full).catch(() => undefined) if (info !== undefined) total += info.size } return total } /** Parsed frontmatter of one `SKILL.md` (only the fields the library needs). */ interface SkillFrontmatter { /** Whether the file opens with a `---` fence at all. */ present: boolean name?: string description?: string whenToUse?: string /** `disable-model-invocation`, when it parsed. */ disableModelInvocation?: boolean /** `user-invocable`, when it parsed. */ userInvocable?: boolean /** A present-but-unparsable invocation spelling: the host drops the skill. */ invalidInvocation: boolean } /** The opening fence of a `SKILL.md` head, body excluded. */ const FRONTMATTER_HEAD = /^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/ /** The same fence as a whole match, for stripping a body out of a file. */ const FRONTMATTER_WHOLE = /^---\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/ /** The boolean spellings the host's frontmatter reader accepts. */ function booleanOf(raw: string): boolean | undefined { switch (raw.trim().toLowerCase()) { case 'true': case 'yes': case 'on': case '1': return true case 'false': case 'no': case 'off': case '0': return false default: return undefined } } /** Strip one matching pair of surrounding quotes from a scalar value. */ function unquote(value: string): string { const text = value.trim() const quote = text[0] if ((quote === '"' || quote === "'") && text.length > 1 && text.endsWith(quote)) return text.slice(1, -1).trim() return text } /** * Read a skill bundle's frontmatter. The DSH skill format uses a YAML head * between `---` fences and wants `name` plus `description`; this reader covers * the scalar and block-scalar spellings real bundles use (a folded * `description: >-` must not surface as the literal text `>-`). * @param markdown - the `SKILL.md` contents. */ export function parseSkillFrontmatter(markdown: string): SkillFrontmatter { const match = FRONTMATTER_HEAD.exec(markdown) if (match === null) return { present: false, invalidInvocation: false } const lines = match[1]!.split(/\r?\n/) const fields = new Map() for (let index = 0; index < lines.length; index += 1) { const line = lines[index]! // Indented lines belong to the key above; a document marker ends the head. if (line.trim() === '' || line.trim().startsWith('#') || /^\s/.test(line)) continue const entry = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line) if (entry === null) continue const key = entry[1]! let value = entry[2]!.trim() const block = /^([|>])[+-]?\d*\s*$/.exec(value) if (block !== null) { const collected: string[] = [] while (index + 1 < lines.length && (lines[index + 1]!.trim() === '' || /^\s/.test(lines[index + 1]!))) { index += 1 collected.push(lines[index]!.trim()) } value = block[1] === '>' ? collected.join(' ').replace(/\s+/g, ' ').trim() : collected.join('\n').trim() } else { value = unquote(value) } if (!fields.has(key)) fields.set(key, value) } const read = (key: string): string | undefined => { const value = fields.get(key) return value === undefined || value === '' ? undefined : value } const disable = read('disable-model-invocation') const user = read('user-invocable') const disableValue = disable === undefined ? undefined : booleanOf(disable) const userValue = user === undefined ? undefined : booleanOf(user) return { present: true, ...read('name') === undefined ? {} : { name: read('name')! }, ...read('description') === undefined ? {} : { description: read('description')! }, ...read('whenToUse') === undefined ? {} : { whenToUse: read('whenToUse')! }, ...disableValue === undefined ? {} : { disableModelInvocation: disableValue }, ...userValue === undefined ? {} : { userInvocable: userValue }, invalidInvocation: (disable !== undefined && disableValue === undefined) || (user !== undefined && userValue === undefined), } } /** First meaningful line of a skill body, used when frontmatter has no blurb. */ function firstHeading(markdown: string): string { const withoutHead = markdown.replace(FRONTMATTER_WHOLE, '') for (const raw of withoutHead.split(/\r?\n/)) { const line = raw.trim() if (line === '') continue return line.replace(/^#+\s*/, '').slice(0, 240) } return '' } /** Why the host skill registry would refuse to load one local bundle. */ export type LocalSkillIssue = 'no-frontmatter' | 'bad-name' | 'no-description' | 'bad-invocation' | 'user-only' /** One `SKILL.md` judged by the host registry's load rules. */ export interface LocalSkillInspection { /** Name the host registry publishes (the frontmatter `name`), else `''`. */ name: string /** Blurb for the UI: the frontmatter description, else the first body line. */ description: string whenToUse?: string /** Whether the host registry loads it, i.e. the canvas may offer it. */ loadable: boolean issue?: LocalSkillIssue } /** * Judge one bundle against the host registry's rules: a kebab-case frontmatter * `name`, a frontmatter `description`, a parseable invocation spelling, and * model invocation left permitted. Anything else is invisible to every agent, * which is exactly what the canvas must not pretend to offer. * @param markdown - the `SKILL.md` contents. */ export function inspectSkillMarkdown(markdown: string): LocalSkillInspection { const front = parseSkillFrontmatter(markdown) const name = front.name?.trim() ?? '' const blurb = (front.description ?? firstHeading(markdown)).trim() const base = { name, description: blurb, ...front.whenToUse === undefined ? {} : { whenToUse: front.whenToUse }, } if (!front.present) return { ...base, loadable: false, issue: 'no-frontmatter' } if (!isDshSkillName(name)) return { ...base, loadable: false, issue: 'bad-name' } if ((front.description ?? '').trim() === '') return { ...base, loadable: false, issue: 'no-description' } if (front.invalidInvocation) return { ...base, loadable: false, issue: 'bad-invocation' } if (front.disableModelInvocation === true) return { ...base, loadable: false, issue: 'user-only' } return { ...base, loadable: true } } /** One local skill the canvas can offer, with its body already read. */ export interface LocalSkill { /** Name the host registry publishes. */ name: string description: string whenToUse?: string /** Absolute path of the `SKILL.md` (or of the flat `.md`). */ path: string /** Instruction body, frontmatter removed. */ content: string } /** Candidate skill files directly under `root` (the host scans one level). */ async function scanSkillFiles(root: string): Promise> { let children: Dirent[] try { children = await readdir(root, { withFileTypes: true }) } catch { return [] } const files: Array<{ folder: string; path: string }> = [] for (const child of children) { // `.system` is the host's own bundled-skill seat, never a user install. if (child.isDirectory()) { if (child.name.startsWith('.')) continue const markdown = path.join(root, child.name, 'SKILL.md') if (existsSync(markdown)) files.push({ folder: child.name, path: markdown }) continue } // A flat `.md` skill is equally valid in the DSH layout. if (child.isFile() && child.name.toLowerCase().endsWith('.md')) { files.push({ folder: child.name.slice(0, -3), path: path.join(root, child.name) }) } } return files } /** Read one candidate `SKILL.md`, bounded like a downloaded one. */ async function readSkillText(candidatePath: string): Promise { const info = await stat(candidatePath).catch(() => undefined) if (info === undefined || !info.isFile() || info.size > MAX_SKILL_MARKDOWN_BYTES) return undefined return await readFile(candidatePath, 'utf8').catch(() => undefined) } /** The instruction body of one `SKILL.md` (frontmatter removed). */ function skillBody(markdown: string): string { return markdown.replace(FRONTMATTER_WHOLE, '').trim() } /** * Every skill under `root` the host registry would load, with its body. * * The canvas is a host surface with no session scope, so it cannot read the * per-preset layer of `ctx.skills` (see `createSkillRegistryBackend`). Scanning * the library directly is what lets the node skill menu agree with the skill * library panel the user installed from. * @param root - resolved skills root. */ export async function listLocalSkills(root: string): Promise { const skills: LocalSkill[] = [] for (const candidate of await scanSkillFiles(root)) { const markdown = await readSkillText(candidate.path) if (markdown === undefined) continue const inspection = inspectSkillMarkdown(markdown) if (!inspection.loadable) continue skills.push({ name: inspection.name, description: inspection.description, ...inspection.whenToUse === undefined ? {} : { whenToUse: inspection.whenToUse }, path: candidate.path, content: skillBody(markdown), }) } return skills.sort((a, b) => a.name.localeCompare(b.name)) } /** * Resolve one loadable local skill by the name the host registry publishes. * @param root - resolved skills root. * @param name - published skill name. */ export async function readLocalSkill(root: string, name: string): Promise { const wanted = name.trim() if (wanted === '') return undefined return (await listLocalSkills(root)).find(skill => skill.name === wanted) } /** One installed skill folder as the library reports it. */ async function entryOf( root: string, candidate: { folder: string; path: string }, options: { known?: ReadonlyArray<{ name: string; path?: string }>; issueText?: IssueText }, ): Promise { const markdown = (await readSkillText(candidate.path)) ?? '' const inspection = inspectSkillMarkdown(markdown) const name = inspection.name !== '' ? inspection.name : candidate.folder const known = knownSkillUrl(name) const registryPath = options.known?.find(item => item.name === name || item.name === candidate.folder)?.path const info = await stat(candidate.path).catch(() => undefined) const issue = inspection.issue !== undefined && options.issueText !== undefined ? options.issueText(inspection.issue, name) : undefined return { name, description: inspection.description, path: registryPath ?? candidate.path, // A bundle's own size is what an uninstall reclaims; a flat file's is itself. sizeBytes: path.basename(candidate.path).toLowerCase() === 'skill.md' ? await directorySize(path.dirname(candidate.path)) : info?.size ?? 0, updatedAt: info?.mtimeMs ?? Date.now(), ...known === undefined ? {} : { installUrl: known }, ...issue === undefined ? {} : { issue }, } } /** Renders one issue code into display copy (the host owns the words). */ export type IssueText = (issue: LocalSkillIssue, name: string) => string /** * Inspect the library. * @param options - skills root plus an optional authoritative name/path list * from the host registry (`ctx.skills`), which is preferred for `path`. * @param options.root - resolved skills root. * @param options.known - registry summaries, when the registry answered. * @param options.networkAvailable - whether installs can be attempted at all. * @param options.issueText - localizes "why the node menu cannot offer this" * for entries the host registry would ignore; omitted leaves it unsaid. */ export async function listLibrary(options: { root: string known?: ReadonlyArray<{ name: string; path?: string }> networkAvailable: boolean issueText?: IssueText }): Promise { const entries: CanvasSkillLibraryEntry[] = [] for (const candidate of await scanSkillFiles(options.root)) { entries.push(await entryOf(options.root, candidate, options)) } entries.sort((a, b) => a.name.localeCompare(b.name)) return { root: options.root, entries, catalog: KNOWN_SKILL_SOURCES.map(source => ({ ...source })), networkAvailable: options.networkAvailable, } } /** Strip a source URL down to something printable in an error message. */ function shortSource(source: string): string { try { const url = new URL(source) return `${url.host}${url.pathname}`.slice(0, 120) } catch { return source.slice(0, 120) } } /** What a source URL means for the installer. */ export type SourceKind = | { kind: 'github'; owner: string; repo: string; ref: string; subpath: string } | { kind: 'raw'; url: string; name: string } | { kind: 'archive'; url: string } | { kind: 'git'; url: string; name: string } /** * Classify one install source. * @param source - user-supplied URL (git URL, archive URL, or SKILL.md URL). */ export function classifySource(source: string): SourceKind { const trimmed = source.trim() const github = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^/\s]+)((?:\/[^\s#?]*)?))?\/?$/i.exec(trimmed) if (github !== null) { const subpath = (github[4] ?? '').replace(/^\/+/, '').replace(/\/+$/, '') // A `blob` URL points at one file, but the surrounding folder is the bundle. return { kind: 'github', owner: github[1]!, repo: github[2]!, ref: github[3] ?? '', subpath } } const raw = /^https?:\/\/raw\.githubusercontent\.com\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/(.+)$/i.exec(trimmed) if (raw !== null) { const file = raw[4]! const base = file.split('/').filter(part => part !== '').pop() ?? 'SKILL.md' return { kind: 'raw', url: trimmed, name: base.replace(/\.md$/i, '') } } if (/\.zip(?:[?#].*)?$/i.test(trimmed)) return { kind: 'archive', url: trimmed } const guessed = trimmed.split(/[?#]/)[0]!.replace(/\/+$/, '').split('/').pop() ?? '' return { kind: 'git', url: trimmed, name: guessed.replace(/\.git$/i, '') } } /** Download one URL into memory with a byte cap. */ async function download(url: string, maxBytes: number, signal?: AbortSignal): Promise { let response: Response try { response = await fetch(url, { redirect: 'follow', signal, headers: { 'user-agent': 'dsh-imagegen-skill-installer', accept: '*/*' }, }) } catch (error) { throw new SkillStoreError(`下载失败:${error instanceof Error ? error.message : String(error)}`) } if (!response.ok) throw new SkillStoreError(`下载失败:HTTP ${response.status}`) const length = Number(response.headers.get('content-length') ?? '0') if (Number.isFinite(length) && length > maxBytes) throw new SkillStoreError('文件超过大小上限') const data = Buffer.from(await response.arrayBuffer()) if (data.length > maxBytes) throw new SkillStoreError('文件超过大小上限') return data } /** Run a command without a shell and collect its output (bounded by a timer). */ function run(command: string, args: string[], options: { cwd: string; timeoutMs: number }): Promise<{ code: number; output: string }> { return new Promise(resolve => { let settled = false const child = spawn(command, args, { cwd: options.cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' }, }) let output = '' const collect = (chunk: Buffer): void => { if (output.length < 8_000) output += chunk.toString('utf8') } child.stdout?.on('data', collect) child.stderr?.on('data', collect) const timer = setTimeout(() => { if (settled) return settled = true child.kill() resolve({ code: -1, output: `${output}\n安装超时` }) }, options.timeoutMs) child.on('error', error => { if (settled) return settled = true clearTimeout(timer) resolve({ code: -1, output: String(error.message) }) }) child.on('close', code => { if (settled) return settled = true clearTimeout(timer) resolve({ code: code ?? -1, output }) }) }) } /** Expand a ZIP archive into `target`, refusing paths that escape it. */ async function extractArchive(data: Buffer, target: string): Promise { const entries = readZipDirectory(data) if (entries.length === 0) throw new SkillStoreError('压缩包无法解析(不是有效的 ZIP)') let written = 0 let expanded = 0 for (const entry of entries) { const relative = entry.name.replace(/\\/g, '/').replace(/^\/+/, '') if (relative === '' || relative.includes('..')) continue const destination = path.resolve(target, relative) if (path.relative(target, destination).startsWith('..')) continue if (isZipDirectory(entry)) { await mkdir(destination, { recursive: true }) continue } const bytes = readZipEntry(data, entry, MAX_SKILL_ARCHIVE_BYTES) if (bytes === undefined) continue expanded += bytes.length if (expanded > MAX_SKILL_ARCHIVE_BYTES) throw new SkillStoreError('压缩包解压后超过大小上限') await mkdir(path.dirname(destination), { recursive: true }) await writeFile(destination, bytes) written += 1 } if (written === 0) throw new SkillStoreError('压缩包内没有可安装的文件') return written } /** Find the folder that actually holds `SKILL.md` inside an extracted tree. */ async function findSkillRoot(root: string): Promise { if (existsSync(path.join(root, 'SKILL.md'))) return root let children: Dirent[] try { children = await readdir(root, { withFileTypes: true }) } catch { return undefined } const dirs = children.filter(child => child.isDirectory()) for (const dir of dirs) { const candidate = path.join(root, dir.name) if (existsSync(path.join(candidate, 'SKILL.md'))) return candidate } // One wrapper folder (GitHub archives) with the bundle a level deeper. if (dirs.length === 1) return await findSkillRoot(path.join(root, dirs[0]!.name)) return undefined } /** `fs.rename` can fail across volumes; fall back to a recursive copy. */ async function moveInto(from: string, to: string): Promise { try { await rename(from, to) } catch { await cp(from, to, { recursive: true }) await rm(from, { recursive: true, force: true }) } } /** * The name one staged bundle installs under, or an actionable refusal. * * A bundle the host registry cannot load is never worth installing: its * frontmatter name must be kebab-case, its description present, and its * invocation spellings parseable. The folder fallback is only a *label* — the * host publishes the frontmatter name, so a fallback cannot rescue a bundle * whose own frontmatter is unusable. * @param markdown - the staged `SKILL.md` contents. */ export function installableSkillName(markdown: string): string { const inspection = inspectSkillMarkdown(markdown) switch (inspection.issue) { case 'no-frontmatter': throw new SkillStoreError('SKILL.md 缺少 YAML frontmatter(--- 开头的 name / description),本机技能注册表不会加载它') case 'bad-name': throw new SkillStoreError(`SKILL.md 的 frontmatter name「${inspection.name}」不是 kebab-case(只能小写字母、数字和连字符),本机技能注册表会忽略它`) case 'no-description': throw new SkillStoreError('SKILL.md 的 frontmatter 缺少 description(必填),本机技能注册表不会加载它') case 'bad-invocation': throw new SkillStoreError('SKILL.md 的 disable-model-invocation / user-invocable 写法无法解析,本机技能注册表会忽略它') default: return inspection.name } } /** Move a staged skill bundle into the library under its published name. */ async function promote(staged: string, root: string, force: boolean): Promise { const skillRoot = await findSkillRoot(staged) if (skillRoot === undefined) throw new SkillStoreError('该来源里没有找到 SKILL.md') const markdown = await readFile(path.join(skillRoot, 'SKILL.md'), 'utf8').catch(() => '') if (markdown.trim() === '') throw new SkillStoreError('SKILL.md 是空文件') // A user-only skill (`disable-model-invocation: true`) still installs: the // canvas cannot run it, but the host's own slash commands can. const name = installableSkillName(markdown) if (!isValidSkillName(name)) throw new SkillStoreError(`技能名「${name}」不能用作目录名,请改名后重新安装`) const destination = path.resolve(root, name) if (path.relative(root, destination).startsWith('..')) throw new SkillStoreError('技能名不合法') if (existsSync(destination) && !force) throw new SkillStoreError(`技能「${name}」已经安装(可勾选覆盖安装)`) await rm(destination, { recursive: true, force: true }) await mkdir(root, { recursive: true }) await moveInto(skillRoot, destination) return name } /** * Install one skill from an uploaded archive, or from a folder of files. * @param data - archive bytes. * @param root - skills root. * @param _fallbackName - accepted for signature compatibility only: the * frontmatter `name` is authoritative, because that is the name the host * registry publishes (a bundle without one is refused, not renamed). * @param force - replace an existing skill of the same name. */ export async function installFromArchive(data: Buffer, root: string, _fallbackName: string, force: boolean): Promise { const staging = await mkdtemp(path.join(tmpdir(), 'dsh-imagegen-skill-')) try { await extractArchive(data, staging) return await promote(staging, root, force) } finally { await rm(staging, { recursive: true, force: true }).catch(() => { /* best effort */ }) } } /** * Install one skill from a URL: a GitHub folder, a raw `SKILL.md`, an archive, * or any other git remote. * @param source - the URL the user supplied. * @param root - skills root. * @param options - `force` replaces an existing install; `signal` aborts. * `fallbackName` only names a *diagnostic* for a source with no bundle; the * frontmatter name is what the host registry publishes. */ export async function installFromUrl( source: string, root: string, options: { force: boolean; signal?: AbortSignal; fallbackName?: string }, ): Promise { const kind = classifySource(source) if (kind.kind === 'raw') { const data = await download(kind.url, MAX_SKILL_MARKDOWN_BYTES, options.signal) const text = data.toString('utf8') const name = installableSkillName(text) if (!isValidSkillName(name)) throw new SkillStoreError(`技能名「${name}」不能用作目录名,请改用仓库地址安装`) const destination = path.resolve(root, name) if (path.relative(root, destination).startsWith('..')) throw new SkillStoreError('技能名不合法') if (existsSync(destination) && !options.force) throw new SkillStoreError(`技能「${name}」已经安装(可勾选覆盖安装)`) await rm(destination, { recursive: true, force: true }) await mkdir(destination, { recursive: true }) await writeFile(path.join(destination, 'SKILL.md'), data) return name } if (kind.kind === 'github') { // GitHub source archives are one cheap download and avoid a git dependency. const ref = kind.ref === '' ? 'HEAD' : kind.ref const url = `https://codeload.github.com/${kind.owner}/${kind.repo}/zip/${encodeURIComponent(ref)}` const data = await download(url, MAX_SKILL_ARCHIVE_BYTES, options.signal) const staging = await mkdtemp(path.join(tmpdir(), 'dsh-imagegen-skill-')) try { await extractArchive(data, staging) const wanted = kind.subpath === '' ? staging : path.resolve(staging, kind.subpath) const scoped = existsSync(wanted) ? wanted : staging return await promote(scoped, root, options.force) } finally { await rm(staging, { recursive: true, force: true }).catch(() => { /* best effort */ }) } } if (kind.kind === 'archive') { const data = await download(kind.url, MAX_SKILL_ARCHIVE_BYTES, options.signal) const tail = shortSource(kind.url).split('/').pop() ?? 'skill' return await installFromArchive(data, root, options.fallbackName ?? tail.replace(/\.zip$/i, ''), options.force) } // Generic git remote: shallow clone into staging, then promote the bundle. const staging = await mkdtemp(path.join(tmpdir(), 'dsh-imagegen-skill-')) try { const result = await run('git', ['clone', '--depth', '1', '--quiet', kind.url, path.join(staging, 'repo')], { cwd: staging, timeoutMs: INSTALL_TIMEOUT_MS, }) if (result.code !== 0) { const detail = result.output.trim().split('\n').slice(-3).join(' ').slice(0, 300) throw new SkillStoreError(`git clone 失败:${detail}`) } await rm(path.join(staging, 'repo', '.git'), { recursive: true, force: true }) return await promote(path.join(staging, 'repo'), root, options.force) } finally { await rm(staging, { recursive: true, force: true }).catch(() => { /* best effort */ }) } } /** One local candidate whose published name matches, for a remove by name. */ async function publishedSkillFile(root: string, name: string): Promise { for (const candidate of await scanSkillFiles(root)) { const markdown = await readSkillText(candidate.path) if (markdown === undefined) continue if (inspectSkillMarkdown(markdown).name === name) return candidate.path } return undefined } /** * Remove one installed skill. The name may be the folder name or the * frontmatter name the panel listed (they differ whenever an author renamed * their skill after the first release). * @param name - the skill's published or folder name inside the library. * @param root - skills root. */ export async function removeSkill(name: string, root: string): Promise { const trimmed = name.trim() // Path-shaped input is refused before it is resolved, scan path included. if (trimmed === '' || trimmed === '.' || trimmed === '..' || /[\\/]/.test(trimmed)) { throw new SkillStoreError('技能名不合法') } const dir = path.resolve(root, trimmed) if (path.relative(root, dir).startsWith('..')) throw new SkillStoreError('技能名不合法') const flat = path.resolve(root, `${trimmed}.md`) if (existsSync(dir)) { await rm(dir, { recursive: true, force: true }); return trimmed } if (existsSync(flat)) { await rm(flat, { force: true }); return trimmed } const published = await publishedSkillFile(root, trimmed) if (published === undefined) throw new SkillStoreError(`没有找到技能「${trimmed}」`) // A bundle is removed whole; a flat `.md` skill is one file. if (path.basename(published).toLowerCase() === 'skill.md') await rm(path.dirname(published), { recursive: true, force: true }) else await rm(published, { force: true }) return trimmed }