learning_ai_common_plat/packages/create-app/src/generators/agents-md.ts
saravanakumardb1 f051942ef6 feat(create-app): add API Route Generator (3.3) + AGENTS.md Auto-Generator (3.4)
3.3 API Route Generator (api-routes.ts):
- Two modes: 'direct' (Cosmos DB CRUD) and 'proxy' (backend fetch)
- Generates route.ts + [id]/route.ts (Next.js App Router named exports)
- Direct mode also generates lib/schemas/ + lib/repositories/ files
- withErrorHandler HOF wrapper, Zod validation, auth check
- Dry-run preview, configurable methods, skip existing files

3.4 AGENTS.md Auto-Generator (agents-md.ts):
- Reads shared/product.json for identity, port, domain
- Scans repo for backend modules, lib files, test counts
- Generates full AGENTS.md with identity, layout, stack, conventions
- --update preserves CUSTOM sections
- Creates CLAUDE.md, .cursorrules, .windsurfrules symlinks
2026-03-19 20:17:02 -07:00

604 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <!-- CUSTOM --> 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 <path> [--dry-run] [--update]
Options:
--repo, -r Path to product repo root (default: ".")
--dry-run, -d Preview without writing files
--update, -u Preserve <!-- CUSTOM --> sections in existing AGENTS.md
--help, -h Show this help
Custom Sections:
Wrap any hand-written content in <!-- CUSTOM:key --> / <!-- /CUSTOM:key -->
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<string, string>;
bundleId?: string;
version?: string;
description?: string;
licensePrefix?: string;
configDirName?: string;
envVarPrefix?: string;
}
async function loadProductJson(repoPath: string): Promise<ProductManifest> {
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<boolean> {
try {
const st = await fs.stat(p);
return st.isDirectory();
} catch {
return false;
}
}
async function fileExists(p: string): Promise<boolean> {
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<string[]> {
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<string[]> {
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<RepoInfo> {
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('<!-- CUSTOM:extra -->');
lines.push('<!-- /CUSTOM:extra -->');
lines.push('');
return lines.join('\n');
}
// ── Custom Section Preservation ──────────────────────────────────────────────
function extractCustomSections(content: string): Map<string, string> {
const sections = new Map<string, string>();
const regex = /<!-- CUSTOM:(\w+) -->\n([\s\S]*?)<!-- \/CUSTOM:\1 -->/g;
let match;
while ((match = regex.exec(content)) !== null) {
sections.set(match[1], match[2]);
}
return sections;
}
function mergeCustomSections(newContent: string, existing: Map<string, string>): string {
let result = newContent;
for (const [key, value] of existing) {
const placeholder = `<!-- CUSTOM:${key} -->\n<!-- /CUSTOM:${key} -->`;
const replacement = `<!-- CUSTOM:${key} -->\n${value}<!-- /CUSTOM:${key} -->`;
result = result.replace(placeholder, replacement);
}
return result;
}
// ── Symlink Manager ──────────────────────────────────────────────────────────
async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise<void> {
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<void> {
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);
});