k-skill/scripts/generate-plugin-manifest.js
seungwonme 3ba8899f63 feat(plugin): Claude Code 플러그인 + 마켓플레이스 매니페스트 추가
디렉토리 구조와 npm/changesets 릴리스 흐름을 건드리지 않고 루트의
한국 특화 Agent Skill 모음을 Claude Code 플러그인으로 설치 가능하게 한다.

- .claude-plugin/plugin.json: skills 배열로 루트 89개 skill을 노출하는 단일 번들
  (지원 중단된 blue-ribbon-nearby 제외)
- .claude-plugin/marketplace.json: repo 자체를 마켓플레이스로, 번들 플러그인 1개(source ./)
- scripts/generate-plugin-manifest.js: SKILL.md 스캔으로 skills 배열 생성, --check drift 게이트
- scripts/test_generate_plugin_manifest.js: discovery/제외/drift node --test 6케이스
- scripts/validate-skills.sh: dot 디렉토리 개별 나열을 '! -name .*'로 일반화 (.claude-plugin 오인 방지)
- package.json: generate:plugin-manifest 스크립트 + lint/test 등록, lint 끝에 drift 게이트
- README.md: 플러그인 설치 안내 추가

설치: /plugin marketplace add NomaDamas/k-skill -> /plugin install k-skill@k-skill
2026-05-27 23:27:56 +09:00

179 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Generate / refresh the Claude Code plugin manifest's `skills` list.
*
* This repo is a flat collection of `<skill-name>/SKILL.md` directories at the
* repo root (NOT under a `skills/` folder), because the npm workspaces +
* changesets release pipeline depends on that layout. A Claude Code plugin can
* still expose them by listing each skill directory in the `skills` array of
* `.claude-plugin/plugin.json` (the field accepts custom directory paths in
* addition to the default `skills/` dir).
*
* Skill discovery mirrors scripts/validate-skills.sh and
* scripts/build-manus-bundle.js. This script writes the sorted `skills` array
* into `.claude-plugin/plugin.json` while preserving every other field.
*
* Usage:
* node scripts/generate-plugin-manifest.js # write/update plugin.json
* node scripts/generate-plugin-manifest.js --check # exit 1 if out of date
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
// Root-level directories that are never skills. Superset of the exclusion
// lists in scripts/validate-skills.sh and scripts/build-manus-bundle.js so
// that test fixtures under tools/ never leak in. Dot-directories are excluded
// unconditionally below; they are listed here only for documentation.
const EXCLUDED_DIRS = new Set([
".git",
".github",
".codex",
".claude",
".omc",
".omx",
".ouroboros",
".changeset",
".cursor",
".vscode",
".sisyphus",
".idea",
"docs",
"dist",
"node_modules",
"packages",
"python-packages",
"scripts",
"examples",
"tools",
]);
// Skills that exist on disk but must not ship in the plugin (e.g. upstream
// blocked automation and the skill no longer works).
const EXCLUDED_SKILLS = new Set(["blue-ribbon-nearby"]);
// Identity fields used when the manifest does not exist yet. Existing values
// are never overwritten; only missing keys are backfilled.
const DEFAULT_MANIFEST = {
name: "k-skill",
description:
"한국인을 위한 90+ Agent Skill 모음 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화",
version: "1.0.0",
author: { name: "NomaDamas" },
homepage: "https://github.com/NomaDamas/k-skill",
repository: "https://github.com/NomaDamas/k-skill",
license: "MIT",
skills: [],
};
function manifestPathFor(root) {
return path.join(root, ".claude-plugin", "plugin.json");
}
/**
* Discover skill directories (those containing a SKILL.md) directly under
* `root`, returning sorted plugin-relative paths like `./lotto-results`.
*/
function discoverSkillPaths(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
const skills = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
if (EXCLUDED_DIRS.has(entry.name)) continue;
if (EXCLUDED_SKILLS.has(entry.name)) continue;
const skillMd = path.join(root, entry.name, "SKILL.md");
if (fs.existsSync(skillMd)) {
skills.push(`./${entry.name}`);
}
}
skills.sort();
return skills;
}
/** Build the manifest object, preserving existing fields and refreshing skills. */
function buildManifest(root) {
const manifestPath = manifestPathFor(root);
let manifest = { ...DEFAULT_MANIFEST };
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const [key, value] of Object.entries(DEFAULT_MANIFEST)) {
if (key === "skills") continue;
if (manifest[key] === undefined) manifest[key] = value;
}
}
manifest.skills = discoverSkillPaths(root);
return manifest;
}
function serialize(manifest) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
/**
* Core entry point usable from tests.
* @returns {{ ok: boolean, manifest: object, current: string, next: string, written?: boolean }}
*/
function run({ root = repoRoot, check = false } = {}) {
const manifestPath = manifestPathFor(root);
const manifest = buildManifest(root);
const next = serialize(manifest);
const current = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath, "utf8") : "";
if (check) {
return { ok: current === next, manifest, current, next };
}
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(manifestPath, next);
return { ok: true, manifest, current, next, written: true };
}
function main() {
const check = process.argv.includes("--check");
const result = run({ check });
const count = result.manifest.skills.length;
if (check) {
if (!result.ok) {
console.error(
"plugin.json is out of date. Run `node scripts/generate-plugin-manifest.js` and commit the result.",
);
let currentSkills = [];
try {
currentSkills = result.current ? JSON.parse(result.current).skills || [] : [];
} catch {
/* malformed current manifest; treat as empty for the diff */
}
const nextSkills = result.manifest.skills;
const added = nextSkills.filter((s) => !currentSkills.includes(s));
const removed = currentSkills.filter((s) => !nextSkills.includes(s));
if (added.length) console.error(` + ${added.join(", ")}`);
if (removed.length) console.error(` - ${removed.join(", ")}`);
process.exit(1);
}
console.log(`plugin.json is up to date (${count} skills).`);
return;
}
console.log(`Wrote .claude-plugin/plugin.json with ${count} skills.`);
}
if (require.main === module) {
main();
}
module.exports = {
EXCLUDED_DIRS,
EXCLUDED_SKILLS,
DEFAULT_MANIFEST,
discoverSkillPaths,
buildManifest,
serialize,
run,
manifestPathFor,
};