/** * SkillPackager — 将 Skill 核心文件打包为 .zip 下载 * * 打包内容:SKILL.md, benchmark.md, 范例代码等 * 排除:hunter/, digest/, evolve/, node_modules, 日志文件 */ const fs = require('fs'); const path = require('path'); const archiver = require('archiver'); // 排除的目录和文件模式 const EXCLUDE_DIRS = new Set(['hunter', 'digest', 'evolve', 'node_modules', '.git', '__pycache__']); const EXCLUDE_EXTENSIONS = new Set(['.log', '.tmp', '.pyc']); const MAX_FILE_SIZE = 512 * 1024; // 512KB per file class SkillPackager { /** * @param {string} skillsDir Skills 根目录 */ constructor(skillsDir) { this.skillsDir = skillsDir; } /** * 打包 Skill 为 JSON bundle(用于 job result 等场景) * @param {string} skillName * @param {object} skillMeta 来自 SkillRegistry 的元数据 * @returns {{ manifest: object, files: Array<{path: string, content: string, size: number}> }} */ packageSkillJson(skillName, skillMeta = {}) { const skillDir = path.join(this.skillsDir, skillName); if (!fs.existsSync(skillDir)) { throw new Error(`Skill directory not found: ${skillName}`); } const skillMdPath = path.join(skillDir, 'SKILL.md'); if (!fs.existsSync(skillMdPath)) { throw new Error(`SKILL.md not found for: ${skillName}`); } const files = this._collectFiles(skillDir, ''); const manifest = this._buildManifest(skillName, skillMeta, files); return { manifest, files }; } /** * 打包 Skill 为 .zip 流(直接 pipe 到 HTTP 响应) * @param {string} skillName * @param {object} skillMeta * @param {import('stream').Writable} outputStream 通常是 res (Express response) * @returns {Promise} */ packageSkillZip(skillName, skillMeta = {}, outputStream) { return new Promise((resolve, reject) => { const skillDir = path.join(this.skillsDir, skillName); if (!fs.existsSync(skillDir)) { return reject(new Error(`Skill directory not found: ${skillName}`)); } const skillMdPath = path.join(skillDir, 'SKILL.md'); if (!fs.existsSync(skillMdPath)) { return reject(new Error(`SKILL.md not found for: ${skillName}`)); } const archive = archiver('zip', { zlib: { level: 6 } }); archive.on('error', reject); archive.on('end', resolve); archive.pipe(outputStream); // 添加 manifest.json const files = this._collectFiles(skillDir, ''); const manifest = this._buildManifest(skillName, skillMeta, files); archive.append(JSON.stringify(manifest, null, 2), { name: `${skillName}/manifest.json` }); // 递归添加文件到 zip(保留目录结构) this._addFilesToArchive(archive, skillDir, skillName); archive.finalize(); }); } /** * 递归将文件添加到 archiver * @private */ _addFilesToArchive(archive, dir, prefix, depth = 0) { if (depth > 2) return; // 最多 2 层 let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const absPath = path.join(dir, entry.name); const arcPath = `${prefix}/${entry.name}`; if (entry.isDirectory()) { if (EXCLUDE_DIRS.has(entry.name)) continue; this._addFilesToArchive(archive, absPath, arcPath, depth + 1); } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (EXCLUDE_EXTENSIONS.has(ext)) continue; try { const stat = fs.statSync(absPath); if (stat.size > MAX_FILE_SIZE) continue; archive.file(absPath, { name: arcPath }); } catch { // 跳过无法读取的文件 } } } } /** * 构建 manifest * @private */ _buildManifest(skillName, skillMeta, files) { return { skillName: skillMeta.skillName || skillName, version: skillMeta.version || '1.0.0', description: skillMeta.description || '', domain: skillMeta.domain || '', scope: skillMeta.scope || '', layer: skillMeta.layer || null, dependencies: skillMeta.dependencies || [], keywords: skillMeta.keywords || [], currentScore: skillMeta.currentScore || null, fileCount: files.length, totalSize: files.reduce((sum, f) => sum + f.size, 0), packagedAt: new Date().toISOString(), }; } /** * 递归收集可打包的文件(用于 JSON bundle 和 manifest 计算) * @private */ _collectFiles(dir, prefix) { const files = []; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; const absPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (EXCLUDE_DIRS.has(entry.name)) continue; if (prefix.split('/').length >= 2) continue; files.push(...this._collectFiles(absPath, relPath)); } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (EXCLUDE_EXTENSIONS.has(ext)) continue; try { const stat = fs.statSync(absPath); if (stat.size > MAX_FILE_SIZE) continue; const content = fs.readFileSync(absPath, 'utf-8'); files.push({ path: relPath, content, size: stat.size }); } catch { // 跳过无法读取的文件 } } } return files; } } module.exports = { SkillPackager };