k-skill/scripts/build-manus-bundle.js
Jeffrey (Dongkyu) Kim f348cb4f85
feat: Manus.ai 호환 import 경로 추가 (GitHub URL + rolling .skill 번들) (#227)
* docs: add Manus.ai GitHub skill import guide

Manus.ai의 'GitHub에서 프로젝트 스킬 가져오기' 기능은 폴더 루트에 SKILL.md(YAML frontmatter name/description 필수)가 있는 디렉토리 URL을 받는다. k-skill의 모든 스킬은 이미 이 포맷을 만족하므로 코드 변경 없이 문서만 추가한다.

- 사용자는 저장소 루트 URL(https://github.com/NomaDamas/k-skill) 대신 개별 스킬 폴더 URL(https://github.com/NomaDamas/k-skill/tree/main/<skill-name>)을 붙여 넣어야 한다.

- 기존 frontmatter(license, metadata.*)는 Manus가 무시하지만 다른 코딩 에이전트와의 호환을 위해 그대로 유지한다.

* feat: add build:manus-bundle for batch .skill upload to Manus.ai

Per-folder GitHub URL import is tedious for 61 skills, so add 'npm run build:manus-bundle' which emits one .skill (ZIP) per skill into dist/manus/, plus a single k-skill-manus-all.zip convenience bundle and an INDEX.md listing. Each archive nests its content under <skill-name>/ to match the public Anthropic skill-creator packager layout.

Manus does NOT support multi-skill bulk import in a single archive (verified against help.manus.im, manus.im/docs, and open.manus.ai API docs). The combined zip is purely a download convenience: users still drag-drop individual .skill files into Manus, but the file picker accepts multiple selections so it's still much faster than pasting 61 GitHub URLs.

- scripts/build-manus-bundle.js: discovers root-level skills (mirrors validate-skills.sh exclusions), shells out to system zip with -X for reproducible archives, excludes node_modules/__pycache__/.DS_Store.

- scripts/test_build_manus_bundle.js: validates discovery, frontmatter parsing, lockstep with validate-skills.sh, and docs coverage.

- scripts/validate-skills.sh: also skip dist/ and .sisyphus/ so the validator stays clean after a build.

- .gitignore: ignore dist/ and .sisyphus/.

- docs/install-manus.md: document both Method A (GitHub URL) and Method B (.skill bundle).

* ci: auto-publish Manus .skill bundle as rolling release on main push

Every push to main that touches a skill folder or the bundler now builds the .skill bundle and publishes it to the GitHub Releases tag 'manus-bundle-latest' (marked prerelease so it does not pollute the Latest release pointer used by the npm release flow).

Users get stable download URLs that always point to the latest build:

  - https://github.com/NomaDamas/k-skill/releases/download/manus-bundle-latest/k-skill-manus-all.zip

  - https://github.com/NomaDamas/k-skill/releases/download/manus-bundle-latest/INDEX.md

This removes the 'clone the repo and run npm' step for non-developers. The direct-build path remains documented as the developer fallback.

- .github/workflows/manus-bundle.yml: workflow_dispatch + push-to-main with paths filter, uses preinstalled gh CLI (no third-party release action), concurrency-grouped so overlapping pushes do not race on the same tag, --clobber upload to keep asset URLs stable.

- docs/install-manus.md: new 'quick path' section with the rolling-release URLs; existing local-build section reframed as a developer fallback.

- scripts/test_build_manus_bundle.js: 2 new tests pinning the doc URLs and key workflow invariants (trigger branch, build invocation, tag, asset name, prerelease flag, write permission).
2026-05-11 12:12:44 +09:00

220 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Build Manus.ai-compatible bundles for k-skill.
*
* Manus accepts ONE skill per upload (`.skill`/`.zip`/folder) and offers no
* multi-skill bulk import path, so this script emits one `.skill` per skill
* plus a single combined download archive.
*
* Each `.skill` archive contains a single top-level `<skill-name>/` folder
* that matches the layout produced by the public Anthropic skill-creator
* packager (https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py).
* That nested layout is load-bearing: flattening it breaks Manus import.
*
* Skill discovery mirrors `scripts/validate-skills.sh`. Requires the system
* `zip` command (preinstalled on macOS and GitHub Actions ubuntu-latest).
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
const distDir = path.join(repoRoot, "dist", "manus");
// Directories at the repo root that are NEVER skills, mirroring
// scripts/validate-skills.sh's exclusion list.
const EXCLUDED_DIRS = new Set([
".git",
".github",
".codex",
".claude",
".omx",
".ouroboros",
".changeset",
".cursor",
".vscode",
".sisyphus",
".idea",
"docs",
"dist",
"node_modules",
"packages",
"python-packages",
"scripts",
"examples",
]);
function ensureZipAvailable() {
const probe = spawnSync("zip", ["-v"], { stdio: "ignore" });
if (probe.error || probe.status !== 0) {
console.error(
"ERROR: the `zip` command is required to build Manus bundles.\n" +
" - macOS: preinstalled.\n" +
" - Debian/Ubuntu: sudo apt-get install -y zip\n" +
" - Windows: install via WSL or Git Bash, or use 7-Zip and zip the folders manually.",
);
process.exit(2);
}
}
function discoverSkills() {
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
const skills = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (EXCLUDED_DIRS.has(entry.name)) continue;
if (entry.name.startsWith(".")) continue;
const skillMd = path.join(repoRoot, entry.name, "SKILL.md");
if (fs.existsSync(skillMd)) {
skills.push(entry.name);
}
}
skills.sort();
return skills;
}
function readSkillMeta(skillName) {
const skillMd = path.join(repoRoot, skillName, "SKILL.md");
const raw = fs.readFileSync(skillMd, "utf8");
const match = raw.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { name: skillName, description: "" };
const fm = match[1];
const grab = (key) => {
const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
return m ? m[1].trim().replace(/^["']|["']$/g, "") : "";
};
return {
name: grab("name") || skillName,
description: grab("description"),
};
}
function rimrafSync(target) {
if (!fs.existsSync(target)) return;
fs.rmSync(target, { recursive: true, force: true });
}
function buildSkillArchive(skillName) {
const archivePath = path.join(distDir, `${skillName}.skill`);
rimrafSync(archivePath);
// zip is run from the repo root and asked to add the whole `<skillName>/`
// folder; the resulting archive therefore has `<skillName>/SKILL.md` etc. at
// its root, which matches the public Anthropic packager layout.
const result = spawnSync(
"zip",
[
"-r",
"-q",
"-X", // strip extra file attributes for reproducible archives
archivePath,
skillName,
"-x",
`${skillName}/node_modules/*`,
"-x",
`${skillName}/__pycache__/*`,
"-x",
`${skillName}/*/__pycache__/*`,
"-x",
`${skillName}/.DS_Store`,
"-x",
`${skillName}/*/.DS_Store`,
],
{ cwd: repoRoot, stdio: ["ignore", "inherit", "inherit"] },
);
if (result.status !== 0) {
throw new Error(`zip failed for ${skillName} (exit ${result.status})`);
}
return archivePath;
}
function buildAllInOneArchive(skillNames) {
// Bundle all the .skill files together so users can download a single
// release asset and then drag-drop the individual .skill files into Manus.
const allInOne = path.join(distDir, "k-skill-manus-all.zip");
rimrafSync(allInOne);
const relativeNames = skillNames.map((s) => `${s}.skill`);
relativeNames.push("INDEX.md");
const result = spawnSync("zip", ["-q", "-X", "-j", allInOne, ...relativeNames.map((n) => path.join(distDir, n))], {
cwd: distDir,
stdio: ["ignore", "inherit", "inherit"],
});
if (result.status !== 0) {
throw new Error(`zip failed for k-skill-manus-all.zip (exit ${result.status})`);
}
return allInOne;
}
function writeIndex(skillMetas) {
const lines = [];
lines.push("# k-skill — Manus.ai 가져오기용 번들");
lines.push("");
lines.push("이 폴더에는 NomaDamas/k-skill 의 모든 스킬이 Manus.ai 호환 `.skill` 아카이브로 빌드되어 있다.");
lines.push("");
lines.push("## 사용 방법");
lines.push("");
lines.push("1. Manus.ai 에서 **스킬 업로드** 화면을 연다.");
lines.push("2. 원하는 `<skill-name>.skill` 파일을 드래그-드롭하거나 파일 선택으로 업로드한다.");
lines.push("3. 한 번의 업로드는 한 개의 스킬을 등록한다. 필요한 스킬만큼 반복한다.");
lines.push("");
lines.push("`.skill` 파일은 사실상 ZIP 아카이브이며, 내부에는 단일 최상위 폴더 `<skill-name>/`(SKILL.md + 보조 리소스)가 들어 있다.");
lines.push("");
lines.push("Manus.ai 는 **하나의 아카이브로 여러 스킬을 한꺼번에 등록하는 기능을 공식 지원하지 않는다.** `k-skill-manus-all.zip` 은 단순히 모든 `.skill` 파일을 한 번에 받기 위한 편의 번들이다. 압축을 풀면 N개의 `.skill` 파일이 나오며 그 파일들을 Manus 에 하나씩 업로드해야 한다.");
lines.push("");
lines.push("## 포함된 스킬");
lines.push("");
lines.push("| 스킬 이름 | 설명 | 파일 |");
lines.push("| --- | --- | --- |");
for (const meta of skillMetas) {
const desc = (meta.description || "").replace(/\|/g, "\\|");
lines.push(`| \`${meta.name}\` | ${desc} | \`${meta.name}.skill\` |`);
}
lines.push("");
lines.push(`${skillMetas.length}개 스킬.`);
lines.push("");
fs.writeFileSync(path.join(distDir, "INDEX.md"), lines.join("\n"));
}
function main() {
ensureZipAvailable();
rimrafSync(distDir);
fs.mkdirSync(distDir, { recursive: true });
const skills = discoverSkills();
if (skills.length === 0) {
console.error("ERROR: no skills with SKILL.md found at repo root.");
process.exit(1);
}
const metas = [];
for (const skill of skills) {
process.stdout.write(`packing ${skill}.skill ... `);
buildSkillArchive(skill);
metas.push(readSkillMeta(skill));
process.stdout.write("ok\n");
}
writeIndex(metas);
buildAllInOneArchive(skills);
console.log("");
console.log(`built ${skills.length} .skill files in ${path.relative(repoRoot, distDir)}/`);
console.log(`combined download: ${path.relative(repoRoot, path.join(distDir, "k-skill-manus-all.zip"))}`);
}
if (require.main === module) {
try {
main();
} catch (err) {
console.error(err.message || err);
process.exit(1);
}
}
module.exports = {
EXCLUDED_DIRS,
discoverSkills,
readSkillMeta,
};