617 lines
22 KiB
JavaScript
617 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* generate-skills.mjs — shared-source generator for agent variants (M3, S-302)
|
||
*
|
||
* Generates every agent-variant directory (`skills/<skill>/<agent>/`) and
|
||
* `pi-package/skills/<skill>/` mirror from canonical sources. Generated files
|
||
* carry file-type-aware headers; each generated root gets a non-self-referential
|
||
* `.generated-manifest.json`.
|
||
*
|
||
* Usage:
|
||
* node scripts/generate-skills.mjs # regenerate everything
|
||
* pnpm run sync:pi # same via pnpm alias
|
||
*
|
||
* Exported helpers (used by verify-generated.mjs and tests):
|
||
* detectFileType(filePath) → string
|
||
* applyHeader(content, fileType, canonicalHint) → string
|
||
* makePackageJsonContent(sourcePkg, skillName, agentName) → object
|
||
* getGeneratedRoots(repoRoot?) → string[]
|
||
* buildManifest(generatedRootAbs, generatedRootRel) → Promise<object>
|
||
* generateSkills(repoRoot, options?) → Promise<{generatedRoots: string[]}>
|
||
*/
|
||
|
||
import {
|
||
lstat,
|
||
mkdir,
|
||
readdir,
|
||
readFile,
|
||
rm,
|
||
writeFile,
|
||
} from "node:fs/promises";
|
||
import crypto from "node:crypto";
|
||
import path from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
|
||
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||
|
||
// ── Constants ──────────────────────────────────────────────────────────────
|
||
|
||
const MANIFEST_SCHEMA =
|
||
"https://ai-coding-skills.dev/schemas/generated-manifest/v1.json";
|
||
const MANIFEST_GENERATOR = "scripts/generate-skills.mjs";
|
||
const MANIFEST_FILENAME = ".generated-manifest.json";
|
||
|
||
const AGENTS = ["claude-code", "codex", "cursor", "opencode", "pi"];
|
||
|
||
/**
|
||
* Canonical list of all generated roots, relative to repo root.
|
||
* Verifier uses this to know which directories to walk.
|
||
*/
|
||
const GENERATED_ROOTS = [
|
||
// atlassian agent variants
|
||
"skills/atlassian/claude-code",
|
||
"skills/atlassian/codex",
|
||
"skills/atlassian/cursor",
|
||
"skills/atlassian/opencode",
|
||
"skills/atlassian/pi",
|
||
// web-automation agent variants
|
||
"skills/web-automation/claude-code",
|
||
"skills/web-automation/codex",
|
||
"skills/web-automation/cursor",
|
||
"skills/web-automation/opencode",
|
||
"skills/web-automation/pi",
|
||
// create-plan agent variants
|
||
"skills/create-plan/claude-code",
|
||
"skills/create-plan/codex",
|
||
"skills/create-plan/cursor",
|
||
"skills/create-plan/opencode",
|
||
"skills/create-plan/pi",
|
||
// do-task agent variants
|
||
"skills/do-task/claude-code",
|
||
"skills/do-task/codex",
|
||
"skills/do-task/cursor",
|
||
"skills/do-task/opencode",
|
||
"skills/do-task/pi",
|
||
// implement-plan agent variants
|
||
"skills/implement-plan/claude-code",
|
||
"skills/implement-plan/codex",
|
||
"skills/implement-plan/cursor",
|
||
"skills/implement-plan/opencode",
|
||
"skills/implement-plan/pi",
|
||
// reviewer-runtime pi variant
|
||
"skills/reviewer-runtime/pi",
|
||
// pi-package mirrors
|
||
"pi-package/skills/atlassian",
|
||
"pi-package/skills/create-plan",
|
||
"pi-package/skills/do-task",
|
||
"pi-package/skills/implement-plan",
|
||
"pi-package/skills/web-automation",
|
||
];
|
||
|
||
// ── File-type detection ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Classify a file path into a header policy category.
|
||
* @param {string} filePath - Relative or absolute path to the file.
|
||
* @returns {'markdown'|'shell'|'ts'|'js'|'json'|'yaml'|'jsonc'|'unknown'}
|
||
*/
|
||
export function detectFileType(filePath) {
|
||
const base = path.basename(filePath);
|
||
const ext = path.extname(base).toLowerCase();
|
||
|
||
if (ext === ".md") return "markdown";
|
||
if (ext === ".sh") return "shell";
|
||
if (ext === ".ts") return "ts";
|
||
if (ext === ".js") return "js";
|
||
if (ext === ".jsonc") return "jsonc";
|
||
if (ext === ".json") return "json";
|
||
if (ext === ".yaml" || ext === ".yml") return "yaml";
|
||
return "unknown";
|
||
}
|
||
|
||
// ── Header insertion ──────────────────────────────────────────────────────
|
||
|
||
const HEADER_MSG = (hint) =>
|
||
`⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in ${hint} and run \`pnpm run sync:pi\`.`;
|
||
|
||
/**
|
||
* Insert a file-type-aware generated-file header into content.
|
||
*
|
||
* Policy:
|
||
* markdown → HTML comment after YAML front matter (or at top if no front matter)
|
||
* shell → # comment after shebang (never before it)
|
||
* ts/js → // comment at top
|
||
* jsonc → // comment at top
|
||
* yaml → # comment at top (pnpm-lock.yaml skipped by caller)
|
||
* json → no header (recorded in .generated-manifest.json)
|
||
* unknown → no header
|
||
*
|
||
* @param {string} content - Original file content.
|
||
* @param {string} fileType - Output of detectFileType().
|
||
* @param {string} canonicalHint - Human-readable hint to canonical source location.
|
||
* @returns {string} Content with header inserted, or original if no header applies.
|
||
*/
|
||
export function applyHeader(content, fileType, canonicalHint) {
|
||
const msg = HEADER_MSG(canonicalHint);
|
||
|
||
switch (fileType) {
|
||
case "markdown": {
|
||
// Insert HTML comment after YAML front matter closing ---, or at top
|
||
if (content.startsWith("---\n")) {
|
||
const closingIdx = content.indexOf("\n---\n", 4);
|
||
if (closingIdx !== -1) {
|
||
const after = closingIdx + 5; // length of "\n---\n"
|
||
const before = content.slice(0, after);
|
||
const rest = content.slice(after);
|
||
// Ensure the comment is on its own line, with a blank line before/after
|
||
return `${before}\n<!-- ${msg} -->\n${rest}`;
|
||
}
|
||
}
|
||
return `<!-- ${msg} -->\n${content}`;
|
||
}
|
||
|
||
case "shell": {
|
||
// Insert # comment AFTER shebang line (never before it)
|
||
const lines = content.split("\n");
|
||
if (lines[0].startsWith("#!")) {
|
||
return [lines[0], `# ${msg}`, ...lines.slice(1)].join("\n");
|
||
}
|
||
return `# ${msg}\n${content}`;
|
||
}
|
||
|
||
case "ts":
|
||
case "js":
|
||
case "jsonc": {
|
||
// Insert after shebang if present (TypeScript requires #! on line 1)
|
||
const lines = content.split("\n");
|
||
if (lines[0].startsWith("#!")) {
|
||
return [lines[0], `// ${msg}`, ...lines.slice(1)].join("\n");
|
||
}
|
||
return `// ${msg}\n${content}`;
|
||
}
|
||
|
||
case "yaml": {
|
||
return `# ${msg}\n${content}`;
|
||
}
|
||
|
||
case "json":
|
||
case "unknown":
|
||
default:
|
||
return content;
|
||
}
|
||
}
|
||
|
||
// ── package.json transformation ───────────────────────────────────────────
|
||
|
||
/**
|
||
* Produce a modified package.json object with a unique scoped name and
|
||
* `"private": true` for an agent-variant generated root.
|
||
*
|
||
* @param {object} sourcePkg - Parsed source package.json object.
|
||
* @param {string} skillName - Skill identifier (e.g. "atlassian").
|
||
* @param {string} agentName - Agent identifier (e.g. "claude-code").
|
||
* @returns {object} New object (source is not mutated).
|
||
*/
|
||
export function makePackageJsonContent(sourcePkg, skillName, agentName) {
|
||
return {
|
||
...sourcePkg,
|
||
name: `@ai-coding-skills/${skillName}-${agentName}`,
|
||
private: true,
|
||
};
|
||
}
|
||
|
||
// ── Generated-root list ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return the authoritative list of generated roots as repo-relative paths.
|
||
* Callers (verify-generated.mjs) use this to know which directories to walk.
|
||
*
|
||
* @returns {string[]} Sorted array of relative paths.
|
||
*/
|
||
export function getGeneratedRoots() {
|
||
return [...GENERATED_ROOTS];
|
||
}
|
||
|
||
// ── Manifest construction ─────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Walk a directory recursively and return all file paths (abs).
|
||
* Skips node_modules.
|
||
*/
|
||
async function walkDir(dir) {
|
||
const results = [];
|
||
let entries;
|
||
try {
|
||
entries = await readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return results;
|
||
}
|
||
for (const entry of entries) {
|
||
if (entry.name === "node_modules") continue;
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
const sub = await walkDir(full);
|
||
results.push(...sub);
|
||
} else if (entry.isFile()) {
|
||
results.push(full);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Build a .generated-manifest.json object for a generated root.
|
||
*
|
||
* The manifest lists every file in the generated root EXCEPT itself.
|
||
* Files are sorted by relative path for stable canonical serialization.
|
||
*
|
||
* @param {string} generatedRootAbs - Absolute path to the generated root directory.
|
||
* @param {string} generatedRootRel - Repo-relative path (e.g. "skills/create-plan/pi").
|
||
* @returns {Promise<object>} Manifest object (not yet serialized to disk).
|
||
*/
|
||
export async function buildManifest(generatedRootAbs, generatedRootRel) {
|
||
const allFiles = await walkDir(generatedRootAbs);
|
||
const entries = [];
|
||
|
||
for (const absPath of allFiles) {
|
||
const relPath = path.relative(generatedRootAbs, absPath).replace(/\\/g, "/");
|
||
|
||
// Non-self-referential: the manifest never lists itself
|
||
if (relPath === MANIFEST_FILENAME) continue;
|
||
|
||
const contentBuf = await readFile(absPath);
|
||
const sha256 = crypto.createHash("sha256").update(contentBuf).digest("hex");
|
||
|
||
const st = await lstat(absPath);
|
||
const mode = (st.mode & 0o777).toString(8).padStart(3, "0");
|
||
|
||
entries.push({
|
||
path: relPath,
|
||
kind: "file",
|
||
mode,
|
||
sha256,
|
||
});
|
||
}
|
||
|
||
entries.sort((a, b) => a.path.localeCompare(b.path));
|
||
|
||
return {
|
||
$schema: MANIFEST_SCHEMA,
|
||
generator: MANIFEST_GENERATOR,
|
||
generatedRoot: generatedRootRel,
|
||
files: entries,
|
||
};
|
||
}
|
||
|
||
// ── Core generation helpers ───────────────────────────────────────────────
|
||
|
||
/**
|
||
* Copy a single file from source to destination, inserting a header.
|
||
* Skips header for:
|
||
* - pnpm-lock.yaml (managed by pnpm, would be stripped on next install)
|
||
* - JSON files (per header policy, no in-file header)
|
||
* - node_modules (never copied)
|
||
*/
|
||
async function copyWithHeader(srcAbs, dstAbs, canonicalHint) {
|
||
const basename = path.basename(srcAbs);
|
||
// Skip pnpm-lock.yaml header (pnpm regenerates without comment)
|
||
const skipHeader = basename === "pnpm-lock.yaml";
|
||
|
||
const raw = await readFile(srcAbs, "utf8");
|
||
const fileType = skipHeader ? "unknown" : detectFileType(srcAbs);
|
||
const content = applyHeader(raw, fileType, canonicalHint);
|
||
|
||
await mkdir(path.dirname(dstAbs), { recursive: true });
|
||
await writeFile(dstAbs, content, "utf8");
|
||
}
|
||
|
||
/**
|
||
* Recursively copy a directory tree, adding headers to text files.
|
||
*/
|
||
async function copyDirWithHeaders(srcDir, dstDir, canonicalHint) {
|
||
const entries = await readdir(srcDir, { withFileTypes: true });
|
||
await mkdir(dstDir, { recursive: true });
|
||
|
||
for (const entry of entries) {
|
||
if (entry.name === "node_modules") continue;
|
||
const src = path.join(srcDir, entry.name);
|
||
const dst = path.join(dstDir, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
await copyDirWithHeaders(src, dst, canonicalHint);
|
||
} else if (entry.isFile()) {
|
||
await copyWithHeader(src, dst, canonicalHint);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write a .generated-manifest.json file into a generated root.
|
||
*/
|
||
async function writeManifest(generatedRootAbs, generatedRootRel) {
|
||
const manifest = await buildManifest(generatedRootAbs, generatedRootRel);
|
||
const dstPath = path.join(generatedRootAbs, MANIFEST_FILENAME);
|
||
await writeFile(dstPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||
}
|
||
|
||
// ── Skill-family generators ───────────────────────────────────────────────
|
||
|
||
/**
|
||
* Generate one agent variant for a "skills-only" skill
|
||
* (create-plan, do-task, implement-plan).
|
||
*
|
||
* Canonical source: skills/<skill>/_source/<agent>/
|
||
* Generated root: skills/<skill>/<agent>/
|
||
*
|
||
* @param {string} writeRoot - Root directory to write output into (defaults to repoRoot).
|
||
*/
|
||
async function generateSkillOnlyVariant(repoRoot, writeRoot, skillName, agentName, generatedRootRel) {
|
||
const sourceDir = path.join(repoRoot, "skills", skillName, "_source", agentName);
|
||
const targetDir = path.join(writeRoot, generatedRootRel);
|
||
const canonicalHint = `skills/${skillName}/_source/${agentName}/`;
|
||
|
||
// Clear previous generated content (preserve node_modules if any)
|
||
await clearGeneratedRoot(targetDir);
|
||
|
||
// Copy all files from source with headers
|
||
await copyDirWithHeaders(sourceDir, targetDir, canonicalHint);
|
||
|
||
// Write manifest
|
||
await writeManifest(targetDir, generatedRootRel);
|
||
}
|
||
|
||
/**
|
||
* Generate one agent variant for a "scripts+skill" skill
|
||
* (atlassian, web-automation).
|
||
*
|
||
* For atlassian:
|
||
* - SKILL.md from skills/atlassian/_source/<agent>/SKILL.md
|
||
* - scripts/* from skills/atlassian/shared/scripts/
|
||
* (only src/, tsconfig.json, pnpm-lock.yaml — not tests/ or scripts/sync-*)
|
||
*
|
||
* For web-automation:
|
||
* - SKILL.md from skills/web-automation/_source/<agent>/SKILL.md
|
||
* - scripts/* from skills/web-automation/shared/
|
||
*
|
||
* @param {string} [packageAgentName] - Override the agent name used only for the
|
||
* package.json `name` field. Defaults to `agentName`. Use this to give
|
||
* pi-package mirrors a distinct name (e.g. "pi-mirror") so workspace package
|
||
* names are unique even when two roots share the same source agent.
|
||
*/
|
||
async function generateScriptsSkillVariant(
|
||
repoRoot,
|
||
writeRoot,
|
||
skillName,
|
||
agentName,
|
||
generatedRootRel,
|
||
config,
|
||
packageAgentName,
|
||
) {
|
||
const targetDir = path.join(writeRoot, generatedRootRel);
|
||
await clearGeneratedRoot(targetDir);
|
||
|
||
// 1. Copy SKILL.md from per-agent canonical source
|
||
const skillMdSrc = path.join(repoRoot, "skills", skillName, "_source", agentName, "SKILL.md");
|
||
const skillMdDst = path.join(targetDir, "SKILL.md");
|
||
const skillMdHint = `skills/${skillName}/_source/${agentName}/SKILL.md`;
|
||
await copyWithHeader(skillMdSrc, skillMdDst, skillMdHint);
|
||
|
||
// 2. Copy scripts
|
||
const scriptsTargetDir = path.join(targetDir, "scripts");
|
||
await mkdir(scriptsTargetDir, { recursive: true });
|
||
|
||
const canonicalScripts = path.join(repoRoot, config.canonicalScripts);
|
||
const scriptsHint = `${config.canonicalScripts}/`;
|
||
|
||
for (const entry of config.scriptFiles) {
|
||
const srcPath = path.join(canonicalScripts, entry);
|
||
const dstPath = path.join(scriptsTargetDir, entry); // scriptsTargetDir already uses writeRoot
|
||
|
||
// Check if source exists
|
||
let st;
|
||
try {
|
||
st = await lstat(srcPath);
|
||
} catch {
|
||
continue; // skip if file doesn't exist in canonical
|
||
}
|
||
|
||
if (st.isDirectory()) {
|
||
await copyDirWithHeaders(srcPath, dstPath, scriptsHint);
|
||
} else {
|
||
await copyWithHeader(srcPath, dstPath, scriptsHint);
|
||
}
|
||
}
|
||
|
||
// 3. Generate modified package.json
|
||
const srcPkgPath = path.join(canonicalScripts, "package.json");
|
||
const srcPkg = JSON.parse(await readFile(srcPkgPath, "utf8"));
|
||
|
||
let targetPkg = makePackageJsonContent(srcPkg, skillName, packageAgentName ?? agentName);
|
||
|
||
// For atlassian variants, strip test/sync scripts (not in agent variants)
|
||
if (skillName === "atlassian") {
|
||
const agentScripts = {};
|
||
for (const [k, v] of Object.entries(targetPkg.scripts ?? {})) {
|
||
if (k !== "test" && k !== "sync:agents") agentScripts[k] = v;
|
||
}
|
||
targetPkg = { ...targetPkg, scripts: agentScripts };
|
||
}
|
||
|
||
const dstPkgPath = path.join(scriptsTargetDir, "package.json");
|
||
await writeFile(dstPkgPath, JSON.stringify(targetPkg, null, 2) + "\n", "utf8");
|
||
|
||
// 4. Write manifest
|
||
await writeManifest(targetDir, generatedRootRel);
|
||
}
|
||
|
||
/**
|
||
* Generate the reviewer-runtime pi variant from the non-Pi canonical scripts.
|
||
*
|
||
* Canonical source: skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}
|
||
* Generated root: skills/reviewer-runtime/pi/
|
||
*
|
||
* The pi variant is byte-identical to the canonical except for:
|
||
* - A generated # comment after the shebang (replaces old "keep in sync" comment)
|
||
*
|
||
* @param {string} writeRoot - Root directory to write output into (defaults to repoRoot).
|
||
*/
|
||
async function generateReviewerRuntimePi(repoRoot, writeRoot) {
|
||
const srcDir = path.join(repoRoot, "skills", "reviewer-runtime");
|
||
const dstDir = path.join(writeRoot, "skills", "reviewer-runtime", "pi");
|
||
const canonicalHint = "skills/reviewer-runtime/";
|
||
|
||
// Clear old generated content (preserve tests/ which is canonical)
|
||
await clearGeneratedRoot(dstDir);
|
||
|
||
for (const fname of ["run-review.sh", "notify-telegram.sh"]) {
|
||
const srcPath = path.join(srcDir, fname);
|
||
const dstPath = path.join(dstDir, fname);
|
||
|
||
const raw = await readFile(srcPath, "utf8");
|
||
let content = applyHeader(raw, "shell", `${canonicalHint}${fname}`);
|
||
|
||
await mkdir(path.dirname(dstPath), { recursive: true });
|
||
await writeFile(dstPath, content, "utf8");
|
||
|
||
// Preserve executable bit
|
||
const st = await lstat(srcPath);
|
||
if (st.mode & 0o100) {
|
||
const { chmod } = await import("node:fs/promises");
|
||
await chmod(dstPath, 0o755);
|
||
}
|
||
}
|
||
|
||
// Write manifest (generatedRootRel is always relative to repo root, not writeRoot)
|
||
await writeManifest(dstDir, "skills/reviewer-runtime/pi");
|
||
}
|
||
|
||
/**
|
||
* Clear generated content in a root, preserving:
|
||
* - node_modules (installed by pnpm) — at any depth
|
||
* - .generated-manifest.json (will be rewritten after generation)
|
||
*
|
||
* Subdirectories are always recursed into before removal so that
|
||
* node_modules trees nested at any depth (e.g. scripts/node_modules inside
|
||
* atlassian or web-automation variants) are preserved.
|
||
*/
|
||
async function clearGeneratedRoot(rootDir) {
|
||
let entries;
|
||
try {
|
||
entries = await readdir(rootDir, { withFileTypes: true });
|
||
} catch {
|
||
return; // dir doesn't exist yet — nothing to clear
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
if (entry.name === "node_modules") continue;
|
||
if (entry.name === MANIFEST_FILENAME) continue;
|
||
const fullPath = path.join(rootDir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
// Always recurse so node_modules at any depth is preserved.
|
||
await clearGeneratedRoot(fullPath);
|
||
// Remove the directory only if nothing protected remains inside it.
|
||
const remaining = await readdir(fullPath).catch(() => []);
|
||
if (remaining.length === 0) {
|
||
await rm(fullPath, { force: true });
|
||
}
|
||
} else {
|
||
await rm(fullPath, { force: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Skill configurations ──────────────────────────────────────────────────
|
||
|
||
const SCRIPTS_SKILL_CONFIGS = {
|
||
atlassian: {
|
||
canonicalScripts: "skills/atlassian/shared/scripts",
|
||
// Files to copy from canonicalScripts into each agent's scripts/ dir
|
||
scriptFiles: ["src", "tsconfig.json", "pnpm-lock.yaml"],
|
||
},
|
||
"web-automation": {
|
||
canonicalScripts: "skills/web-automation/shared",
|
||
scriptFiles: [
|
||
"auth.ts",
|
||
"browse.ts",
|
||
"check-install.js",
|
||
"extract.js",
|
||
"flow.ts",
|
||
"lib",
|
||
"scan-local-app.ts",
|
||
"scrape.ts",
|
||
"test-full.ts",
|
||
"test-minimal.ts",
|
||
"test-profile.ts",
|
||
"tsconfig.json",
|
||
"turndown-plugin-gfm.d.ts",
|
||
"pnpm-lock.yaml",
|
||
],
|
||
},
|
||
};
|
||
|
||
// ── Main generator ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Regenerate all agent variants from canonical sources.
|
||
*
|
||
* @param {string} repoRoot - Absolute path to repo root (canonical sources are read from here).
|
||
* @param {object} [options]
|
||
* @param {boolean} [options.dryRun] - If true, don't write files (future use).
|
||
* @param {string} [options.targetRoot] - Write generated output here instead of `repoRoot`.
|
||
* Canonical sources are always read from `repoRoot`. Use this to generate into a
|
||
* temp directory for drift detection without modifying on-disk files.
|
||
* @returns {Promise<{generatedRoots: string[]}>}
|
||
*/
|
||
export async function generateSkills(repoRoot = REPO_ROOT, options = {}) {
|
||
const { dryRun = false, targetRoot } = options;
|
||
const writeRoot = targetRoot ?? repoRoot;
|
||
if (dryRun) {
|
||
return { generatedRoots: GENERATED_ROOTS };
|
||
}
|
||
|
||
const skillOnlySkills = ["create-plan", "do-task", "implement-plan"];
|
||
|
||
// 1. Generate skill-only skills (create-plan, do-task, implement-plan)
|
||
for (const skillName of skillOnlySkills) {
|
||
for (const agentName of AGENTS) {
|
||
const rootRel = `skills/${skillName}/${agentName}`;
|
||
await generateSkillOnlyVariant(repoRoot, writeRoot, skillName, agentName, rootRel);
|
||
}
|
||
// pi-package mirror (same source as pi variant)
|
||
const piPackageRel = `pi-package/skills/${skillName}`;
|
||
await generateSkillOnlyVariant(repoRoot, writeRoot, skillName, "pi", piPackageRel);
|
||
}
|
||
|
||
// 2. Generate scripts skills (atlassian, web-automation)
|
||
for (const [skillName, config] of Object.entries(SCRIPTS_SKILL_CONFIGS)) {
|
||
for (const agentName of AGENTS) {
|
||
const rootRel = `skills/${skillName}/${agentName}`;
|
||
await generateScriptsSkillVariant(repoRoot, writeRoot, skillName, agentName, rootRel, config);
|
||
}
|
||
// pi-package mirror: same source as pi variant but a distinct package name
|
||
// ("pi-mirror" suffix) so the workspace has no duplicate package names.
|
||
const piPackageRel = `pi-package/skills/${skillName}`;
|
||
await generateScriptsSkillVariant(repoRoot, writeRoot, skillName, "pi", piPackageRel, config, "pi-mirror");
|
||
}
|
||
|
||
// 3. Generate reviewer-runtime pi variant
|
||
await generateReviewerRuntimePi(repoRoot, writeRoot);
|
||
|
||
return { generatedRoots: GENERATED_ROOTS };
|
||
}
|
||
|
||
// ── CLI entry point ────────────────────────────────────────────────────────
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||
try {
|
||
const result = await generateSkills(REPO_ROOT);
|
||
console.log(
|
||
`Generated ${result.generatedRoots.length} roots from canonical sources.`,
|
||
);
|
||
process.exit(0);
|
||
} catch (err) {
|
||
console.error("generate-skills: fatal error:", err.message ?? err);
|
||
process.exit(1);
|
||
}
|
||
}
|