#!/usr/bin/env node /** * generate-skills.mjs — shared-source generator for agent variants (M3, S-302) * * Generates every agent-variant directory (`skills///`) and * `pi-package/skills//` 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 * 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\n${rest}`; } } return `\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} 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//_source// * Generated root: skills/// * * @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//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//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); } }