|
#!/usr/bin/env npx tsx |
|
/** |
|
* generate-ai-configs.ts |
|
* |
|
* Transpiles canonical `.claude/` sources into config files for |
|
* GitHub Copilot, Google Gemini, and OpenAI Codex. |
|
* |
|
* Usage: npx tsx scripts/generate-ai-configs.ts [--check] |
|
* --check Verify generated files are up-to-date (exits 1 if stale) |
|
*/ |
|
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs"; |
|
import { join, basename, dirname, resolve } from "node:path"; |
|
import { execFileSync } from "node:child_process"; |
|
import { fileURLToPath } from "node:url"; |
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url)); |
|
const ROOT = resolve(__dirname, ".."); |
|
const GENERATED_HEADER = "<!-- GENERATED FROM .claude/ — DO NOT EDIT BY HAND -->\n\n"; |
|
const CHECK_MODE = process.argv.includes("--check"); |
|
|
|
// --------------------------------------------------------------------------- |
|
// Helpers |
|
// --------------------------------------------------------------------------- |
|
|
|
function read(rel: string): string { |
|
const abs = join(ROOT, rel); |
|
try { |
|
return readFileSync(abs, "utf-8"); |
|
} catch (error) { |
|
const reason = error instanceof Error ? error.message : String(error); |
|
throw new Error( |
|
`Cannot generate AI configs: required source file '${rel}' could not be read (${reason}).`, |
|
); |
|
} |
|
} |
|
|
|
/** Track files written this run for check mode comparison. */ |
|
const writtenFiles: string[] = []; |
|
|
|
function prependGeneratedHeader(content: string): string { |
|
// Some consumers require YAML frontmatter to be the first bytes in a file. |
|
// Keep frontmatter at the top and place the generated marker right after it. |
|
if (!content.startsWith("---\n")) { |
|
return GENERATED_HEADER + content; |
|
} |
|
|
|
const closingIndex = content.indexOf("\n---\n", 4); |
|
if (closingIndex === -1) { |
|
return GENERATED_HEADER + content; |
|
} |
|
|
|
const frontmatterEnd = closingIndex + "\n---\n".length; |
|
const frontmatter = content.slice(0, frontmatterEnd); |
|
const rest = content.slice(frontmatterEnd).replace(/^\n/, ""); |
|
return `${frontmatter}\n${GENERATED_HEADER}${rest}`; |
|
} |
|
|
|
function writeGenerated(rel: string, content: string): void { |
|
const abs = join(ROOT, rel); |
|
mkdirSync(dirname(abs), { recursive: true }); |
|
const full = prependGeneratedHeader(content); |
|
writeFileSync(abs, full, "utf-8"); |
|
writtenFiles.push(rel); |
|
if (!CHECK_MODE) console.log(` wrote ${rel}`); |
|
} |
|
|
|
/** |
|
* Extract a markdown section by ## heading, including all content up to the |
|
* next ## heading (or EOF). The `---` horizontal rules between sections in |
|
* CLAUDE.md are trimmed from the result. |
|
*/ |
|
function extractSection(md: string, heading: string): string | null { |
|
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
|
// Match from "## Heading" to just before the next level-2 heading (or EOF). |
|
const re = new RegExp(`(?:^|\\n)## ${escapedHeading}\\n([\\s\\S]*?)(?=\\n## (?!#)|$)`); |
|
const m = md.match(re); |
|
if (!m) return null; |
|
const body = m[1].replace(/\n---\s*$/, "").trim(); |
|
return body ? `## ${heading}\n\n${body}` : null; |
|
} |
|
|
|
/** Strip Claude-style YAML frontmatter (---\npaths: ...\n---) from rule files. */ |
|
function stripFrontmatter(content: string): string { |
|
return content.replace(/^---\n[\s\S]*?\n---\n*/, ""); |
|
} |
|
|
|
/** List subdirectories that contain a given file. */ |
|
function listSkillDirs(base: string, file: string): string[] { |
|
const abs = join(ROOT, base); |
|
if (!existsSync(abs)) return []; |
|
return readdirSync(abs, { withFileTypes: true }) |
|
.filter((d) => d.isDirectory() && existsSync(join(abs, d.name, file))) |
|
.map((d) => d.name); |
|
} |
|
|
|
/** List markdown basenames (without extension) from a directory if present. */ |
|
function listMarkdownBasenames(base: string): string[] { |
|
const abs = join(ROOT, base); |
|
if (!existsSync(abs)) return []; |
|
return readdirSync(abs) |
|
.filter((f) => f.endsWith(".md")) |
|
.map((f) => f.replace(/\.md$/, "")); |
|
} |
|
|
|
function parseFrontmatterMap(content: string): Record<string, string> { |
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); |
|
if (!fmMatch) return {}; |
|
|
|
const lines = fmMatch[1].split(/\r?\n/); |
|
const values: Record<string, string> = {}; |
|
|
|
for (let i = 0; i < lines.length; i += 1) { |
|
const keyMatch = lines[i].match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); |
|
if (!keyMatch) continue; |
|
|
|
const key = keyMatch[1]; |
|
const value = keyMatch[2].trim(); |
|
if (value === "|" || value === ">") { |
|
const block: string[] = []; |
|
i += 1; |
|
for (; i < lines.length; i += 1) { |
|
const next = lines[i]; |
|
if (next.trim() === "" || /^\s/.test(next)) { |
|
block.push(next); |
|
continue; |
|
} |
|
i -= 1; |
|
break; |
|
} |
|
|
|
const indented = block.filter((line) => line.trim() !== ""); |
|
const minIndent = |
|
indented.length === 0 |
|
? 0 |
|
: Math.min(...indented.map((line) => line.match(/^\s*/)?.[0].length ?? 0)); |
|
const normalized = block |
|
.map((line) => (line.trim() === "" ? "" : line.slice(minIndent))) |
|
.join(value === ">" ? " " : "\n") |
|
.replace(/\s+/g, " ") |
|
.trim(); |
|
values[key] = normalized; |
|
continue; |
|
} |
|
|
|
values[key] = value.replace(/^["']|["']$/g, "").trim(); |
|
} |
|
|
|
return values; |
|
} |
|
|
|
function inferDescriptionFromBody(content: string): string { |
|
const body = stripFrontmatter(content); |
|
const paragraphs = body.split(/\n\s*\n/); |
|
for (const paragraph of paragraphs) { |
|
const compact = paragraph |
|
.split(/\r?\n/) |
|
.map((line) => line.trim()) |
|
.filter((line) => line.length > 0 && !line.startsWith("#") && line !== "---") |
|
.join(" ") |
|
.replace(/\s+/g, " ") |
|
.trim(); |
|
if (compact) return compact; |
|
} |
|
return ""; |
|
} |
|
|
|
/** Read YAML frontmatter from a skill SKILL.md and return {name, description, argumentHint}. */ |
|
function parseSkillFrontmatter(content: string): { |
|
name: string; |
|
description: string; |
|
argumentHint: string; |
|
} { |
|
const values = parseFrontmatterMap(content); |
|
const name = values.name?.trim() ?? ""; |
|
const description = values.description?.trim() ?? ""; |
|
const argumentHint = values["argument-hint"]?.trim() ?? ""; |
|
return { name, description, argumentHint }; |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Read canonical sources |
|
// --------------------------------------------------------------------------- |
|
|
|
const claudeMd = read("CLAUDE.md"); |
|
|
|
const rules: Record<string, string> = {}; |
|
const RULE_FILES = ["rust", "native-clients", "pulumi", "keycloak", "cli"]; |
|
for (const r of RULE_FILES) { |
|
rules[r] = read(`.claude/rules/${r}.md`); |
|
} |
|
|
|
const skills = listSkillDirs(".claude/skills", "SKILL.md"); |
|
const commands = listMarkdownBasenames(".claude/commands"); |
|
const agents = listMarkdownBasenames(".claude/agents"); |
|
|
|
// --------------------------------------------------------------------------- |
|
// 1. Copilot — repo-wide instructions |
|
// --------------------------------------------------------------------------- |
|
|
|
function generateCopilotRepoWide(): void { |
|
const sections = [ |
|
"Code Principles", |
|
"Git Workflow", |
|
"Architecture Decision Records (ADRs)", |
|
]; |
|
|
|
let body = "# Alder — GitHub Copilot Instructions\n\n"; |
|
body += "These instructions are auto-generated from the canonical `CLAUDE.md`.\n"; |
|
body += "See `.claude/` for the authoritative source.\n\n"; |
|
|
|
for (const s of sections) { |
|
const extracted = extractSection(claudeMd, s); |
|
if (extracted) body += extracted + "\n\n---\n\n"; |
|
} |
|
|
|
// Add quick reference table |
|
const quickRef = extractSection(claudeMd, "Quick Reference"); |
|
if (quickRef) body += quickRef + "\n\n---\n\n"; |
|
|
|
// Add platform priorities (compact) |
|
const platform = extractSection(claudeMd, "Platform Priorities"); |
|
if (platform) body += platform + "\n"; |
|
|
|
writeGenerated(".github/copilot-instructions.md", body.trimEnd() + "\n"); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 2. Copilot — path-specific instruction files |
|
// --------------------------------------------------------------------------- |
|
|
|
const COPILOT_PATH_MAP: Record<string, string> = { |
|
rust: "alder-*/**/*.rs", |
|
"native-clients": "clients/**", |
|
pulumi: "infrastructure/pulumi/**", |
|
keycloak: "infrastructure/keycloak/**,**/keycloak*", |
|
cli: "**/cli/**,**/commands/**", |
|
}; |
|
|
|
function generateCopilotPathSpecific(): void { |
|
for (const [rule, applyTo] of Object.entries(COPILOT_PATH_MAP)) { |
|
const frontmatter = `---\napplyTo: "${applyTo}"\n---\n\n`; |
|
const content = stripFrontmatter(rules[rule]); |
|
writeGenerated( |
|
`.github/instructions/${rule}.instructions.md`, |
|
frontmatter + content.trimEnd() + "\n", |
|
); |
|
} |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 3. Gemini — GEMINI.md + .gemini/styleguide.md |
|
// --------------------------------------------------------------------------- |
|
|
|
function generateGemini(): void { |
|
// GEMINI.md — entry point with @imports |
|
let gemini = "# Alder — Gemini Project Instructions\n\n"; |
|
gemini += "This file is auto-generated. See `.claude/` for the canonical source.\n\n"; |
|
gemini += "## Coding Standards\n\n"; |
|
gemini += "@.gemini/styleguide.md\n\n"; |
|
gemini += "## Domain Rules\n\n"; |
|
for (const r of RULE_FILES) { |
|
gemini += `@.claude/rules/${r}.md\n`; |
|
} |
|
gemini += "\n"; |
|
writeGenerated("GEMINI.md", gemini.trimEnd() + "\n"); |
|
|
|
// .gemini/styleguide.md — extracted code quality sections |
|
let styleguide = "# Alder Coding Standards\n\n"; |
|
styleguide += "Extracted from the canonical `CLAUDE.md`.\n\n"; |
|
|
|
const qualitySections = [ |
|
"Code Principles", |
|
"Git Workflow", |
|
]; |
|
for (const s of qualitySections) { |
|
const extracted = extractSection(claudeMd, s); |
|
if (extracted) styleguide += extracted + "\n\n---\n\n"; |
|
} |
|
|
|
writeGenerated(".gemini/styleguide.md", styleguide.trimEnd() + "\n"); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 4. Codex — AGENTS.md + .agents/skills/*/SKILL.md + README |
|
// --------------------------------------------------------------------------- |
|
|
|
function generateCodex(): void { |
|
// AGENTS.md — bridge file |
|
let agents_md = "# Codex Repository Instructions\n\n"; |
|
agents_md += "This repository keeps most assistant playbooks in `.claude/`.\n"; |
|
agents_md += "Use this file to bridge those assets into Codex-native behavior.\n\n"; |
|
agents_md += "Official references (Codex):\n"; |
|
agents_md += "- AGENTS: https://developers.openai.com/codex/agents\n"; |
|
agents_md += "- Skills: https://developers.openai.com/codex/skills\n"; |
|
agents_md += "- CLI slash commands: https://developers.openai.com/codex/cli#slash-commands\n\n"; |
|
|
|
// Command playbooks |
|
agents_md += "## Command Playbooks (`.claude/commands`)\n\n"; |
|
agents_md += "When the user invokes a slash-style command (for example `/code-review`), treat it as a request to load and follow the corresponding command file:\n\n"; |
|
agents_md += "1. Look for `.claude/commands/<command>.md` (strip leading `/`).\n"; |
|
agents_md += '2. If no exact match exists, pick the closest command filename in `.claude/commands/` and state which mapping you used.\n'; |
|
agents_md += "3. Follow that file's workflow and output format exactly.\n\n"; |
|
agents_md += "Available commands:\n"; |
|
for (const c of commands.sort()) { |
|
agents_md += `- \`/${c}\` -> \`.claude/commands/${c}.md\`\n`; |
|
} |
|
agents_md += "\n"; |
|
|
|
// Skills |
|
agents_md += "## Skills (`.claude/skills` via `.agents/skills`)\n\n"; |
|
agents_md += "Codex discovers skills from `.agents/skills/*/SKILL.md`.\n"; |
|
agents_md += "In this repo, each `.agents/skills/*` file is a compatibility wrapper that points to the canonical skill at `.claude/skills/*/SKILL.md`.\n\n"; |
|
agents_md += "When using a wrapped skill:\n\n"; |
|
agents_md += "1. Open the canonical `.claude/skills/<skill>/SKILL.md`.\n"; |
|
agents_md += "2. Execute that workflow.\n"; |
|
agents_md += "3. Resolve relative paths from the canonical skill directory first.\n\n"; |
|
|
|
// Agents |
|
agents_md += "## Agent Roles (`.claude/agents`)\n\n"; |
|
agents_md += "Specialized role playbooks:\n"; |
|
for (const a of agents.sort()) { |
|
agents_md += `- \`${a}\` -> \`.claude/agents/${a}.md\`\n`; |
|
} |
|
agents_md += "\n"; |
|
|
|
// Additional guidance |
|
agents_md += "## Additional Guidance\n\n"; |
|
agents_md += "- Project-wide rules and style guidance are in `CLAUDE.md`.\n"; |
|
agents_md += "- Path/domain rules live in `.claude/rules/*.md`.\n"; |
|
agents_md += "- Specialized role playbooks live in `.claude/agents/*.md`.\n"; |
|
|
|
writeGenerated("AGENTS.md", agents_md.trimEnd() + "\n"); |
|
|
|
// .agents/skills/*/SKILL.md wrappers |
|
for (const skill of skills) { |
|
const canonical = read(`.claude/skills/${skill}/SKILL.md`); |
|
const { name, description, argumentHint } = parseSkillFrontmatter(canonical); |
|
const resolvedName = name || skill; |
|
const resolvedDescription = description || inferDescriptionFromBody(canonical); |
|
if (!resolvedDescription) { |
|
throw new Error( |
|
`Cannot generate AI configs: skill '${skill}' is missing a usable description in frontmatter/body.`, |
|
); |
|
} |
|
|
|
let wrapper = "---\n"; |
|
wrapper += `name: ${resolvedName}\n`; |
|
wrapper += `description: ${resolvedDescription}\n`; |
|
if (argumentHint) wrapper += `argument-hint: "${argumentHint}"\n`; |
|
wrapper += "---\n\n"; |
|
wrapper += `# ${resolvedName} (Codex compatibility wrapper)\n\n`; |
|
wrapper += `Canonical instructions: \`.claude/skills/${skill}/SKILL.md\`\n\n`; |
|
wrapper += "When invoked:\n"; |
|
wrapper += `1. Read \`.claude/skills/${skill}/SKILL.md\`.\n`; |
|
wrapper += "2. Follow that workflow and output format.\n"; |
|
wrapper += `3. Resolve relative paths from \`.claude/skills/${skill}/\`.\n`; |
|
|
|
writeGenerated(`.agents/skills/${skill}/SKILL.md`, wrapper.trimEnd() + "\n"); |
|
} |
|
|
|
// .agents/skills/README.md |
|
let readme = "# Codex Skills (Auto-generated Wrappers)\n\n"; |
|
readme += "Each subdirectory wraps a canonical `.claude/skills/*/SKILL.md`.\n"; |
|
readme += "Do not edit these files — they are regenerated by `scripts/generate-ai-configs.ts`.\n\n"; |
|
readme += "| Skill | Canonical Source |\n"; |
|
readme += "|-------|------------------|\n"; |
|
for (const skill of skills.sort()) { |
|
readme += `| ${skill} | \`.claude/skills/${skill}/SKILL.md\` |\n`; |
|
} |
|
|
|
writeGenerated(".agents/skills/README.md", readme.trimEnd() + "\n"); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 5. Size checks |
|
// --------------------------------------------------------------------------- |
|
|
|
function checkSizes(): void { |
|
const agentsMd = join(ROOT, "AGENTS.md"); |
|
if (existsSync(agentsMd)) { |
|
const size = readFileSync(agentsMd).length; |
|
if (size > 32 * 1024) { |
|
console.warn(`⚠️ AGENTS.md is ${(size / 1024).toFixed(1)}KB (limit: 32KB)`); |
|
} |
|
} |
|
|
|
const copilotInstructions = join(ROOT, ".github/copilot-instructions.md"); |
|
if (existsSync(copilotInstructions)) { |
|
const lines = readFileSync(copilotInstructions, "utf-8").split("\n").length; |
|
if (lines > 1000) { |
|
console.warn( |
|
`⚠️ .github/copilot-instructions.md is ${lines} lines (limit: 1000)`, |
|
); |
|
} |
|
} |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 6. Format generated markdown with prettier (so pre-commit hooks are stable) |
|
// --------------------------------------------------------------------------- |
|
|
|
function formatWithPrettier(): boolean { |
|
const files = getAllGeneratedFiles(); |
|
try { |
|
execFileSync("npx", ["prettier", "--write", "--prose-wrap", "preserve", ...files], { |
|
cwd: ROOT, |
|
stdio: "pipe", |
|
}); |
|
if (!CHECK_MODE) console.log(" formatted with prettier"); |
|
return true; |
|
} catch { |
|
const mode = CHECK_MODE ? "[check]" : "[generate]"; |
|
console.warn(` prettier formatting skipped ${mode}: prettier not available or failed`); |
|
return false; |
|
} |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 7. Auto-stage generated files (for pre-commit hook usage) |
|
// --------------------------------------------------------------------------- |
|
|
|
function getAllGeneratedFiles(): string[] { |
|
return [ |
|
".github/copilot-instructions.md", |
|
...RULE_FILES.map((r) => `.github/instructions/${r}.instructions.md`), |
|
"GEMINI.md", |
|
".gemini/styleguide.md", |
|
"AGENTS.md", |
|
".agents/skills/README.md", |
|
...skills.map((s) => `.agents/skills/${s}/SKILL.md`), |
|
]; |
|
} |
|
|
|
function stageGeneratedFiles(): void { |
|
if (CHECK_MODE) return; |
|
// Only stage if running inside a git hook (GIT_INDEX_FILE is set) |
|
if (!process.env.GIT_INDEX_FILE) return; |
|
|
|
const files = getAllGeneratedFiles(); |
|
try { |
|
execFileSync("git", ["add", ...files], { cwd: ROOT, stdio: "pipe" }); |
|
console.log(" staged generated files"); |
|
} catch { |
|
// Not in a git context or staging failed — non-fatal |
|
} |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Main |
|
// --------------------------------------------------------------------------- |
|
|
|
function run(): void { |
|
console.log(CHECK_MODE ? "Checking AI configs..." : "Generating AI configs..."); |
|
|
|
generateCopilotRepoWide(); |
|
generateCopilotPathSpecific(); |
|
generateGemini(); |
|
generateCodex(); |
|
const prettierOk = formatWithPrettier(); |
|
checkSizes(); |
|
|
|
if (CHECK_MODE) { |
|
if (!prettierOk) { |
|
console.error("Cannot verify generated files in --check mode because prettier failed."); |
|
try { |
|
execFileSync("git", ["checkout", "--", ...writtenFiles], { cwd: ROOT, stdio: "pipe" }); |
|
} catch { |
|
// best-effort cleanup |
|
} |
|
process.exitCode = 1; |
|
return; |
|
} |
|
|
|
// In check mode we wrote + prettified the files. If they differ from git's |
|
// view, the configs are stale. |
|
try { |
|
const diff = execFileSync("git", ["diff", "--name-only", "--", ...writtenFiles], { |
|
cwd: ROOT, |
|
encoding: "utf-8", |
|
}).trim(); |
|
if (diff) { |
|
console.error("Stale AI config files:\n" + diff); |
|
console.error("\nRun: npx tsx scripts/generate-ai-configs.ts"); |
|
// Restore the files to their git state |
|
execFileSync("git", ["checkout", "--", ...writtenFiles], { cwd: ROOT, stdio: "pipe" }); |
|
process.exitCode = 1; |
|
} else { |
|
console.log("All AI config files are up-to-date."); |
|
} |
|
} catch { |
|
console.error("Could not verify — not in a git repository?"); |
|
process.exitCode = 1; |
|
} |
|
} else { |
|
stageGeneratedFiles(); |
|
console.log("Done."); |
|
} |
|
} |
|
|
|
function isMainModule(): boolean { |
|
const entry = process.argv[1]; |
|
if (!entry) return false; |
|
return resolve(entry) === fileURLToPath(import.meta.url); |
|
} |
|
|
|
if (isMainModule()) { |
|
run(); |
|
} |
|
|
|
export { |
|
extractSection, |
|
inferDescriptionFromBody, |
|
parseSkillFrontmatter, |
|
prependGeneratedHeader, |
|
run, |
|
stripFrontmatter, |
|
}; |