初始发布: 21 个 skills (Claude Code / Codex / DSH)

This commit is contained in:
2026-08-14 01:51:44 +08:00
commit ea0857bedb
129 changed files with 35566 additions and 0 deletions
+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* aipage_pack.js — 把一个本地 HTML 目录(或单文件)打包成符合 aicanvas
* McpImport 规范的 .aipage 压缩包,供 tencent-docs MCP 的导入流程使用。
*
* 设计原则:
* - tencent-docs skill 自持「打包 + 导入」全链路,
* 上游 skill(如 smart-page)只负责输出 HTML,不再关心 manifest/zip。
* - 跨平台:纯 Node.js>= 14),零 npm 依赖。
* 手写 ZIPstore 模式,method=0),同时兼容 macOS / Linux / Windows
* 原生 cmd / PowerShell(无需 bash / Git Bash / WSL)。
*
* Usage:
* node aipage_pack.js --html <html_path> [--title <title>] [--output <out_path>]
* node aipage_pack.js --dir <html_dir> [--title <title>] [--output <out_path>]
*
* Behaviors:
* 1. 创建临时打包目录
* 2. 将入口 HTML 复制为 index.htmlaipage 硬要求)
* 3. 复制同级 assets/ 目录(如存在),目录模式下复制整个目录的全部文件
* 4. 生成 manifest.json(标题安全转义;未传 --title 时自动从 <title> 提取,
* 再 fallback 用文件名/目录名)
* 5. 生成 janus.manifest.json(固定内容)
* 6. 扁平化 zip 打包(zip 内无顶层目录),后缀强制为 .aipage
* 7. 输出结构化结果(供 SKILL 内 agent 直接解析):
* AIPAGE_PATH=...
* AIPAGE_SIZE=...
* AIPAGE_MD5=...
* AIPAGE_TITLE=...
*
* Exit codes:
* 0 成功
* 1 参数错误
* 2 源 HTML / 目录不存在或不合法
* 3 打包失败 / 内部错误
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const zlib = require('zlib');
// ─────────────────────────────────────────────────────────────────────────────
// 1. 参数解析
// ─────────────────────────────────────────────────────────────────────────────
function usage(exitCode) {
const msg = [
'Usage:',
' node aipage_pack.js --html <html_path> [--title <title>] [--output <out_path>]',
' node aipage_pack.js --dir <html_dir> [--title <title>] [--output <out_path>]',
'',
' --html 单个 HTML 文件路径(推荐:smart-page 等上游产物)',
' --dir 已组织好的 HTML 目录路径,目录中必须有且仅有一个 .html / .htm 入口',
' --title 可选,文档标题;缺省时自动读 <title> 标签,再 fallback 用文件名/目录名',
' --output 可选,输出 .aipage 路径;缺省为 <tmpdir>/<stem>.aipage',
'',
'示例:',
' node aipage_pack.js --html "output/立项方案.html"',
' node aipage_pack.js --html "output/邀请函.html" --title "邀请函" --output /tmp/x.aipage',
' node aipage_pack.js --dir "output/site" --title "站点演示"',
'',
].join('\n');
process.stderr.write(msg);
process.exit(typeof exitCode === 'number' ? exitCode : 1);
}
function parseArgs(argv) {
const opts = { html: '', dir: '', title: '', output: '' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case '--html':
opts.html = argv[++i] || '';
break;
case '--dir':
opts.dir = argv[++i] || '';
break;
case '--title':
opts.title = argv[++i] || '';
break;
case '--output':
opts.output = argv[++i] || '';
break;
case '-h':
case '--help':
usage(0);
break;
default:
process.stderr.write(`aipage_pack.js: unknown argument: ${a}\n`);
usage(1);
}
}
return opts;
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. 工具函数
// ─────────────────────────────────────────────────────────────────────────────
function fail(code, msg) {
process.stderr.write(`aipage_pack.js: ${msg}\n`);
process.exit(code);
}
function md5OfFile(filePath) {
const h = crypto.createHash('md5');
h.update(fs.readFileSync(filePath));
return h.digest('hex');
}
function sizeOfFile(filePath) {
return fs.statSync(filePath).size;
}
function extractHtmlTitle(htmlPath) {
let html = '';
try {
html = fs.readFileSync(htmlPath, 'utf8');
} catch (_) {
return '';
}
const m = html.match(/<title>([\s\S]*?)<\/title>/i);
return m ? m[1].trim() : '';
}
function rmrf(p) {
if (!fs.existsSync(p)) return;
// Node 14.14+ 支持 rmSync({recursive:true}); 兼容更早版本回退到 rmdirSync
try {
fs.rmSync(p, { recursive: true, force: true });
} catch (_) {
fs.rmdirSync(p, { recursive: true });
}
}
function mkTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function copyFile(src, dst) {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
}
// 递归复制目录内容到 dstDir(不包含 dstDir 自身的创建)
function copyDirContents(srcDir, dstDir) {
fs.mkdirSync(dstDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const ent of entries) {
const sp = path.join(srcDir, ent.name);
const dp = path.join(dstDir, ent.name);
if (ent.isDirectory()) {
copyDirContents(sp, dp);
} else if (ent.isFile()) {
fs.copyFileSync(sp, dp);
}
// 软链/特殊文件直接忽略,避免打入压缩包污染
}
}
// 递归收集打包目录下所有相对路径(POSIX 风格,给 zip 用)
function listFilesRel(rootDir) {
const result = [];
function walk(absDir, relDir) {
const entries = fs.readdirSync(absDir, { withFileTypes: true });
for (const ent of entries) {
const abs = path.join(absDir, ent.name);
const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
if (shouldExcludeName(ent.name)) continue;
if (ent.isDirectory()) {
walk(abs, rel);
} else if (ent.isFile()) {
result.push({ abs, rel });
}
}
}
walk(rootDir, '');
return result;
}
// 排除 macOS / Windows 副产物(与原 .sh 保持一致)
function shouldExcludeName(name) {
if (name === '__MACOSX') return true;
if (name === '.DS_Store') return true;
if (name === 'Thumbs.db') return true;
if (name.startsWith('._')) return true;
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. 手写 ZIPstore 模式 + DEFLATE 模式自动选择,扁平、无目录条目、无外部依赖)
// 采用 ZIP 标准(PKZIP appnote 6.3.x),仅使用 method=0/8、CRC32、本地头/中央目录头/EOCD。
// 不支持 Zip64aipage 单文件不会大到需要 Zip64)。
// ─────────────────────────────────────────────────────────────────────────────
// CRC32(标准多项式 0xEDB88320),构建查表,处理 Buffer
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
// 把 JS Date 转为 DOS 时间/日期
function toDosDateTime(date) {
const year = Math.max(1980, date.getFullYear());
const dosTime =
((date.getHours() & 0x1f) << 11) |
((date.getMinutes() & 0x3f) << 5) |
((Math.floor(date.getSeconds() / 2)) & 0x1f);
const dosDate =
(((year - 1980) & 0x7f) << 9) |
(((date.getMonth() + 1) & 0x0f) << 5) |
(date.getDate() & 0x1f);
return { dosTime, dosDate };
}
/**
* 创建符合 aipage 要求的扁平 zip。
* @param {string} outPath 输出 .aipage 文件路径
* @param {{abs:string, rel:string}[]} files 待打包文件列表,rel 必须是 POSIX 风格相对路径
*/
function buildZip(outPath, files) {
const localChunks = [];
const centralChunks = [];
let offset = 0;
const now = new Date();
const { dosTime, dosDate } = toDosDateTime(now);
for (const f of files) {
const data = fs.readFileSync(f.abs);
const nameBuf = Buffer.from(f.rel, 'utf8');
const crc = crc32(data);
const uncompressedSize = data.length;
// 选择压缩算法:默认 DEFLATE(method=8),但若压缩反而变大则回退到 STORE(method=0
let method = 8;
let compressed = zlib.deflateRawSync(data, { level: 9 });
if (compressed.length >= uncompressedSize) {
method = 0;
compressed = data;
}
const compressedSize = compressed.length;
// ── Local file header (30 bytes + name + extra(0))
const lfh = Buffer.alloc(30);
lfh.writeUInt32LE(0x04034b50, 0); // signature
lfh.writeUInt16LE(20, 4); // version needed
// bit 11: UTF-8 file name; 其它位为 0(无加密、无 data descriptor
lfh.writeUInt16LE(0x0800, 6); // general purpose bit flag
lfh.writeUInt16LE(method, 8); // compression method
lfh.writeUInt16LE(dosTime, 10);
lfh.writeUInt16LE(dosDate, 12);
lfh.writeUInt32LE(crc, 14);
lfh.writeUInt32LE(compressedSize, 18);
lfh.writeUInt32LE(uncompressedSize, 22);
lfh.writeUInt16LE(nameBuf.length, 26);
lfh.writeUInt16LE(0, 28); // extra length
localChunks.push(lfh, nameBuf, compressed);
// ── Central directory header (46 bytes + name + extra(0) + comment(0))
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0); // signature
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0x0800, 8); // general purpose bit flag
cdh.writeUInt16LE(method, 10); // compression method
cdh.writeUInt16LE(dosTime, 12);
cdh.writeUInt16LE(dosDate, 14);
cdh.writeUInt32LE(crc, 16);
cdh.writeUInt32LE(compressedSize, 20);
cdh.writeUInt32LE(uncompressedSize, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra length
cdh.writeUInt16LE(0, 32); // comment length
cdh.writeUInt16LE(0, 34); // disk number start
cdh.writeUInt16LE(0, 36); // internal file attrs
cdh.writeUInt32LE(0, 38); // external file attrs
cdh.writeUInt32LE(offset, 42); // relative offset of local header
centralChunks.push(cdh, nameBuf);
offset += lfh.length + nameBuf.length + compressed.length;
}
const centralStart = offset;
const centralBuf = Buffer.concat(centralChunks);
const centralSize = centralBuf.length;
// ── End of central directory record
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // signature
eocd.writeUInt16LE(0, 4); // disk number
eocd.writeUInt16LE(0, 6); // disk with central dir
eocd.writeUInt16LE(files.length, 8); // entries on this disk
eocd.writeUInt16LE(files.length, 10); // total entries
eocd.writeUInt32LE(centralSize, 12); // central dir size
eocd.writeUInt32LE(centralStart, 16); // central dir offset
eocd.writeUInt16LE(0, 20); // comment length
const out = Buffer.concat([Buffer.concat(localChunks), centralBuf, eocd]);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, out);
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. 主流程
// ─────────────────────────────────────────────────────────────────────────────
function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.html && opts.dir) {
process.stderr.write('aipage_pack.js: --html 与 --dir 二选一,不可同时使用\n');
usage(1);
}
if (!opts.html && !opts.dir) {
process.stderr.write('aipage_pack.js: 必须指定 --html 或 --dir\n');
usage(1);
}
// ── 创建临时打包目录 ────────────────────────────────────────────────
const packDir = mkTempDir('aipage_pack_');
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
rmrf(packDir);
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
let stem = '';
let entryHtmlInPack = '';
try {
if (opts.html) {
if (!fs.existsSync(opts.html) || !fs.statSync(opts.html).isFile()) {
fail(2, `HTML 不存在: ${opts.html}`);
}
const htmlAbs = path.resolve(opts.html);
const htmlAbsDir = path.dirname(htmlAbs);
const base = path.basename(htmlAbs);
const ext = path.extname(base).toLowerCase();
stem = (ext === '.html' || ext === '.htm') ? base.slice(0, -ext.length) : base;
copyFile(htmlAbs, path.join(packDir, 'index.html'));
const assetsDir = path.join(htmlAbsDir, 'assets');
if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
copyDirContents(assetsDir, path.join(packDir, 'assets'));
}
entryHtmlInPack = path.join(packDir, 'index.html');
} else {
if (!fs.existsSync(opts.dir) || !fs.statSync(opts.dir).isDirectory()) {
fail(2, `目录不存在: ${opts.dir}`);
}
const htmlDirAbs = path.resolve(opts.dir);
// 找入口 HTML:优先 index.html / index.htm,其次唯一 *.html / *.htm
let entry = '';
if (fs.existsSync(path.join(htmlDirAbs, 'index.html'))) {
entry = path.join(htmlDirAbs, 'index.html');
} else if (fs.existsSync(path.join(htmlDirAbs, 'index.htm'))) {
entry = path.join(htmlDirAbs, 'index.htm');
} else {
const candidates = fs.readdirSync(htmlDirAbs)
.filter((n) => /\.html?$/i.test(n))
.map((n) => path.join(htmlDirAbs, n))
.filter((p) => fs.statSync(p).isFile());
if (candidates.length === 1) {
entry = candidates[0];
} else if (candidates.length === 0) {
fail(2, `目录下找不到 HTML 入口: ${htmlDirAbs}`);
} else {
fail(2, `目录下存在多个 HTML,请显式 --html 指定: ${candidates.join(' ')}`);
}
}
stem = path.basename(htmlDirAbs);
copyDirContents(htmlDirAbs, packDir);
const entryName = path.basename(entry);
if (entryName !== 'index.html') {
const src = path.join(packDir, entryName);
const dst = path.join(packDir, 'index.html');
// 入口归一化为 index.html;如果同名 index.html 与入口同名(理论不会到这里)则跳过
if (src !== dst) {
fs.renameSync(src, dst);
}
}
entryHtmlInPack = path.join(packDir, 'index.html');
}
// ── 推导 TITLE ──────────────────────────────────────────────────
let title = opts.title;
if (!title) {
title = extractHtmlTitle(entryHtmlInPack) || stem;
}
// ── 默认 OUT ───────────────────────────────────────────────────
let zipOut = opts.output;
if (!zipOut) {
zipOut = path.join(os.tmpdir(), `${stem}.aipage`);
}
// 后缀强制 .aipage
const lower = zipOut.toLowerCase();
if (lower.endsWith('.aipage')) {
// 保持原样
} else if (lower.endsWith('.page')) {
zipOut = zipOut.slice(0, -'.page'.length) + '.aipage';
} else if (lower.endsWith('.zip')) {
zipOut = zipOut.slice(0, -'.zip'.length) + '.aipage';
} else {
zipOut = zipOut + '.aipage';
}
fs.mkdirSync(path.dirname(zipOut), { recursive: true });
if (fs.existsSync(zipOut)) {
fs.unlinkSync(zipOut);
}
// ── 生成 manifest.json ────────────────────────────────────────
const manifest = { entry: 'index.html', title, version: '1.0' };
fs.writeFileSync(
path.join(packDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
'utf8',
);
// ── 生成 janus.manifest.json(固定内容)─────────────────────
fs.writeFileSync(
path.join(packDir, 'janus.manifest.json'),
'{"version":"1.0.0","render_engine":"native","scene":""}',
'utf8',
);
// ── 校验 ─────────────────────────────────────────────────────
if (!fs.existsSync(path.join(packDir, 'index.html'))) {
fail(3, 'missing index.html');
}
if (!fs.existsSync(path.join(packDir, 'manifest.json'))) {
fail(3, 'missing manifest.json');
}
try {
JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
} catch (_) {
fail(3, 'manifest.json 不是合法 JSON');
}
// ── 打包(扁平化)────────────────────────────────────────────
const files = listFilesRel(packDir);
if (files.length === 0) {
fail(3, '打包目录为空');
}
buildZip(zipOut, files);
if (!fs.existsSync(zipOut)) {
fail(3, `zip 没产出: ${zipOut}`);
}
const size = sizeOfFile(zipOut);
const md5 = md5OfFile(zipOut);
// ── 结构化输出(与 .sh 完全一致)────────────────────────────
process.stdout.write(`AIPAGE_PATH=${zipOut}\n`);
process.stdout.write(`AIPAGE_SIZE=${size}\n`);
process.stdout.write(`AIPAGE_MD5=${md5}\n`);
process.stdout.write(`AIPAGE_TITLE=${title}\n`);
} finally {
cleanup();
}
}
main();