#!/usr/bin/env node /** * AGENTS.md Auto-Generator * * Generates AGENTS.md from product.json + repo directory scan. * Also creates/updates symlinks: CLAUDE.md, .cursorrules, .windsurfrules * * Usage: * npx tsx agents-md.ts --repo /path/to/product-repo * npx tsx agents-md.ts --repo /path/to/product-repo --dry-run * npx tsx agents-md.ts --repo /path/to/product-repo --update # preserves sections * * @module @bytelyst/create-app/generators/agents-md */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { execSync } from 'node:child_process'; // ── CLI ────────────────────────────────────────────────────────────────────── interface Options { repo: string; dryRun: boolean; update: boolean; } function parseArgs(): Options { const args = process.argv.slice(2); const options: Options = { repo: '.', dryRun: false, update: false }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--repo' || arg === '-r') options.repo = args[++i]; else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; else if (arg === '--update' || arg === '-u') options.update = true; else if (arg === '--help' || arg === '-h') { showHelp(); process.exit(0); } } return options; } function showHelp(): void { console.log(` AGENTS.md Auto-Generator Generates AGENTS.md from product.json + repo scan, plus symlinks for CLAUDE.md, .cursorrules, and .windsurfrules. Usage: npx tsx agents-md.ts --repo [--dry-run] [--update] Options: --repo, -r Path to product repo root (default: ".") --dry-run, -d Preview without writing files --update, -u Preserve sections in existing AGENTS.md --help, -h Show this help Custom Sections: Wrap any hand-written content in / markers. The --update flag preserves these sections when regenerating. `); } // ── Product.json Loader ────────────────────────────────────────────────────── interface ProductManifest { productId: string; displayName: string; tagline?: string; domain?: string; backendPort?: number; primarySurface?: string; mobileCompanion?: boolean; bundleIds?: Record; bundleId?: string; version?: string; description?: string; licensePrefix?: string; configDirName?: string; envVarPrefix?: string; } async function loadProductJson(repoPath: string): Promise { const candidates = [ path.join(repoPath, 'shared', 'product.json'), path.join(repoPath, 'product.json'), ]; for (const p of candidates) { try { const raw = await fs.readFile(p, 'utf-8'); return JSON.parse(raw); } catch { // try next } } throw new Error('product.json not found in shared/ or repo root'); } // ── Repo Scanner ───────────────────────────────────────────────────────────── interface RepoInfo { repoName: string; hasFastifyBackend: boolean; hasNextWeb: boolean; hasExpoMobile: boolean; hasSwiftIos: boolean; hasKotlinAndroid: boolean; hasKmpShared: boolean; backendModules: string[]; backendTestCount: number; webTestCount: number; mobileTestCount: number; backendLibFiles: string[]; webLibFiles: string[]; cosmosContainers: string[]; techStack: { layer: string; tech: string }[]; buildCommands: string[]; } async function dirExists(p: string): Promise { try { const st = await fs.stat(p); return st.isDirectory(); } catch { return false; } } async function fileExists(p: string): Promise { try { await fs.access(p); return true; } catch { return false; } } function countTestsInFiles(dir: string): number { try { const output = execSync( `grep -r "\\b\\(it\\|test\\)(" "${dir}" --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" 2>/dev/null | wc -l`, { encoding: 'utf-8', timeout: 5000 } ); return parseInt(output.trim()) || 0; } catch { return 0; } } async function listDirs(dir: string): Promise { try { const entries = await fs.readdir(dir, { withFileTypes: true }); return entries.filter(e => e.isDirectory()).map(e => e.name); } catch { return []; } } async function listFiles(dir: string): Promise { try { const entries = await fs.readdir(dir, { withFileTypes: true }); return entries.filter(e => e.isFile()).map(e => e.name); } catch { return []; } } async function scanRepo(repoPath: string, manifest: ProductManifest): Promise { const repoName = path.basename(repoPath); // Detect surfaces const hasFastifyBackend = await dirExists(path.join(repoPath, 'backend', 'src')); const hasNextWeb = (await fileExists(path.join(repoPath, 'web', 'next.config.ts'))) || (await fileExists(path.join(repoPath, 'web', 'next.config.js'))) || (await fileExists(path.join(repoPath, 'mindlyst-native', 'web', 'next.config.ts'))); const hasExpoMobile = (await fileExists(path.join(repoPath, 'mobile', 'app.json'))) || (await fileExists(path.join(repoPath, 'app.json'))); const hasSwiftIos = await dirExists(path.join(repoPath, 'ios')); const hasKotlinAndroid = await dirExists(path.join(repoPath, 'android')); const hasKmpShared = await dirExists(path.join(repoPath, 'shared', 'src')); // Backend modules const modulesDir = path.join(repoPath, 'backend', 'src', 'modules'); const backendModules = await listDirs(modulesDir); // Backend lib files const libDir = path.join(repoPath, 'backend', 'src', 'lib'); const backendLibFiles = (await listFiles(libDir)).filter( f => f.endsWith('.ts') && !f.endsWith('.test.ts') ); // Web lib files let webLibDir = path.join(repoPath, 'web', 'src', 'lib'); if (!(await dirExists(webLibDir))) { webLibDir = path.join(repoPath, 'web', 'src', 'components', 'lib'); } const webLibFiles = (await listFiles(webLibDir)).filter( f => f.endsWith('.ts') && !f.endsWith('.test.ts') ); // Test counts const backendTestCount = hasFastifyBackend ? countTestsInFiles(path.join(repoPath, 'backend', 'src')) : 0; let webTestDir = path.join(repoPath, 'web', 'src'); if (!(await dirExists(webTestDir))) { webTestDir = path.join(repoPath, 'mindlyst-native', 'web', 'src'); } const webTestCount = hasNextWeb ? countTestsInFiles(webTestDir) : 0; const mobileTestDir = hasExpoMobile ? (await dirExists(path.join(repoPath, 'mobile'))) ? path.join(repoPath, 'mobile') : repoPath : ''; const mobileTestCount = mobileTestDir ? countTestsInFiles(mobileTestDir) : 0; // Cosmos containers — scan backend types files const cosmosContainers: string[] = []; for (const mod of backendModules) { const typesFile = path.join(modulesDir, mod, 'types.ts'); if (await fileExists(typesFile)) { cosmosContainers.push(mod.replace(/-/g, '_')); } } // Tech stack const techStack: { layer: string; tech: string }[] = []; if (hasFastifyBackend) techStack.push({ layer: 'Backend', tech: `Fastify 5, TypeScript ESM, Zod, jose (JWT), @bytelyst/datastore`, }); if (hasNextWeb) techStack.push({ layer: 'Web', tech: 'Next.js 16 (App Router), React 19, TypeScript' }); if (hasExpoMobile) techStack.push({ layer: 'Mobile', tech: 'React Native (Expo), TypeScript, expo-router' }); if (hasSwiftIos) techStack.push({ layer: 'iOS', tech: 'SwiftUI (iOS 17+)' }); if (hasKotlinAndroid) techStack.push({ layer: 'Android', tech: 'Jetpack Compose, Material 3, Kotlin' }); if (hasKmpShared) techStack.push({ layer: 'Shared', tech: 'Kotlin Multiplatform (KMP)' }); techStack.push({ layer: 'Platform', tech: 'platform-service (port 4003) for auth, flags, telemetry, billing', }); techStack.push({ layer: 'Database', tech: `Azure Cosmos DB via @bytelyst/datastore — productId: "${manifest.productId}"`, }); // Build commands const buildCommands: string[] = []; if (hasFastifyBackend) { const port = manifest.backendPort ?? 4000; buildCommands.push(`cd backend && npm run dev # Dev server (port ${port})`); buildCommands.push(`cd backend && npm run typecheck # tsc --noEmit`); buildCommands.push(`cd backend && npm test # Vitest tests`); } if (hasNextWeb) { buildCommands.push(`cd web && npm run dev # Dev server`); buildCommands.push(`cd web && npm run typecheck # tsc --noEmit`); buildCommands.push(`cd web && npm run build # Production build`); } if (hasExpoMobile) { buildCommands.push(`cd mobile && npm start # Expo dev server`); buildCommands.push(`cd mobile && npm run typecheck # tsc --noEmit`); } return { repoName, hasFastifyBackend, hasNextWeb, hasExpoMobile, hasSwiftIos, hasKotlinAndroid, hasKmpShared, backendModules, backendTestCount, webTestCount, mobileTestCount, backendLibFiles, webLibFiles, cosmosContainers, techStack, buildCommands, }; } // ── Markdown Generator ─────────────────────────────────────────────────────── function generateAgentsMd(manifest: ProductManifest, info: RepoInfo): string { const { productId, displayName, domain, tagline } = manifest; const repoDesc = tagline ?? `${displayName} product`; const lines: string[] = []; // ── Header lines.push(`# AGENTS.md — AI Coding Agent Instructions`); lines.push(''); lines.push( `> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.` ); lines.push(`> **Repo:** \`${info.repoName}\` — ${repoDesc}.`); lines.push(''); lines.push('---'); lines.push(''); // ── 1. Project Identity lines.push('## 1. Project Identity'); lines.push(''); lines.push('| Key | Value |'); lines.push('|-----|-------|'); lines.push(`| **Product** | ${displayName} |`); lines.push(`| **Product ID** | \`${productId}\` |`); if (domain) lines.push(`| **Domain** | ${domain} |`); lines.push(`| **Repo** | \`${info.repoName}\` |`); lines.push(`| **Ecosystem** | ByteLyst (shares platform-service with other ByteLyst products) |`); lines.push(''); // ── 2. Repo Layout (simplified tree) lines.push('## 2. Repo Layout'); lines.push(''); lines.push('```'); lines.push(`${info.repoName}/`); if (info.hasFastifyBackend) { const port = manifest.backendPort ?? 4000; lines.push( `├── backend/ # Fastify 5 + TypeScript ESM backend (port ${port})` ); lines.push(`│ ├── src/`); if (info.backendLibFiles.length > 0) { lines.push(`│ │ ├── lib/ # Shared backend wiring`); for (const f of info.backendLibFiles.slice(0, 8)) { lines.push(`│ │ │ ├── ${f}`); } if (info.backendLibFiles.length > 8) { lines.push(`│ │ │ └── ... (${info.backendLibFiles.length - 8} more)`); } } if (info.backendModules.length > 0) { lines.push(`│ │ ├── modules/`); for (const m of info.backendModules) { lines.push(`│ │ │ ├── ${m}/`); } } lines.push(`│ │ └── server.ts`); lines.push(`│ ├── package.json`); lines.push(`│ └── tsconfig.json`); lines.push('│'); } if (info.hasNextWeb) { lines.push(`├── web/ # Next.js 16 + React 19 (App Router)`); lines.push(`│ ├── src/`); lines.push(`│ │ ├── app/ # App Router pages`); if (info.webLibFiles.length > 0) { lines.push(`│ │ └── lib/ # Pure TS clients + config`); } lines.push(`│ ├── package.json`); lines.push(`│ └── tsconfig.json`); lines.push('│'); } if (info.hasExpoMobile) { lines.push(`├── mobile/ # React Native + Expo`); lines.push('│'); } if (info.hasSwiftIos) { lines.push(`├── ios/ # SwiftUI native app`); lines.push('│'); } if (info.hasKotlinAndroid) { lines.push(`├── android/ # Jetpack Compose`); lines.push('│'); } lines.push(`├── shared/`); lines.push(`│ └── product.json # Canonical product identity`); lines.push(`├── AGENTS.md # This file`); lines.push(`└── README.md`); lines.push('```'); lines.push(''); // ── 3. Tech Stack lines.push('## 3. Tech Stack'); lines.push(''); lines.push('| Layer | Technology |'); lines.push('|-------|-----------|'); for (const { layer, tech } of info.techStack) { lines.push(`| **${layer}** | ${tech} |`); } // Test counts const totalTests = info.backendTestCount + info.webTestCount + info.mobileTestCount; if (totalTests > 0) { const parts: string[] = []; if (info.backendTestCount > 0) parts.push(`${info.backendTestCount} backend`); if (info.webTestCount > 0) parts.push(`${info.webTestCount} web`); if (info.mobileTestCount > 0) parts.push(`${info.mobileTestCount} mobile`); lines.push(`| **Tests** | Vitest — ~${totalTests} tests (${parts.join(' + ')}) |`); } lines.push(''); // ── 4. Coding Conventions lines.push('## 4. Coding Conventions'); lines.push(''); lines.push('### MUST follow'); lines.push(''); lines.push(`- Every Cosmos document MUST include a \`productId: "${productId}"\` field`); if (info.hasFastifyBackend) { lines.push('- Backend modules follow `types.ts` → `repository.ts` → `routes.ts` pattern'); lines.push( '- All repositories use `@bytelyst/datastore` getCollection() — never direct Cosmos SDK calls' ); } if (info.hasNextWeb) { lines.push('- Web engine logic in `web/src/lib/` — pure TS, no React imports'); lines.push('- Web components in `web/src/components/` — React UI only'); } if (info.hasExpoMobile) { lines.push('- Mobile engine logic in `mobile/src/lib/` — pure TS, no React Native imports'); } lines.push( '- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`' ); lines.push(''); lines.push('### MUST NOT do'); lines.push(''); lines.push( '- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify' ); lines.push('- Never use `any` type — use Zod inference or explicit types'); lines.push('- Never hardcode colors — use theme tokens'); lines.push('- Never hardcode API URLs — use env vars or config'); lines.push(`- Never hardcode product ID — use \`productConfig.productId\``); lines.push('- Never modify tests to make them pass — fix the actual code'); lines.push('- Never delete existing comments or documentation unless explicitly asked'); lines.push('- Never add emojis to code unless explicitly asked'); lines.push(''); // ── 5. Build & Test Commands if (info.buildCommands.length > 0) { lines.push('## 5. Build & Test Commands'); lines.push(''); lines.push('```bash'); for (const cmd of info.buildCommands) { lines.push(cmd); } lines.push('```'); lines.push(''); } // ── 6. Backend API Modules (if applicable) if (info.hasFastifyBackend && info.backendModules.length > 0) { lines.push('## 6. Backend Modules'); lines.push(''); lines.push('| Module | Container | Description |'); lines.push('|--------|-----------|-------------|'); for (const mod of info.backendModules) { const container = mod.replace(/-/g, '_'); lines.push(`| \`${mod}\` | \`${container}\` | ${mod.replace(/-/g, ' ')} |`); } lines.push(''); } // ── Custom section placeholder lines.push(''); lines.push(''); lines.push(''); return lines.join('\n'); } // ── Custom Section Preservation ────────────────────────────────────────────── function extractCustomSections(content: string): Map { const sections = new Map(); const regex = /\n([\s\S]*?)/g; let match; while ((match = regex.exec(content)) !== null) { sections.set(match[1], match[2]); } return sections; } function mergeCustomSections(newContent: string, existing: Map): string { let result = newContent; for (const [key, value] of existing) { const placeholder = `\n`; const replacement = `\n${value}`; result = result.replace(placeholder, replacement); } return result; } // ── Symlink Manager ────────────────────────────────────────────────────────── async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise { const targets = ['CLAUDE.md', '.cursorrules', '.windsurfrules']; for (const target of targets) { const linkPath = path.join(repoPath, target); const exists = await fileExists(linkPath); if (exists) { try { const stat = await fs.lstat(linkPath); if (stat.isSymbolicLink()) { const linkTarget = await fs.readlink(linkPath); if (linkTarget === 'AGENTS.md') { continue; // already correct } } } catch { // not a symlink } } if (dryRun) { console.log(` 📝 Would create symlink: ${target} → AGENTS.md`); } else { try { if (exists) await fs.unlink(linkPath); await fs.symlink('AGENTS.md', linkPath); console.log(` ✅ ${target} → AGENTS.md`); } catch (err) { console.log( ` ⚠️ Could not create symlink ${target}: ${err instanceof Error ? err.message : String(err)}` ); } } } } // ── Main ───────────────────────────────────────────────────────────────────── async function main(): Promise { const { repo, dryRun, update } = parseArgs(); const repoPath = path.resolve(repo); console.log(`\n📄 AGENTS.md Generator`); console.log(` Repo: ${repoPath}`); if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); if (update) console.log(' 🔄 UPDATE mode — preserving custom sections\n'); // Load product.json const manifest = await loadProductJson(repoPath); console.log(` Product: ${manifest.displayName} (${manifest.productId})`); // Scan repo const info = await scanRepo(repoPath, manifest); console.log(` Backend modules: ${info.backendModules.length}`); console.log(` Tests: ~${info.backendTestCount + info.webTestCount + info.mobileTestCount}`); console.log(''); // Generate content let content = generateAgentsMd(manifest, info); // Merge custom sections if updating if (update) { const agentsPath = path.join(repoPath, 'AGENTS.md'); try { const existing = await fs.readFile(agentsPath, 'utf-8'); const customSections = extractCustomSections(existing); if (customSections.size > 0) { console.log(` 🔄 Preserving ${customSections.size} custom section(s)`); content = mergeCustomSections(content, customSections); } } catch { console.log(' ℹ️ No existing AGENTS.md to preserve custom sections from'); } } if (dryRun) { console.log('── AGENTS.md ──────────────────────────────────────'); console.log(content); console.log('\n── Symlinks ──────────────────────────────────────'); await ensureSymlinks(repoPath, true); console.log('\n✨ Dry run complete.'); return; } // Write AGENTS.md const agentsPath = path.join(repoPath, 'AGENTS.md'); await fs.writeFile(agentsPath, content, 'utf-8'); console.log(` ✅ AGENTS.md written`); // Ensure symlinks await ensureSymlinks(repoPath, false); console.log(`\n✨ AGENTS.md generated for ${manifest.displayName}.`); } main().catch(err => { console.error('❌ Error:', err instanceof Error ? err.message : String(err)); process.exit(1); });