/** * agents-md: Generate Next.js documentation index for AI coding agents. * * Downloads docs from GitHub via git sparse-checkout, builds a compact * index of all doc files, and injects it into CLAUDE.md or AGENTS.md. */ import execa from 'execa' import fs from 'fs' import path from 'path' import os from 'os' interface NextjsVersionResult { version: string | null error?: string } export function getNextjsVersion(cwd: string): NextjsVersionResult { try { const nextPkgPath = require.resolve('next/package.json', { paths: [cwd] }) const pkg = JSON.parse(fs.readFileSync(nextPkgPath, 'utf-8')) return { version: pkg.version } } catch { // Not found at root - check for monorepo workspace const workspace = detectWorkspace(cwd) if (workspace.isMonorepo && workspace.packages.length > 0) { const highestVersion = findNextjsInWorkspace(cwd, workspace.packages) if (highestVersion) { return { version: highestVersion } } return { version: null, error: `No Next.js found in ${workspace.type} workspace packages.`, } } return { version: null, error: 'Next.js is not installed in this project.', } } } function versionToGitHubTag(version: string): string { return version.startsWith('v') ? version : `v${version}` } interface PullOptions { cwd: string version?: string docsDir?: string } interface PullResult { success: boolean docsPath?: string nextjsVersion?: string error?: string } export async function pullDocs(options: PullOptions): Promise { const { cwd, version: versionOverride, docsDir } = options let nextjsVersion: string if (versionOverride) { nextjsVersion = versionOverride } else { const versionResult = getNextjsVersion(cwd) if (!versionResult.version) { return { success: false, error: versionResult.error || 'Could not detect Next.js version', } } nextjsVersion = versionResult.version } const docsPath = docsDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'next-agents-md-')) const useTempDir = !docsDir try { if (useTempDir && fs.existsSync(docsPath)) { fs.rmSync(docsPath, { recursive: true }) } const tag = versionToGitHubTag(nextjsVersion) await cloneDocsFolder(tag, docsPath) return { success: true, docsPath, nextjsVersion, } } catch (error) { if (useTempDir && fs.existsSync(docsPath)) { fs.rmSync(docsPath, { recursive: true }) } return { success: false, error: error instanceof Error ? error.message : String(error), } } } async function cloneDocsFolder(tag: string, destDir: string): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'next-agents-md-')) try { try { await execa( 'git', [ 'clone', '--depth', '1', '--filter=blob:none', '--sparse', '--branch', tag, 'https://github.com/vercel/next.js.git', '.', ], { cwd: tempDir } ) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (message.includes('not found') || message.includes('did not match')) { throw new Error( `Could not find documentation for Next.js ${tag}. This version may not exist on GitHub yet.` ) } throw error } await execa('git', ['sparse-checkout', 'set', 'docs'], { cwd: tempDir }) const sourceDocsDir = path.join(tempDir, 'docs') if (!fs.existsSync(sourceDocsDir)) { throw new Error('docs folder not found in cloned repository') } if (fs.existsSync(destDir)) { fs.rmSync(destDir, { recursive: true }) } fs.mkdirSync(destDir, { recursive: true }) fs.cpSync(sourceDocsDir, destDir, { recursive: true }) } finally { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }) } } } export function collectDocFiles(dir: string): { relativePath: string }[] { return (fs.readdirSync(dir, { recursive: true }) as string[]) .filter( (f) => (f.endsWith('.mdx') || f.endsWith('.md')) && !/[/\\]index\.mdx$/.test(f) && !/[/\\]index\.md$/.test(f) && !f.startsWith('index.') ) .sort() .map((f) => ({ relativePath: f.replace(/\\/g, '/') })) } interface DocSection { name: string files: { relativePath: string }[] subsections: DocSection[] } export function buildDocTree(files: { relativePath: string }[]): DocSection[] { const sections: Map = new Map() for (const file of files) { const parts = file.relativePath.split(/[/\\]/) if (parts.length < 2) continue const topLevelDir = parts[0] if (!sections.has(topLevelDir)) { sections.set(topLevelDir, { name: topLevelDir, files: [], subsections: [], }) } const section = sections.get(topLevelDir)! if (parts.length === 2) { section.files.push({ relativePath: file.relativePath }) } else { const subsectionDir = parts[1] let subsection = section.subsections.find((s) => s.name === subsectionDir) if (!subsection) { subsection = { name: subsectionDir, files: [], subsections: [] } section.subsections.push(subsection) } if (parts.length === 3) { subsection.files.push({ relativePath: file.relativePath }) } else { const subSubDir = parts[2] let subSubsection = subsection.subsections.find( (s) => s.name === subSubDir ) if (!subSubsection) { subSubsection = { name: subSubDir, files: [], subsections: [] } subsection.subsections.push(subSubsection) } subSubsection.files.push({ relativePath: file.relativePath }) } } } const sortedSections = Array.from(sections.values()).sort((a, b) => a.name.localeCompare(b.name) ) for (const section of sortedSections) { section.files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) section.subsections.sort((a, b) => a.name.localeCompare(b.name)) for (const subsection of section.subsections) { subsection.files.sort((a, b) => a.relativePath.localeCompare(b.relativePath) ) subsection.subsections.sort((a, b) => a.name.localeCompare(b.name)) } } return sortedSections } interface ClaudeMdIndexData { docsPath: string sections: DocSection[] outputFile?: string } export function generateClaudeMdIndex(data: ClaudeMdIndexData): string { const { docsPath, sections, outputFile } = data const parts: string[] = [] parts.push('[Next.js Docs Index]') parts.push(`root: ${docsPath}`) parts.push( 'STOP. What you remember about Next.js is WRONG for this project. Always search docs and read before any task.' ) const targetFile = outputFile || 'CLAUDE.md' parts.push( `If docs missing, run this command first: npx @next/codemod agents-md --output ${targetFile}` ) const allFiles = collectAllFilesFromSections(sections) const grouped = groupByDirectory(allFiles) for (const [dir, files] of grouped) { parts.push(`${dir}:{${files.join(',')}}`) } return parts.join('|') } function collectAllFilesFromSections(sections: DocSection[]): string[] { const files: string[] = [] for (const section of sections) { for (const file of section.files) { files.push(file.relativePath) } files.push(...collectAllFilesFromSections(section.subsections)) } return files } function groupByDirectory(files: string[]): Map { const grouped = new Map() for (const filePath of files) { const lastSlash = Math.max( filePath.lastIndexOf('/'), filePath.lastIndexOf('\\') ) const dir = lastSlash === -1 ? '.' : filePath.slice(0, lastSlash) const fileName = lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1) const existing = grouped.get(dir) if (existing) { existing.push(fileName) } else { grouped.set(dir, [fileName]) } } return grouped } const START_MARKER = '' const END_MARKER = '' function hasExistingIndex(content: string): boolean { return content.includes(START_MARKER) } function wrapWithMarkers(content: string): string { return `${START_MARKER}${content}${END_MARKER}` } export function injectIntoClaudeMd( claudeMdContent: string, indexContent: string ): string { const wrappedContent = wrapWithMarkers(indexContent) if (hasExistingIndex(claudeMdContent)) { const startIdx = claudeMdContent.indexOf(START_MARKER) const endIdx = claudeMdContent.indexOf(END_MARKER) + END_MARKER.length return ( claudeMdContent.slice(0, startIdx) + wrappedContent + claudeMdContent.slice(endIdx) ) } const separator = claudeMdContent.endsWith('\n') ? '\n' : '\n\n' return claudeMdContent + separator + wrappedContent + '\n' } interface GitignoreStatus { path: string updated: boolean alreadyPresent: boolean } const GITIGNORE_ENTRY = '.next-docs/' export function ensureGitignoreEntry(cwd: string): GitignoreStatus { const gitignorePath = path.join(cwd, '.gitignore') const entryRegex = /^\s*\.next-docs(?:\/.*)?$/ let content = '' if (fs.existsSync(gitignorePath)) { content = fs.readFileSync(gitignorePath, 'utf-8') } const hasEntry = content.split(/\r?\n/).some((line) => entryRegex.test(line)) if (hasEntry) { return { path: gitignorePath, updated: false, alreadyPresent: true } } const needsNewline = content.length > 0 && !content.endsWith('\n') const header = content.includes('# next-agents-md') ? '' : '# next-agents-md\n' const newContent = content + (needsNewline ? '\n' : '') + header + `${GITIGNORE_ENTRY}\n` fs.writeFileSync(gitignorePath, newContent, 'utf-8') return { path: gitignorePath, updated: true, alreadyPresent: false } } type WorkspaceType = 'pnpm' | 'npm' | 'yarn' | 'nx' | 'lerna' | null interface WorkspaceInfo { isMonorepo: boolean type: WorkspaceType packages: string[] } function detectWorkspace(cwd: string): WorkspaceInfo { const packageJsonPath = path.join(cwd, 'package.json') // Check pnpm workspaces (pnpm-workspace.yaml) const pnpmWorkspacePath = path.join(cwd, 'pnpm-workspace.yaml') if (fs.existsSync(pnpmWorkspacePath)) { const packages = parsePnpmWorkspace(pnpmWorkspacePath) if (packages.length > 0) { return { isMonorepo: true, type: 'pnpm', packages } } } // Check npm/yarn workspaces (package.json workspaces field) if (fs.existsSync(packageJsonPath)) { const packages = parsePackageJsonWorkspaces(packageJsonPath) if (packages.length > 0) { return { isMonorepo: true, type: 'npm', packages } } } // Check Lerna (lerna.json) const lernaPath = path.join(cwd, 'lerna.json') if (fs.existsSync(lernaPath)) { const packages = parseLernaConfig(lernaPath) if (packages.length > 0) { return { isMonorepo: true, type: 'lerna', packages } } } // Check Nx (nx.json) const nxPath = path.join(cwd, 'nx.json') if (fs.existsSync(nxPath)) { const packages = parseNxWorkspace(cwd, packageJsonPath) if (packages.length > 0) { return { isMonorepo: true, type: 'nx', packages } } } return { isMonorepo: false, type: null, packages: [] } } function parsePnpmWorkspace(filePath: string): string[] { try { const content = fs.readFileSync(filePath, 'utf-8') const lines = content.split('\n') const packages: string[] = [] let inPackages = false for (const line of lines) { const trimmed = line.trim() if (trimmed === 'packages:') { inPackages = true continue } if (inPackages) { if (trimmed && !trimmed.startsWith('-') && !trimmed.startsWith('#')) { break } const match = trimmed.match(/^-\s*['"]?([^'"]+)['"]?$/) if (match) { packages.push(match[1]) } } } return packages } catch { return [] } } function parsePackageJsonWorkspaces(filePath: string): string[] { try { const content = fs.readFileSync(filePath, 'utf-8') const pkg = JSON.parse(content) if (Array.isArray(pkg.workspaces)) { return pkg.workspaces } if (pkg.workspaces?.packages && Array.isArray(pkg.workspaces.packages)) { return pkg.workspaces.packages } return [] } catch { return [] } } function parseLernaConfig(filePath: string): string[] { try { const content = fs.readFileSync(filePath, 'utf-8') const config = JSON.parse(content) if (Array.isArray(config.packages)) { return config.packages } return ['packages/*'] } catch { return [] } } function parseNxWorkspace(cwd: string, packageJsonPath: string): string[] { if (fs.existsSync(packageJsonPath)) { const packages = parsePackageJsonWorkspaces(packageJsonPath) if (packages.length > 0) { return packages } } const defaultPatterns = ['apps/*', 'libs/*', 'packages/*'] const existingPatterns: string[] = [] for (const pattern of defaultPatterns) { const basePath = path.join(cwd, pattern.replace('/*', '')) if (fs.existsSync(basePath)) { existingPatterns.push(pattern) } } return existingPatterns } function findNextjsInWorkspace(cwd: string, patterns: string[]): string | null { const packagePaths = expandWorkspacePatterns(cwd, patterns) const versions: string[] = [] for (const pkgPath of packagePaths) { try { const nextPkgPath = require.resolve('next/package.json', { paths: [pkgPath], }) const pkg = JSON.parse(fs.readFileSync(nextPkgPath, 'utf-8')) if (pkg.version) { versions.push(pkg.version) } } catch { // Next.js not installed in this package } } return findHighestVersion(versions) } function expandWorkspacePatterns(cwd: string, patterns: string[]): string[] { const packagePaths: string[] = [] for (const pattern of patterns) { if (pattern.startsWith('!')) continue if (pattern.includes('*')) { packagePaths.push(...expandGlobPattern(cwd, pattern)) } else { const fullPath = path.join(cwd, pattern) if (fs.existsSync(fullPath)) { packagePaths.push(fullPath) } } } return [...new Set(packagePaths)] } function expandGlobPattern(cwd: string, pattern: string): string[] { const parts = pattern.split('/') const results: string[] = [] function walk(currentPath: string, partIndex: number): void { if (partIndex >= parts.length) { if (fs.existsSync(path.join(currentPath, 'package.json'))) { results.push(currentPath) } return } const part = parts[partIndex] if (part === '*') { if (!fs.existsSync(currentPath)) return try { for (const entry of fs.readdirSync(currentPath)) { const fullPath = path.join(currentPath, entry) if (isDirectory(fullPath)) { if (partIndex === parts.length - 1) { if (fs.existsSync(path.join(fullPath, 'package.json'))) { results.push(fullPath) } } else { walk(fullPath, partIndex + 1) } } } } catch { // Permission denied } } else if (part === '**') { walkRecursive(currentPath, results) } else { walk(path.join(currentPath, part), partIndex + 1) } } walk(cwd, 0) return results } function walkRecursive(dir: string, results: string[]): void { if (!fs.existsSync(dir)) return if (fs.existsSync(path.join(dir, 'package.json'))) { results.push(dir) } try { for (const entry of fs.readdirSync(dir)) { if (entry === 'node_modules' || entry.startsWith('.')) continue const fullPath = path.join(dir, entry) if (isDirectory(fullPath)) { walkRecursive(fullPath, results) } } } catch { // Permission denied } } function isDirectory(dirPath: string): boolean { try { return fs.statSync(dirPath).isDirectory() } catch { return false } } function findHighestVersion(versions: string[]): string | null { if (versions.length === 0) return null if (versions.length === 1) return versions[0] return versions.reduce((highest, current) => { return compareVersions(current, highest) > 0 ? current : highest }) } function compareVersions(a: string, b: string): number { const parseVersion = (v: string) => { const match = v.match(/^(\d+)\.(\d+)\.(\d+)/) if (!match) return [0, 0, 0] return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])] } const [aMajor, aMinor, aPatch] = parseVersion(a) const [bMajor, bMinor, bPatch] = parseVersion(b) if (aMajor !== bMajor) return aMajor - bMajor if (aMinor !== bMinor) return aMinor - bMinor return aPatch - bPatch }