import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
export function findPluginDirectory(repositoryRoot, parent, pluginId) {
const direct = join(repositoryRoot, parent, pluginId);
if (existsSync(join(direct, ".claude-plugin", "plugin.json"))) return direct;
const parentRoot = join(repositoryRoot, parent);
if (!existsSync(parentRoot)) return null;
const entries = readdirSync(parentRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const candidate = join(parentRoot, entry.name);
const manifest = join(candidate, ".claude-plugin", "plugin.json");
if (!existsSync(manifest)) continue;
try {
if (readJson(manifest).name === pluginId) return candidate;
} catch {
}
}
return null;
}
function isCommunityPlugin(pluginRoot) {
return basename(dirname(pluginRoot)) === "plugins-community";
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function assertInside(root, candidate, label) {
const pathFromRoot = relative(root, candidate);
if (pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot))) return;
throw new Error(`${label} escapes its repository root: ${candidate}`);
}
export function pluginSourceDefinition(repositoryRoot, pluginId, pluginDirectory = null) {
const pluginRoot = pluginDirectory ?? ["plugins-official", "plugins-community"]
.map((parent) => findPluginDirectory(repositoryRoot, parent, pluginId))
.find((candidate) => candidate && existsSync(join(candidate, "plugin-sources.json")))
?? join(repositoryRoot, "plugins-official", pluginId);
const definitionPath = join(pluginRoot, "plugin-sources.json");
if (!existsSync(definitionPath)) return null;
const definition = readJson(definitionPath);
if (!definition || typeof definition.skillsRepository !== "string" || !Array.isArray(definition.skills)) {
throw new Error(`invalid plugin source definition: ${definitionPath}`);
}
if (definition.skillInstallMode !== undefined && definition.skillInstallMode !== "symlink") {
throw new Error(`invalid Skill install mode in ${definitionPath}`);
}
if (definition.preserveSkillInvocationPolicy !== undefined
&& typeof definition.preserveSkillInvocationPolicy !== "boolean") {
throw new Error(`invalid preserveSkillInvocationPolicy in ${definitionPath}`);
}
return { pluginRoot, definitionPath, ...definition };
}
export function assemblePlugins(repositoryRoot, outputRoot, selectedPluginIds) {
const sourcePluginRoot = join(repositoryRoot, "plugins-official");
const pluginIds = selectedPluginIds ?? ["plugins-official", "plugins-community"].flatMap((parent) => {
const root = join(repositoryRoot, parent);
return existsSync(root) ? readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "plugin-sources.json")))
.map((entry) => entry.name) : [];
}).sort();
if (new Set(pluginIds).size !== pluginIds.length) throw new Error("duplicate plugin ids");
mkdirSync(join(outputRoot, "plugins"), { recursive: true });
const pluginLicense = join(sourcePluginRoot, "LICENSE");
if (!existsSync(pluginLicense)) throw new Error(`plugin license is missing: ${pluginLicense}`);
cpSync(pluginLicense, join(outputRoot, "plugins", "LICENSE"));
const assembled = [];
for (const pluginId of pluginIds) {
const source = pluginSourceDefinition(repositoryRoot, pluginId);
if (!source) throw new Error(`plugin has no source definition: ${pluginId}`);
const manifestPath = join(source.pluginRoot, ".claude-plugin", "plugin.json");
const manifest = readJson(manifestPath);
if (typeof manifest.name !== "string" || !/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(manifest.name)) {
throw new Error(`invalid plugin manifest name: ${manifestPath}`);
}
const destination = join(outputRoot, "plugins", pluginId);
rmSync(destination, { recursive: true, force: true });
mkdirSync(destination, { recursive: true });
if (!isCommunityPlugin(source.pluginRoot)) cpSync(pluginLicense, join(destination, "LICENSE"));
for (const entry of readdirSync(source.pluginRoot, { withFileTypes: true })) {
if (entry.name === "skills" || entry.name === "plugin-sources.json") continue;
cpSync(join(source.pluginRoot, entry.name), join(destination, entry.name), {
recursive: true,
dereference: true,
});
}
const skillsRepository = resolve(repositoryRoot, source.skillsRepository);
if (!existsSync(join(skillsRepository, ".git"))) {
throw new Error(`Skill submodule is not initialized: ${source.skillsRepository}`);
}
const skillsLicense = join(skillsRepository, "LICENSE");
if (!existsSync(skillsLicense)) throw new Error(`Skill repository license is missing: ${skillsLicense}`);
cpSync(skillsLicense, join(destination, "SKILLS_LICENSE"));
const names = new Set();
const copySkill = (skillSource, skillName) => {
if (names.has(skillName)) throw new Error(`duplicate Skill name in ${pluginId}: ${skillName}`);
names.add(skillName);
const skillDestination = join(destination, "skills", skillName);
cpSync(skillSource, skillDestination, {
recursive: true,
dereference: true,
filter: (path) => basename(path) !== "__pycache__" && !path.endsWith(".pyc"),
});
if (source.preserveSkillInvocationPolicy === true) return;
const skillManifest = join(skillDestination, "SKILL.md");
const skillMd = readFileSync(skillManifest, "utf8")
.replace(/^disable-model-invocation:\s*true\s*$/m, "disable-model-invocation: false");
writeFileSync(skillManifest, skillMd);
};
for (const relativeSkill of source.skills) {
if (typeof relativeSkill !== "string" || !relativeSkill) {
throw new Error(`invalid Skill path in ${source.definitionPath}`);
}
const skillSource = resolve(skillsRepository, relativeSkill);
assertInside(skillsRepository, skillSource, "Skill path");
if (!existsSync(join(skillSource, "SKILL.md"))) {
throw new Error(`Skill is missing SKILL.md: ${relativeSkill}`);
}
copySkill(skillSource, basename(skillSource));
}
const localSkillsRoot = join(source.pluginRoot, "skills");
if (existsSync(localSkillsRoot)) {
for (const entry of readdirSync(localSkillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillSource = join(localSkillsRoot, entry.name);
if (!existsSync(join(skillSource, "SKILL.md"))) continue;
copySkill(skillSource, entry.name);
}
}
manifest.skills = [...names].map((name) => `./skills/${name}`);
delete manifest.dependencies;
writeFileSync(
join(destination, ".claude-plugin", "plugin.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
assembled.push({ pluginId: manifest.name, destination, skills: names.size });
}
return assembled;
}