Files

748 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { promises as fsp } from "node:fs";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { findDshHome, atomicWriteFile } from "./core.js";
import { extractZipArchive, listZipFile } from "./zip-extract.js";
export { findDshHome };
/** 与官方 @deepseek-ai/dsh-skill 一致:kebab-case。 */
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_SKILL_BYTES = 256 * 1024;
const MAX_ARCHIVE_BYTES = 12 * 1024 * 1024;
const MAX_IMPORT_FILES = 200;
const MAX_DESCRIPTION = 500;
const SKIP_COPY_NAMES = new Set([".", "..", ".system", ".git", "node_modules", "__MACOSX", ".DS_Store"]);
const MAX_UNPACKED_BYTES = 50 * 1024 * 1024;
export function validSkillName(id) {
return typeof id === "string" && SKILL_NAME_RE.test(id);
}
export function skillsDir(dshHome) {
return path.join(dshHome, "skills");
}
export function skillBackupDir(dshHome) {
return path.join(dshHome, "dsh-purge-backups", "skills");
}
export function skillBundleDir(dshHome, id) {
return path.join(skillsDir(dshHome), id);
}
export function skillBundleFile(dshHome, id) {
return path.join(skillBundleDir(dshHome, id), "SKILL.md");
}
export function skillFlatFile(dshHome, id) {
return path.join(skillsDir(dshHome), `${id}.md`);
}
/** 对外只报 $DSH_HOME/...,不暴露本机盘符或安装目录。 */
export function displayUnderHome(dshHome, absPath) {
if (!absPath) return "";
const home = path.resolve(dshHome);
const abs = path.resolve(absPath);
if (abs === home) return "$DSH_HOME";
const rel = path.relative(home, abs);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return "$DSH_HOME";
return `$DSH_HOME/${rel.split(/[/\\]/).join("/")}`;
}
export function toPublicSkill(dshHome, item) {
if (!item || typeof item !== "object") return item;
const next = { ...item };
if (typeof next.path === "string") next.path = displayUnderHome(dshHome, next.path);
if (typeof next.backup === "string") next.backup = displayUnderHome(dshHome, next.backup);
return next;
}
function assertInside(root, target) {
const a = path.resolve(root);
const b = path.resolve(target);
const prefix = a.endsWith(path.sep) ? a : a + path.sep;
if (b !== a && !b.startsWith(prefix)) {
throw new Error("skill path escaped official skills directory");
}
}
/** 源与 $DSH_HOME/skills 重叠时先删后拷会把自己删空(#54)。 */
function pathsOverlap(a, b) {
// 必须 realpath — 只 resolve 不解 symlink,软链到 skills 下的路径 resolve 后不匹配,
// 删除时顺着 symlink 把 skills 删穿。realpath 对不存在路径会 throw,fallback 到 resolve。
const toReal = (p) => {
const abs = path.resolve(String(p || ""));
if (!abs) return "";
try { return fs.realpathSync.native ? fs.realpathSync.native(abs) : fs.realpathSync(abs); }
catch { return abs; }
};
const left = toReal(a);
const right = toReal(b);
if (!left || !right) return false;
if (left === right) return true;
const leftPrefix = left.endsWith(path.sep) ? left : left + path.sep;
const rightPrefix = right.endsWith(path.sep) ? right : right + path.sep;
return left.startsWith(rightPrefix) || right.startsWith(leftPrefix);
}
function assertImportSourceOutsideSkills(dshHome, srcPath) {
const root = skillsDir(dshHome);
if (!pathsOverlap(srcPath, root)) return;
throw new Error("不能从 $DSH_HOME/skills 自身(或其子目录)导入,请换一个源目录");
}
function yamlScalar(value) {
const text = String(value ?? "").trim();
if (!text) return '""';
if (/^[A-Za-z0-9._+/-]+$/.test(text)) return text;
return JSON.stringify(text);
}
export function splitFrontmatter(raw) {
const text = String(raw || "").replace(/^\uFEFF/, "");
if (!text.startsWith("---")) return null;
const afterOpen = text.slice(3);
if (!/^\r?\n/.test(afterOpen)) return null;
const rest = afterOpen.replace(/^\r?\n/, "");
const close = rest.match(/\r?\n---(?:\r?\n|$)/);
if (!close || close.index == null) return null;
return {
head: rest.slice(0, close.index),
body: rest.slice(close.index + close[0].length),
};
}
/**
* YAML 块标量解析:覆盖 `|`, `>`, `|-`, `|+`, `>-`, `>+`, `|2` 等 chomp/indent 指示。
* 只扫 frontmatter,返回**压成单行**的字符串(换行→空格),因为上游面板按单行描述渲染。
* 不支持 anchor / alias / tag / 多行 flow —— SKILL.md 不该出现。
*/
function escapeYamlKey(s) {
// frontmatter key 走到 RegExp 构造前必须 escape:当前调用点都是硬编码字面量,
// 但万一未来有数据驱动场景,点 / 美元符 / 括号在正则里是元字符,会错配或 ReDoS。
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function blockScalarValue(head, key) {
const lines = String(head || "").split(/\r?\n/);
const headerRe = new RegExp(`^(\\s*)${escapeYamlKey(key)}:\\s*([|>])([+-]?)(\\d*)\\s*(?:#.*)?$`);
let headerIdx = -1;
let keyIndent = 0;
let style = "|";
let chomp = "";
let indentHint = 0;
for (let i = 0; i < lines.length; i += 1) {
const m = lines[i].match(headerRe);
if (!m) continue;
headerIdx = i;
keyIndent = m[1].length;
style = m[2];
chomp = m[3] || "";
indentHint = m[4] ? parseInt(m[4], 10) : 0;
break;
}
if (headerIdx < 0) return "";
const body = [];
let blockIndent = indentHint > 0 ? keyIndent + indentHint : 0;
for (let i = headerIdx + 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === "") { body.push(""); continue; }
const indent = line.match(/^(\s*)/)[1].length;
if (indent <= keyIndent) break;
if (!blockIndent) blockIndent = indent;
if (indent < blockIndent) break;
body.push(line.slice(blockIndent));
}
while (body.length && body[body.length - 1] === "") body.pop();
let text;
if (style === "|") {
text = body.join("\n");
} else {
text = "";
for (let i = 0; i < body.length; i += 1) {
const cur = body[i];
if (i === 0) { text += cur; continue; }
if (cur === "") { text += "\n"; continue; }
if (body[i - 1] === "") { text += cur; continue; }
text += " " + cur;
}
}
if (chomp === "+") text += "\n";
else if (chomp !== "-" && text.length > 0) text += "\n";
return text.replace(/\s+/g, " ").trim();
}
function yamlLineField(head, key) {
const re = new RegExp(`^${escapeYamlKey(key)}:\\s*(.*)$`, "m");
const match = String(head || "").match(re);
if (!match) return "";
let value = match[1].trim();
if (!value) return "";
// 块标量:`|`, `>`, 加 chomp (`-`/`+`) 或 indent 指示(`|2`)—— 调用 blockScalarValue 解析正文。
if (/^[|>][+-]?\d*\s*(?:#.*)?$/.test(value)) return blockScalarValue(head, key);
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
try {
return value.startsWith("\"") ? JSON.parse(value) : value.slice(1, -1);
} catch {
return value.slice(1, -1);
}
}
const comment = value.indexOf(" #");
if (comment >= 0) value = value.slice(0, comment).trim();
return value;
}
function yamlBoolField(head, key) {
const re = new RegExp(`^${key}:\\s*(.*)$`, "m");
const match = String(head || "").match(re);
if (!match) return { present: false };
let value = match[1].trim();
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1).trim();
}
const comment = value.indexOf(" #");
if (comment >= 0) value = value.slice(0, comment).trim();
const norm = value.toLowerCase();
if (["true", "yes", "on", "1"].includes(norm)) return { present: true, value: true };
if (["false", "no", "off", "0"].includes(norm)) return { present: true, value: false };
return { present: true, invalid: true };
}
export function parseSkillDocument(raw) {
const parts = splitFrontmatter(raw);
if (!parts) return { ok: false, error: "缺少 YAML frontmatter(须以 --- 包围)" };
const name = yamlLineField(parts.head, "name");
const description = yamlLineField(parts.head, "description");
if (!validSkillName(name)) return { ok: false, error: "frontmatter.name 必须是 kebab-case,例如 code-review" };
if (!description) return { ok: false, error: "frontmatter.description 必填(单行)" };
const disableModel = yamlBoolField(parts.head, "disable-model-invocation");
const userInvocable = yamlBoolField(parts.head, "user-invocable");
if (disableModel.invalid) return { ok: false, error: "disable-model-invocation 必须是布尔值(true/false)" };
if (userInvocable.invalid) return { ok: false, error: "user-invocable 必须是布尔值(true/false)" };
return {
ok: true,
name,
description,
whenToUse: yamlLineField(parts.head, "whenToUse"),
modelInvocable: disableModel.value !== true,
userInvocable: userInvocable.value !== false,
body: parts.body,
head: parts.head,
};
}
export function renderSkillDocument(id, description, body = "") {
if (!validSkillName(id)) throw new Error("invalid skill name");
const desc = String(description || "").trim() || id;
const text = String(body || "").replace(/^\uFEFF/, "");
const parts = splitFrontmatter(text);
const inner = parts ? parts.body : text;
return `---\nname: ${id}\ndescription: ${yamlScalar(desc.slice(0, MAX_DESCRIPTION))}\n---\n\n${inner.replace(/^\r?\n/, "")}`;
}
function upsertYamlLine(head, key, value) {
const line = `${key}: ${yamlScalar(value)}`;
const re = new RegExp(`^${key}:\\s*.*$`, "m");
if (re.test(head)) return head.replace(re, line);
return `${line}\n${String(head || "").replace(/^\r?\n/, "")}`;
}
/** 保留官方 whenToUse / invocation / metadata,只改 name 与 description。 */
export function mergeSkillDocument(id, description, raw = "") {
const parsed = parseSkillDocument(raw);
const desc = String(description || "").trim() || (parsed.ok ? parsed.description : "") || id;
if (!parsed.ok) return renderSkillDocument(id, desc, raw);
const head = upsertYamlLine(upsertYamlLine(parsed.head, "name", id), "description", desc.slice(0, MAX_DESCRIPTION));
return `---\n${head.replace(/^\r?\n/, "").replace(/\r?\n$/, "")}\n---\n\n${parsed.body.replace(/^\r?\n/, "")}`;
}
export function formatSize(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
return `${kb >= 100 ? Math.round(kb) : kb.toFixed(1)} KB`;
}
async function pathExists(fp) {
try {
await fsp.access(fp);
return true;
} catch {
return false;
}
}
async function skillFromFile(id, fp, kind) {
const content = await fsp.readFile(fp, "utf8");
const parsed = parseSkillDocument(content);
return {
id,
kind,
path: fp,
content,
description: parsed.ok ? parsed.description : "",
whenToUse: parsed.ok ? parsed.whenToUse : "",
modelInvocable: parsed.ok ? parsed.modelInvocable : false,
userInvocable: parsed.ok ? parsed.userInvocable : false,
valid: parsed.ok,
error: parsed.ok ? null : parsed.error,
size: Buffer.byteLength(content, "utf8"),
};
}
export async function readSkill(dshHome, id) {
if (!validSkillName(id)) throw new Error(`invalid skill name: ${id}`);
const bundle = skillBundleFile(dshHome, id);
if (await pathExists(bundle)) return skillFromFile(id, bundle, "bundle");
const flat = skillFlatFile(dshHome, id);
if (await pathExists(flat)) return skillFromFile(id, flat, "flat");
const dir = skillsDir(dshHome);
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return null;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === ".system") continue;
const fp = path.join(dir, entry.name, "SKILL.md");
if (!(await pathExists(fp))) continue;
const parsed = parseSkillDocument(await fsp.readFile(fp, "utf8"));
if (parsed.ok && parsed.name === id) return skillFromFile(id, fp, "bundle");
}
return null;
}
export async function listSkills(dshHome) {
const dir = skillsDir(dshHome);
const out = [];
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return out;
}
const seen = new Set();
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name === ".system") continue;
if (entry.isDirectory()) {
const fp = path.join(dir, entry.name, "SKILL.md");
if (!(await pathExists(fp))) continue;
const parsed = parseSkillDocument(await fsp.readFile(fp, "utf8"));
const id = parsed.ok ? parsed.name : (validSkillName(entry.name) ? entry.name : "");
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(await skillFromFile(id, fp, "bundle"));
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "SKILL.md") continue;
const fallback = entry.name.slice(0, -3);
const fp = path.join(dir, entry.name);
const parsed = parseSkillDocument(await fsp.readFile(fp, "utf8"));
const id = parsed.ok ? parsed.name : (validSkillName(fallback) ? fallback : "");
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(await skillFromFile(id, fp, "flat"));
}
return out;
}
async function copyDir(src, dest) {
await fsp.mkdir(dest, { recursive: true });
for (const entry of await fsp.readdir(src, { withFileTypes: true })) {
if (SKIP_COPY_NAMES.has(entry.name) || entry.name.startsWith("._")) continue;
if (entry.isSymbolicLink()) continue;
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
assertInside(dest, to);
if (entry.isDirectory()) await copyDir(from, to);
else if (entry.isFile()) await fsp.copyFile(from, to);
}
}
async function backupExisting(dshHome, id) {
const existing = await readSkill(dshHome, id).catch(() => null);
const root = skillsDir(dshHome);
const bundleDir = existing?.kind === "bundle" ? path.dirname(existing.path) : skillBundleDir(dshHome, id);
const flat = existing?.kind === "flat" ? existing.path : skillFlatFile(dshHome, id);
const hasBundle = await pathExists(bundleDir);
const hasFlat = await pathExists(flat);
if (!hasBundle && !hasFlat) return null;
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const uniq = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
const dest = path.join(skillBackupDir(dshHome), `${id}_${stamp}_${uniq}`);
assertInside(path.join(dshHome, "dsh-purge-backups"), dest);
await fsp.mkdir(dest, { recursive: true });
if (hasBundle) {
assertInside(root, bundleDir);
await copyDir(bundleDir, path.join(dest, id));
}
if (hasFlat) {
assertInside(root, flat);
await fsp.copyFile(flat, path.join(dest, `${id}.md`));
}
return dest;
}
async function removeOfficialSkill(dshHome, id) {
const existing = await readSkill(dshHome, id).catch(() => null);
const root = skillsDir(dshHome);
const bundleDir = existing?.kind === "bundle" ? path.dirname(existing.path) : skillBundleDir(dshHome, id);
const flat = existing?.kind === "flat" ? existing.path : skillFlatFile(dshHome, id);
if (await pathExists(bundleDir)) {
assertInside(root, bundleDir);
await fsp.rm(bundleDir, { recursive: true, force: true });
}
if (await pathExists(flat)) {
assertInside(root, flat);
await fsp.rm(flat, { force: true });
}
const canonical = skillBundleDir(dshHome, id);
if (canonical !== bundleDir && await pathExists(canonical)) {
assertInside(root, canonical);
await fsp.rm(canonical, { recursive: true, force: true });
}
}
export async function saveSkill(dshHome, id, content, description) {
if (!validSkillName(id)) throw new Error(`invalid skill name: ${id}`);
const parsed = parseSkillDocument(content);
const desc = String(description || "").trim() || (parsed.ok ? parsed.description : "");
const document = mergeSkillDocument(id, desc, String(content || ""));
if (Buffer.byteLength(document, "utf8") > MAX_SKILL_BYTES) {
throw new Error("skill too large (max 256KB)");
}
const check = parseSkillDocument(document);
if (!check.ok) throw new Error(check.error);
const root = skillsDir(dshHome);
const dest = skillBundleFile(dshHome, id);
assertInside(root, dest);
const backup = await backupExisting(dshHome, id);
try {
await removeOfficialSkill(dshHome, id);
await fsp.mkdir(skillBundleDir(dshHome, id), { recursive: true });
await atomicWriteFile(dest, document);
} catch (err) {
try { await restoreSkillFromBackup(dshHome, id, backup); }
catch (restoreErr) {
console.warn(
"[dsh-purge] saveSkill restore failed:",
restoreErr && restoreErr.message ? restoreErr.message : restoreErr,
);
}
throw err;
}
return {
id,
path: dest,
description: check.description,
backup,
size: Buffer.byteLength(document, "utf8"),
content: document,
};
}
export async function deleteSkill(dshHome, id) {
if (!validSkillName(id)) throw new Error(`invalid skill name: ${id}`);
const existing = await readSkill(dshHome, id);
if (!existing) throw new Error(`skill not found: ${id}`);
const backup = await backupExisting(dshHome, id);
await removeOfficialSkill(dshHome, id);
return { id, backup, path: existing.path };
}
function kebabFromLabel(name) {
const base = String(name || "")
.replace(/\.(zip|tgz|tar\.gz|tar|skill)$/i, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return validSkillName(base) ? base : "";
}
function isUnsafeArchiveEntry(name) {
const rel = String(name || "").replace(/\\/g, "/").replace(/^\.?\//, "").replace(/\/+$/, "");
if (!rel) return false;
if (rel.startsWith("/") || /^[A-Za-z]:/.test(rel)) return true;
return rel.split("/").some((part) => part === ".." || part === "");
}
function splitLines(text) {
return String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
function listArchiveEntries(archivePath) {
const result = spawnSync("tar", ["-tf", archivePath], {
windowsHide: true,
encoding: "utf8",
timeout: 15000,
});
if (result.status === 0) return splitLines(result.stdout);
if (/\.zip$/i.test(archivePath)) {
const unzip = spawnSync("unzip", ["-Z1", archivePath], {
windowsHide: true,
encoding: "utf8",
timeout: 15000,
});
if (unzip.status === 0) return splitLines(unzip.stdout);
try {
return listZipFile(archivePath);
} catch {
return null;
}
}
return null;
}
function extractArchive(archivePath, dest) {
const entries = listArchiveEntries(archivePath);
if (entries && entries.some(isUnsafeArchiveEntry)) {
throw new Error("压缩包路径不合法");
}
if (entries && entries.length > MAX_IMPORT_FILES) {
throw new Error("压缩包里文件太多");
}
if (/\.zip$/i.test(archivePath)) {
try {
extractZipArchive(archivePath, dest);
return;
} catch (error) {
throw new Error(String(error && error.message ? error.message : error));
}
}
const result = spawnSync("tar", ["-xf", archivePath, "-C", dest], {
windowsHide: true,
encoding: "utf8",
timeout: 30000,
});
if (result.status === 0) return;
const detail = String(result.stderr || result.stdout || "").trim();
throw new Error(detail || "无法解压,请改用文件夹导入");
}
async function walkFiles(root, onFile, depth = 0) {
if (depth > 6) return;
let entries = [];
try {
entries = await fsp.readdir(root, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === "." || entry.name === ".." || entry.name === ".system" || entry.name === "__MACOSX" || entry.name === ".DS_Store") continue;
if (entry.name.startsWith("._")) continue;
const full = path.join(root, entry.name);
assertInside(root, full);
if (entry.isDirectory()) await walkFiles(full, onFile, depth + 1);
else if (entry.isFile()) await onFile(full);
}
}
export async function findSkillRoots(root) {
const dirs = [];
const flats = [];
async function visit(dir, depth) {
if (depth > 4) return;
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
if (entries.some((entry) => entry.isFile() && entry.name === "SKILL.md")) {
dirs.push(dir);
return;
}
for (const entry of entries) {
if (entry.name === "." || entry.name === "..") continue;
if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "SKILL.md") {
const raw = await fsp.readFile(path.join(dir, entry.name), "utf8");
if (parseSkillDocument(raw).ok) flats.push(path.join(dir, entry.name));
}
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === "." || entry.name === ".." || entry.name === ".system" || entry.name === "__MACOSX") continue;
const next = path.join(dir, entry.name);
assertInside(root, next);
await visit(next, depth + 1);
}
}
await visit(root, 0);
return { dirs, flats };
}
async function restoreSkillFromBackup(dshHome, id, backup) {
if (!backup) return;
const root = skillsDir(dshHome);
const bundleSrc = path.join(backup, id);
const flatSrc = path.join(backup, `${id}.md`);
try {
if (await pathExists(bundleSrc)) {
const dest = skillBundleDir(dshHome, id);
assertInside(root, dest);
await fsp.rm(dest, { recursive: true, force: true }).catch(() => {});
await copyDir(bundleSrc, dest);
}
if (await pathExists(flatSrc)) {
const dest = skillFlatFile(dshHome, id);
assertInside(root, dest);
await fsp.copyFile(flatSrc, dest);
}
} catch {
// 回滚失败时保留 backup 路径给调用方。
}
}
async function installBundleDir(dshHome, srcDir, fallbackId) {
assertImportSourceOutsideSkills(dshHome, srcDir);
const mdPath = path.join(srcDir, "SKILL.md");
const raw = await fsp.readFile(mdPath, "utf8");
const parsed = parseSkillDocument(raw);
const id = parsed.ok ? parsed.name : kebabFromLabel(fallbackId || path.basename(srcDir));
if (!validSkillName(id)) throw new Error("压缩包/文件夹里没有合法的 Skill 名称");
const dest = skillBundleDir(dshHome, id);
assertInside(skillsDir(dshHome), dest);
if (pathsOverlap(srcDir, dest)) {
throw new Error("不能从目标技能目录自身导入,请换一个源目录");
}
const backup = await backupExisting(dshHome, id);
await removeOfficialSkill(dshHome, id);
try {
await copyDir(srcDir, dest);
const destMd = path.join(dest, "SKILL.md");
if (!(await pathExists(destMd))) throw new Error("导入后缺少 SKILL.md");
if (parsed.ok && parsed.name !== id) {
await fsp.writeFile(destMd, mergeSkillDocument(id, parsed.description, raw), "utf8");
} else if (!parsed.ok && !String(raw).replace(/^\uFEFF/, "").startsWith("---")) {
await fsp.writeFile(destMd, renderSkillDocument(id, id, raw), "utf8");
}
return { id, path: destMd, backup, description: parsed.ok ? parsed.description : id };
} catch (err) {
await fsp.rm(dest, { recursive: true, force: true }).catch(() => {});
await restoreSkillFromBackup(dshHome, id, backup);
throw err;
}
}
async function installFlatFile(dshHome, srcFile, fallbackId) {
assertImportSourceOutsideSkills(dshHome, srcFile);
const raw = await fsp.readFile(srcFile, "utf8");
const parsed = parseSkillDocument(raw);
const id = parsed.ok ? parsed.name : kebabFromLabel(fallbackId || path.basename(srcFile, ".md"));
if (!parsed.ok) throw new Error(parsed.error);
return saveSkill(dshHome, id, raw, parsed.description);
}
async function installFoundSkills(dshHome, root, fallbackId) {
assertImportSourceOutsideSkills(dshHome, root);
const found = await findSkillRoots(root);
const imported = [];
for (const dir of found.dirs) {
imported.push(await installBundleDir(dshHome, dir, fallbackId || path.basename(dir)));
}
if (!imported.length) {
for (const file of found.flats) {
imported.push(await installFlatFile(dshHome, file, fallbackId));
}
}
if (!imported.length) throw new Error("未找到 SKILL.md 或合法的 Skill 文件");
return imported;
}
export async function importArchive(dshHome, buffer, filename = "skill.zip") {
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
if (!bytes.length) throw new Error("压缩包是空的");
if (bytes.length > MAX_ARCHIVE_BYTES) throw new Error("压缩包太大(最大 12MB)");
const temp = await fsp.mkdtemp(path.join(os.tmpdir(), "dshp-skill-"));
const archive = path.join(temp, path.basename(filename) || "skill.zip");
const unpacked = path.join(temp, "unpacked");
try {
await fsp.mkdir(unpacked, { recursive: true });
await fsp.writeFile(archive, bytes);
extractArchive(archive, unpacked);
let total = 0;
await walkFiles(unpacked, async (fp) => {
// 非 ENOENT 的 fs 错误要 rethrow,否则磁盘损坏 / EACCES / EMFILE 都被吞,
// size 累计漏算,导入看似成功但产物坏。ENOENT 可能是枚举后被外部删了,安全跳过。
let st;
try { st = await fsp.stat(fp); }
catch (err) {
if (err && err.code === "ENOENT") return;
throw err;
}
total += st.size;
if (total > MAX_UNPACKED_BYTES) {
throw new Error(`压缩包解压后超过 ${Math.floor(MAX_UNPACKED_BYTES / 1024 / 1024)}MB`);
}
});
const imported = await installFoundSkills(dshHome, unpacked, kebabFromLabel(filename));
return { imported: imported.map((item) => toPublicSkill(dshHome, item)) };
} finally {
await fsp.rm(temp, { recursive: true, force: true }).catch(() => {});
}
}
export async function importFileTree(dshHome, files, fallbackId = "") {
if (!Array.isArray(files) || !files.length) throw new Error("文件夹是空的");
if (files.length > MAX_IMPORT_FILES) throw new Error("文件夹里文件太多");
const temp = await fsp.mkdtemp(path.join(os.tmpdir(), "dshp-skill-"));
try {
let total = 0;
for (const file of files) {
const rel = String(file?.path || file?.name || "").replace(/\\/g, "/").replace(/^\/+/, "");
if (!rel || rel.includes("..") || path.isAbsolute(rel) || isUnsafeArchiveEntry(rel)) {
throw new Error("文件夹路径不合法");
}
const dest = path.join(temp, rel);
assertInside(temp, dest);
await fsp.mkdir(path.dirname(dest), { recursive: true });
const text = typeof file.content === "string" ? file.content : "";
const raw = file.encoding === "base64" ? Buffer.from(text, "base64") : Buffer.from(text, "utf8");
total += raw.length;
if (raw.length > MAX_ARCHIVE_BYTES || total > MAX_ARCHIVE_BYTES) throw new Error("文件太大");
await fsp.writeFile(dest, raw);
}
const imported = await installFoundSkills(dshHome, temp, fallbackId);
return { imported: imported.map((item) => toPublicSkill(dshHome, item)) };
} finally {
await fsp.rm(temp, { recursive: true, force: true }).catch(() => {});
}
}
export async function importFromPath(dshHome, sourcePath) {
const src = path.resolve(String(sourcePath || ""));
const stat = await fsp.stat(src);
if (stat.isDirectory()) {
const imported = await installFoundSkills(dshHome, src, kebabFromLabel(path.basename(src)));
return { imported: imported.map((item) => toPublicSkill(dshHome, item)) };
}
if (stat.isFile()) {
const name = path.basename(src);
if (/\.(zip|tgz|tar\.gz|tar|skill)$/i.test(name)) {
return importArchive(dshHome, await fsp.readFile(src), name);
}
return { imported: [toPublicSkill(dshHome, await installFlatFile(dshHome, src, kebabFromLabel(name)))] };
}
throw new Error("只能导入压缩包或文件夹");
}
export async function skillsStatus(dshHome) {
const listed = await listSkills(dshHome);
return {
skills_dir: "$DSH_HOME/skills",
skills: listed.map((item) => ({
id: item.id,
description: item.description,
kind: item.kind,
valid: item.valid,
error: item.error,
size: item.size,
path: displayUnderHome(dshHome, item.path),
})),
};
}