/** * Inject project docs INDEX into AGENTS.md between marker comments. * * Normalizes agent instruction files: migrates CLAUDE.md / GEMINI.md content * into AGENTS.md (the canonical file) and replaces originals with symlinks. * * Reads the README.md index from .context/components/ and injects a compact reference * between and markers. * Also discovers core docs (design system, grid, architecture) and copies * bundled best-practice docs for the detected platform. */ import { copyFileSync, existsSync, mkdirSync, readFileSync, readlinkSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { isDir, isFile, isSymlink, walkDir } from './fs' import { log } from './log' // ── Constants ────────────────────────────────────────────────────────── const MARKER_START = '' const MARKER_END = '' const MARKER_PATTERN = new RegExp( MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]*?' + MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ) const CORE_DOCS_ROOT = 'docs' const CONTEXT_ROOT = '.context' const CANONICAL_AGENT_FILE = 'AGENTS.md' const LEGACY_AGENT_FILES = ['CLAUDE.md', 'GEMINI.md'] // ── Types ────────────────────────────────────────────────────────────── interface CoreDocs { frontend: string[] entities: string[] entitiesRoot: string bestPractices: string[] layout: string[] a11y: string[] } // ── Helpers ──────────────────────────────────────────────────────────── function groupByDir(paths: string[]): [string, string[]][] { const groups = new Map() for (const p of paths) { const lastSlash = p.lastIndexOf('/') const dir = lastSlash >= 0 ? p.slice(0, lastSlash) : '.' const file = lastSlash >= 0 ? p.slice(lastSlash + 1) : p const list = groups.get(dir) if (list) list.push(file) else groups.set(dir, [file]) } return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)) } // ── Platform → doc folder mapping ───────────────────────────────────── const PLATFORM_DOC_FOLDER: Record = { nextjs: 'react', shopify: 'liquid', } // ── Best practices copy ─────────────────────────────────────────────── function copyBestPractices(projectRoot: string, platform: string): string[] { const folder = PLATFORM_DOC_FOLDER[platform] if (!folder) return [] const bundledDir = resolve(import.meta.dir, '..', 'docs', folder) if (!isDir(bundledDir)) return [] const destDir = join(projectRoot, CONTEXT_ROOT, 'best-practices') mkdirSync(destDir, { recursive: true }) const files = walkDir(bundledDir, ['.md']) const copied: string[] = [] // Ensure README is first so the index is always listed before patterns files.sort((a, b) => { const aName = a.slice(a.lastIndexOf('/') + 1) const bName = b.slice(b.lastIndexOf('/') + 1) const aReadme = aName.toLowerCase().startsWith('readme') const bReadme = bName.toLowerCase().startsWith('readme') if (aReadme && !bReadme) return -1 if (!aReadme && bReadme) return 1 return aName.localeCompare(bName) }) for (const src of files) { const name = src.slice(src.lastIndexOf('/') + 1) copyFileSync(src, join(destDir, name)) copied.push(`best-practices/${name}`) } if (copied.length) { log.success(`Copied ${copied.length} best practice doc(s)`) } return copied } // ── Layout docs copy ──────────────────────────────────────────────── function copyLayout(projectRoot: string): string[] { const bundledDir = resolve(import.meta.dir, '..', 'docs', 'layout') if (!isDir(bundledDir)) return [] const destDir = join(projectRoot, CONTEXT_ROOT, 'layout') mkdirSync(destDir, { recursive: true }) const mdFiles = walkDir(bundledDir, ['.md']) const copied: string[] = [] for (const src of mdFiles) { const name = src.slice(src.lastIndexOf('/') + 1) copyFileSync(src, join(destDir, name)) copied.push(`layout/${name}`) } // Pixel→column conversion is provided by the grid package CLI // (`bunx px-to-cols`), so no script is injected here. if (copied.length) { log.success(`Copied ${copied.length} layout doc(s)`) } return copied } // ── Accessibility docs copy ───────────────────────────────────────── function copyA11y(projectRoot: string): string[] { const bundledDir = resolve(import.meta.dir, '..', 'docs', 'a11y') if (!isDir(bundledDir)) return [] const destDir = join(projectRoot, CONTEXT_ROOT, 'a11y') mkdirSync(destDir, { recursive: true }) const files = walkDir(bundledDir, ['.md']) const copied: string[] = [] // Ensure README is first files.sort((a, b) => { const aName = a.slice(a.lastIndexOf('/') + 1) const bName = b.slice(b.lastIndexOf('/') + 1) const aReadme = aName.toLowerCase().startsWith('readme') const bReadme = bName.toLowerCase().startsWith('readme') if (aReadme && !bReadme) return -1 if (!aReadme && bReadme) return 1 return aName.localeCompare(bName) }) for (const src of files) { const name = src.slice(src.lastIndexOf('/') + 1) copyFileSync(src, join(destDir, name)) copied.push(`a11y/${name}`) } if (copied.length) { log.success(`Copied ${copied.length} accessibility doc(s)`) } return copied } // ── Core docs discovery ──────────────────────────────────────────────── function discoverCoreDocs(projectRoot: string, bestPractices: string[], layout: string[], a11y: string[]): CoreDocs { const docsDir = join(projectRoot, CORE_DOCS_ROOT) const result: CoreDocs = { frontend: [], entities: [], entitiesRoot: '', bestPractices, layout, a11y } if (!isDir(docsDir)) return result // Frontend: design tokens, grid/layout for (const name of ['design-system.md', 'grid-system.md']) { if (isFile(join(docsDir, name))) result.frontend.push(name) } // Entities: architecture specs const archDir = join(docsDir, 'specs', 'architecture') if (isDir(archDir)) { result.entitiesRoot = relative(docsDir, archDir) result.entities = walkDir(archDir, ['.md']).map((f) => relative(archDir, f)) } return result } // ── Block builder ────────────────────────────────────────────────────── // Listing every component filename bloats the always-loaded AGENTS.md. Point at // the README.md index instead — it carries conventions and a one-line description // per component, read on demand. function buildComponentLines(names: string[]): string[] { const root = `./${CONTEXT_ROOT}/components` return [ `[Component Index]|root: ${root}`, `|${names.length} component docs — ${root}/README.md indexes each with a one-line description`, `|IMPORTANT: find the component in README.md, then read its .md before using it`, ] } const PLATFORM_LABEL: Record = { nextjs: 'React & Next.js', shopify: 'Liquid & Alpine.js', } function buildIndexBlock(indexPath: string | null, coreDocs: CoreDocs | null, platform?: string): string | null { const names: string[] = [] if (indexPath) { const indexContent = readFileSync(indexPath, 'utf-8') for (const m of indexContent.matchAll(/^-\s+\*\*(.+?)\*\*/gm)) { names.push(`${m[1]}.md`) } } const hasCoreDocs = coreDocs && ( coreDocs.frontend.length > 0 || coreDocs.bestPractices.length > 0 || coreDocs.layout.length > 0 || coreDocs.a11y.length > 0 || coreDocs.entities.length > 0 ) if (names.length === 0 && !hasCoreDocs) return null const lines: string[] = [MARKER_START, '## Project Docs'] const docsRoot = `./${CORE_DOCS_ROOT}` // Frontend (includes layout docs when present) if (coreDocs?.frontend.length || coreDocs?.layout.length) { const hasLayout = coreDocs?.layout.length const frontendFiles = (coreDocs?.frontend ?? []).map((f) => `${CORE_DOCS_ROOT}/${f}`) const layoutFiles = (coreDocs?.layout ?? []).map((p) => `${CONTEXT_ROOT}/${p}`) lines.push('|[Frontend — Design System & Grid]|root: .') lines.push(`|frontend:{${[...frontendFiles, ...layoutFiles].join(',')}}`) lines.push(`|IMPORTANT: Read before any styling, layout, or spacing work${hasLayout ? ' — convert pixels to columns with: bunx px-to-cols --columns N --mockup N --gutter N --margin N' : ''}`) } // Best Practices if (coreDocs?.bestPractices.length) { const contextRoot = `./${CONTEXT_ROOT}` const label = (platform && PLATFORM_LABEL[platform]) || '' const fileNames = coreDocs.bestPractices.map((p) => p.slice(p.lastIndexOf('/') + 1)) lines.push(`|[Best Practices${label ? ` — ${label}` : ''}]|root: ${contextRoot}`) lines.push(`|best-practices:{${fileNames.join(',')}}`) lines.push(`|IMPORTANT: ${label || 'Performance'} patterns — read best-practices/README.md first, it indexes all patterns by category and impact`) } // Accessibility if (coreDocs?.a11y.length) { const contextRoot = `./${CONTEXT_ROOT}` const fileNames = coreDocs.a11y.map((p) => p.slice(p.lastIndexOf('/') + 1)) lines.push(`|[Accessibility — WCAG 2.2 & RGAA]|root: ${contextRoot}`) lines.push(`|a11y:{${fileNames.join(',')}}`) lines.push('|IMPORTANT: Read before implementing interactive components, forms, or dynamic content') } // Components (when present) if (names.length > 0) { lines.push(...buildComponentLines(names)) } // Entities (grouped by directory) if (coreDocs?.entities.length) { const entRoot = coreDocs.entitiesRoot ? `${docsRoot}/${coreDocs.entitiesRoot}` : docsRoot lines.push(`|[Entities]|root: ${entRoot}`) for (const [dirPath, files] of groupByDir(coreDocs.entities)) { const prefix = dirPath === '.' ? '' : `${dirPath}:` lines.push(`|${prefix}{${files.join(',')}}`) } const entDesc = platform === 'nextjs' ? 'Read before any Sanity schema, GROQ resolver, or data modeling work' : platform === 'shopify' ? 'Read before any metafield, section schema, or data modeling work' : 'Read before any schema or data modeling work' lines.push(`|IMPORTANT: ${entDesc}`) } lines.push(MARKER_END) return lines.join('\n') } // ── Agent file normalization ──────────────────────────────────────────── /** * Normalize agent instruction files: CLAUDE.md / GEMINI.md → AGENTS.md. * * - If AGENTS.md doesn't exist, the first legacy file found is renamed to it. * - Any remaining legacy files have their content merged into AGENTS.md. * - Legacy files are replaced with symlinks → AGENTS.md. * - Files that are already symlinks pointing to AGENTS.md are left alone. * * Returns the resolved path to AGENTS.md. */ export function normalizeAgentFiles(projectRoot: string): string { const canonicalPath = join(projectRoot, CANONICAL_AGENT_FILE) for (const name of LEGACY_AGENT_FILES) { const filePath = join(projectRoot, name) if (!existsSync(filePath)) continue if (isSymlink(filePath)) { try { if (readlinkSync(filePath) === CANONICAL_AGENT_FILE) continue } catch { /* broken symlink — will be replaced */ } } if (!existsSync(canonicalPath)) { renameSync(filePath, canonicalPath) log.success(`Renamed ${name} → ${CANONICAL_AGENT_FILE}`) } else { const trimmed = readFileSync(filePath, 'utf-8').trim() const existing = readFileSync(canonicalPath, 'utf-8') if (trimmed && !existing.includes(trimmed)) { writeFileSync(canonicalPath, existing.trimEnd() + '\n\n' + trimmed + '\n', 'utf-8') log.success(`Merged ${name} content into ${CANONICAL_AGENT_FILE}`) } unlinkSync(filePath) } symlinkSync(CANONICAL_AGENT_FILE, filePath) log.success(`${name} → ${CANONICAL_AGENT_FILE} (symlink)`) } if (!existsSync(canonicalPath)) { writeFileSync(canonicalPath, '', 'utf-8') log.info(`Created empty ${CANONICAL_AGENT_FILE}`) } return canonicalPath } // ── AGENTS.md injection ──────────────────────────────────────────────── function injectIntoAgentsMd(agentsMdPath: string, block: string) { if (!existsSync(agentsMdPath)) { log.error(`${CANONICAL_AGENT_FILE} not found: ${agentsMdPath}`) process.exit(1) } const content = readFileSync(agentsMdPath, 'utf-8') // Single-pass: replacer callback tracks whether a match occurred let matched = false const replaced = content.replace(MARKER_PATTERN, () => { matched = true return block }) const newContent = matched ? replaced : content.trimEnd() + '\n\n' + block + '\n' writeFileSync(agentsMdPath, newContent, 'utf-8') log.success(`Injected index into ${CANONICAL_AGENT_FILE}`) } // ── .gitignore ───────────────────────────────────────────────────────── function ensureGitignore(projectRoot: string) { const gitignorePath = join(projectRoot, '.gitignore') const entry = `${CONTEXT_ROOT}/` if (existsSync(gitignorePath)) { const content = readFileSync(gitignorePath, 'utf-8') if (content.includes(entry) || content.includes(CONTEXT_ROOT)) return const padded = content.endsWith('\n') ? content : content + '\n' writeFileSync(gitignorePath, padded + `\n# Generated project context\n${entry}\n`, 'utf-8') } else { writeFileSync(gitignorePath, `# Generated project context\n${entry}\n`, 'utf-8') } log.info(`Added ${entry} to .gitignore`) } // ── Public API ───────────────────────────────────────────────────────── export interface InjectOptions { projectRoot: string indexPath?: string agentsMdPath?: string docsDir?: string platform?: 'nextjs' | 'shopify' ignore?: Set } export function inject(opts: InjectOptions) { const root = resolve(opts.projectRoot) const docsDir = opts.docsDir ?? `${CONTEXT_ROOT}/components` const indexPath = opts.indexPath ?? join(root, docsDir, 'README.md') const agentsMdPath = opts.agentsMdPath ?? normalizeAgentFiles(root) const skip = opts.ignore ?? new Set() if (skip.size) log.dim(`Ignoring: ${[...skip].join(', ')}`) // Copy best practices if platform is known const bestPractices = !skip.has('best-practices') && opts.platform ? copyBestPractices(root, opts.platform) : [] // Copy layout docs const layout = !skip.has('layout') ? copyLayout(root) : [] // Copy accessibility docs const a11y = !skip.has('a11y') ? copyA11y(root) : [] const coreDocs = discoverCoreDocs(root, bestPractices, layout, a11y) if (skip.has('frontend')) coreDocs.frontend = [] if (skip.has('entities')) { coreDocs.entities = []; coreDocs.entitiesRoot = '' } const total = coreDocs.frontend.length + coreDocs.entities.length + coreDocs.bestPractices.length + coreDocs.layout.length + coreDocs.a11y.length if (total) log.info(`Found ${total} core doc(s)`) const hasIndex = existsSync(indexPath) if (!hasIndex) log.warn('No component README.md index found — injecting docs only') const block = buildIndexBlock(hasIndex ? indexPath : null, total ? coreDocs : null, opts.platform) if (!block) { log.warn('No components or docs found, nothing to inject.') return } injectIntoAgentsMd(agentsMdPath, block) ensureGitignore(root) }