skill-packager.js 5.5 KB
/**
 * 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<void>}
   */
  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 };