Skip to content

Instantly share code, notes, and snippets.

@paulbreuler
Last active February 19, 2026 03:32
Show Gist options
  • Select an option

  • Save paulbreuler/1650abf64d54d9fff891cf97df3f37ea to your computer and use it in GitHub Desktop.

Select an option

Save paulbreuler/1650abf64d54d9fff891cf97df3f37ea to your computer and use it in GitHub Desktop.
generate-ai-configs.ts — transpiles .claude/ sources into GitHub Copilot, Gemini, and Codex config files

generate-ai-configs

Keeps GitHub Copilot, Google Gemini, and OpenAI Codex config files in sync with a single canonical source: your .claude/ directory.

Instead of maintaining copilot-instructions.md, GEMINI.md, and AGENTS.md by hand, you edit CLAUDE.md and your rule/skill files once — this script generates everything else automatically.

What it generates

Output Target AI
.github/copilot-instructions.md GitHub Copilot (repo-wide)
.github/instructions/*.instructions.md GitHub Copilot (path-specific)
GEMINI.md + .gemini/styleguide.md Google Gemini
AGENTS.md OpenAI Codex
.agents/skills/*/SKILL.md OpenAI Codex (skill wrappers)

Source layout expected

.claude/
├── rules/
│   ├── rust.md
│   ├── native-clients.md
│   ├── pulumi.md
│   ├── keycloak.md
│   └── cli.md
├── skills/
│   └── <skill-name>/
│       └── SKILL.md        # requires name + description frontmatter
├── commands/
│   └── <command>.md
└── agents/
    └── <role>.md
CLAUDE.md                   # canonical source, sections extracted by ## heading

Prerequisites

  • Node.js with npx available
  • tsx (installed on first run via npx)
  • prettier for stable formatting (optional but recommended — script warns if missing)

Usage

# Generate all configs
npx tsx scripts/generate-ai-configs.ts

# Check mode — exits 1 if any generated file is stale (useful in CI)
npx tsx scripts/generate-ai-configs.ts --check

Setup as a pre-commit hook

Option A: pre-commit framework (recommended)

Add to .pre-commit-config.yaml:

repos:
  - repo: local
    hooks:
      - id: generate-ai-configs
        name: generate AI assistant configs
        description: Regenerate Copilot/Gemini/Codex configs from .claude/ sources
        entry: npx tsx scripts/generate-ai-configs.ts
        language: system
        files: ^(CLAUDE\.md|\.claude/(rules|skills|commands|agents)/.*)$
        pass_filenames: false

Install hooks once:

pip install pre-commit
pre-commit install

The hook only runs when you touch CLAUDE.md or anything under .claude/. Generated files are auto-staged so they're included in the same commit.

Option B: Plain git hook

cat >> .git/hooks/pre-commit << 'HOOK'
# Regenerate AI configs when .claude/ sources change
if git diff --cached --name-only | grep -qE '^(CLAUDE\.md|\.claude/(rules|skills|commands|agents)/)'; then
  npx tsx scripts/generate-ai-configs.ts
fi
HOOK
chmod +x .git/hooks/pre-commit

CI staleness check

Add a step to your CI pipeline to catch generated files that weren't committed:

- name: Check AI configs are up-to-date
  run: npx tsx scripts/generate-ai-configs.ts --check

Exits 1 and prints the stale file list if anything is out of date. Restores the files to their committed state before exiting so the working tree stays clean.

Skill frontmatter

Each .claude/skills/<name>/SKILL.md must have YAML frontmatter with at least name and description:

---
name: my-skill
description: One-line description used in AGENTS.md and Codex wrappers.
argument-hint: "<optional hint shown to user>"
---

The generator throws if a skill is missing a usable description.

import assert from "node:assert/strict";
import test from "node:test";
import {
extractSection,
inferDescriptionFromBody,
prependGeneratedHeader,
parseSkillFrontmatter,
stripFrontmatter,
} from "./generate-ai-configs.ts";
test("extractSection stops at next level-2 heading regardless of first character", () => {
const md = `# Title
## Code Principles
alpha
## 1st section
beta
`;
const section = extractSection(md, "Code Principles");
assert.equal(section, "## Code Principles\n\nalpha");
});
test("parseSkillFrontmatter supports folded multiline descriptions", () => {
const content = `---
name: skill-with-folded-description
description: >
First line
second line
argument-hint: "[scope]"
---
# Skill
`;
const parsed = parseSkillFrontmatter(content);
assert.equal(parsed.name, "skill-with-folded-description");
assert.equal(parsed.description, "First line second line");
assert.equal(parsed.argumentHint, "[scope]");
});
test("inferDescriptionFromBody derives description when frontmatter is missing", () => {
const content = `# Pull Request Best Practices
Centralized knowledge for the Alder PR lifecycle.
## PR Lifecycle
Details...
`;
assert.equal(inferDescriptionFromBody(content), "Centralized knowledge for the Alder PR lifecycle.");
});
test("stripFrontmatter removes YAML header and keeps markdown body", () => {
const content = `---
name: foo
description: bar
---
# Heading
Body
`;
const stripped = stripFrontmatter(content);
assert.ok(stripped.startsWith("# Heading"));
assert.ok(!stripped.includes("name: foo"));
});
test("prependGeneratedHeader prepends marker when content has no frontmatter", () => {
const content = "# Heading\n\nBody\n";
const result = prependGeneratedHeader(content);
assert.ok(result.startsWith("<!-- GENERATED FROM .claude/ — DO NOT EDIT BY HAND -->\n\n"));
assert.ok(result.endsWith(content));
});
test("prependGeneratedHeader keeps frontmatter first and inserts marker after it", () => {
const content = `---
name: sample
description: test
---
# Heading
`;
const result = prependGeneratedHeader(content);
assert.ok(result.startsWith("---\nname: sample\ndescription: test\n---\n\n"));
assert.ok(result.includes("<!-- GENERATED FROM .claude/ — DO NOT EDIT BY HAND -->\n\n# Heading\n"));
assert.equal(result.indexOf("---\n"), 0);
});
#!/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,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment