From 1e859c06aafad02037e6240855c61c5968c2d7ba Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Fri, 23 Jan 2026 21:31:32 -0500 Subject: [PATCH] feat(next-codemod): add agents-md command for AI coding agents (#88961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a new `agents-md` subcommand to `@next/codemod` that generates a Next.js documentation index for AI coding agents (Claude, Cursor, GitHub Copilot, etc.). ## Usage ```bash # Interactive mode - prompts for version and target file npx @next/codemod agents-md # Non-interactive mode npx @next/codemod agents-md --version 15.1.0 --output CLAUDE.md # Auto-detect version, specify output npx @next/codemod agents-md --output AGENTS.md ``` ## What it does 1. Downloads the Next.js documentation matching your project's version to `.next-docs/` (via git sparse-checkout) 2. Generates a compact index of all doc files grouped by directory 3. Injects the index into `CLAUDE.md` or `AGENTS.md` between marker comments 4. Adds `.next-docs/` to `.gitignore` The index helps AI agents prefer local documentation over pre-trained knowledge, ensuring answers match your Next.js version. ## Options | Option | Description | |--------|-------------| | `--version ` | Specify Next.js version (auto-detected from `package.json` if not provided) | | `--output ` | Target file path (e.g., `CLAUDE.md`, `AGENTS.md`) | ## Output format The generated index is a single line with pipe separators: ``` [Next.js Docs Index]|root: ./.next-docs|IMPORTANT: Prefer retrieval-led reasoning...|dir:{file1.mdx,file2.mdx}|... ``` ## Test plan - [x] Build passes - [x] Tested interactive mode - [x] Tested non-interactive mode with `--version` and `--output` - [x] Tested auto-detect version with `--output` only - [x] Verified `.gitignore` is updated - [x] Verified index is injected correctly (new file and existing file) ## Eval results Tested against 19 Next.js-specific agent evals with 100% pass rate: | Eval | Result | |------|--------| | agent-000-app-router-migration-simple | ✅✅✅ (89.5s) | | agent-021-avoid-fetch-in-effect | ✅✅✅ (90.4s) | | agent-022-prefer-server-actions | ✅✅✅ (67.1s) | | agent-023-avoid-getserversideprops | ✅✅✅ (102.5s) | | agent-024-avoid-redundant-usestate | ✅✅✅ (62.9s) | | agent-025-prefer-next-link | ✅✅✅ (69.5s) | | agent-026-no-serial-await | ✅✅✅ (96.2s) | | agent-027-prefer-next-image | ✅✅✅ (68.0s) | | agent-028-prefer-next-font | ✅✅✅ (64.2s) | | agent-029-use-cache-directive | ✅✅✅ (76.6s) | | agent-030-app-router-migration-hard | ✅✅✅ (220.8s) | | agent-031-proxy-middleware | ✅✅✅ (92.4s) | | agent-032-use-cache-directive | ✅✅✅ (71.3s) | | agent-033-forbidden-auth | ✅✅✅ (100.6s) | | agent-034-async-cookies | ✅✅✅ (68.7s) | | agent-035-connection-dynamic | ✅✅✅ (65.2s) | | agent-036-after-response | ✅✅✅ (68.7s) | | agent-037-updatetag-cache | ✅✅✅ (70.3s) | | agent-038-refresh-settings | ✅✅✅ (71.8s) | | **Overall** | **19/19/19 (100%, 100%, 100%)** | Legend: ✅✅✅ = Build/Lint/Test --- packages/next-codemod/.gitignore | 3 +- packages/next-codemod/bin/agents-md.ts | 207 ++++++ packages/next-codemod/bin/next-codemod.ts | 24 + .../lib/__tests__/agents-md.test.js | 142 ++++ packages/next-codemod/lib/agents-md.ts | 644 ++++++++++++++++++ 5 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 packages/next-codemod/bin/agents-md.ts create mode 100644 packages/next-codemod/lib/__tests__/agents-md.test.js create mode 100644 packages/next-codemod/lib/agents-md.ts diff --git a/packages/next-codemod/.gitignore b/packages/next-codemod/.gitignore index 5eadbe5734c45e..284c37d29f3bd5 100644 --- a/packages/next-codemod/.gitignore +++ b/packages/next-codemod/.gitignore @@ -2,4 +2,5 @@ *.js *.js.map !transforms/__tests__/**/*.js -!transforms/__testfixtures__/**/*.js \ No newline at end of file +!transforms/__testfixtures__/**/*.js +!lib/__tests__/**/*.js \ No newline at end of file diff --git a/packages/next-codemod/bin/agents-md.ts b/packages/next-codemod/bin/agents-md.ts new file mode 100644 index 00000000000000..9c55cea2e3ad76 --- /dev/null +++ b/packages/next-codemod/bin/agents-md.ts @@ -0,0 +1,207 @@ +/** + * CLI handler for `npx @next/codemod agents-md`. + * See ../lib/agents-md.ts for the core logic. + */ + +import fs from 'fs' +import path from 'path' +import prompts from 'prompts' +import pc from 'picocolors' +import { BadInput } from './shared' +import { + getNextjsVersion, + pullDocs, + collectDocFiles, + buildDocTree, + generateClaudeMdIndex, + injectIntoClaudeMd, + ensureGitignoreEntry, +} from '../lib/agents-md' +import { onCancel } from '../lib/utils' + +export interface AgentsMdOptions { + version?: string + output?: string +} + +const DOCS_DIR_NAME = '.next-docs' + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + const mb = kb / 1024 + return `${mb.toFixed(1)} MB` +} + +export async function runAgentsMd(options: AgentsMdOptions): Promise { + const cwd = process.cwd() + + // Mode logic: + // 1. No flags → interactive mode (prompts for version + target file) + // 2. --version provided → --output is REQUIRED (error if missing) + // 3. --output alone → auto-detect version, error if not found + + let nextjsVersion: string + let targetFile: string + + if (options.version) { + // --version provided: --output is required + if (!options.output) { + throw new BadInput( + 'When using --version, --output is also required.\n' + + 'Example: npx @next/codemod agents-md --version 15.1.3 --output CLAUDE.md' + ) + } + nextjsVersion = options.version + targetFile = options.output + } else if (options.output) { + // --output alone: auto-detect version + const detected = getNextjsVersion(cwd) + if (!detected.version) { + throw new BadInput( + 'Could not detect Next.js version. Use --version to specify.\n' + + `Example: npx @next/codemod agents-md --version 15.1.3 --output ${options.output}` + ) + } + nextjsVersion = detected.version + targetFile = options.output + } else { + // No flags: interactive mode + const promptedOptions = await promptForOptions(cwd) + nextjsVersion = promptedOptions.nextVersion + targetFile = promptedOptions.targetFile + } + + const claudeMdPath = path.join(cwd, targetFile) + const docsPath = path.join(cwd, DOCS_DIR_NAME) + const docsLinkPath = `./${DOCS_DIR_NAME}` + + let sizeBefore = 0 + let isNewFile = true + let existingContent = '' + + if (fs.existsSync(claudeMdPath)) { + existingContent = fs.readFileSync(claudeMdPath, 'utf-8') + sizeBefore = Buffer.byteLength(existingContent, 'utf-8') + isNewFile = false + } + + console.log( + `\nDownloading Next.js ${pc.cyan(nextjsVersion)} documentation to ${pc.cyan(DOCS_DIR_NAME)}...` + ) + + const pullResult = await pullDocs({ + cwd, + version: nextjsVersion, + docsDir: docsPath, + }) + + if (!pullResult.success) { + throw new BadInput(`Failed to pull docs: ${pullResult.error}`) + } + + const docFiles = collectDocFiles(docsPath) + const sections = buildDocTree(docFiles) + + const indexContent = generateClaudeMdIndex({ + docsPath: docsLinkPath, + sections, + outputFile: targetFile, + }) + + const newContent = injectIntoClaudeMd(existingContent, indexContent) + fs.writeFileSync(claudeMdPath, newContent, 'utf-8') + + const sizeAfter = Buffer.byteLength(newContent, 'utf-8') + + const gitignoreResult = ensureGitignoreEntry(cwd) + + const action = isNewFile ? 'Created' : 'Updated' + const sizeInfo = isNewFile + ? formatSize(sizeAfter) + : `${formatSize(sizeBefore)} → ${formatSize(sizeAfter)}` + + console.log(`${pc.green('✓')} ${action} ${pc.bold(targetFile)} (${sizeInfo})`) + if (gitignoreResult.updated) { + console.log( + `${pc.green('✓')} Added ${pc.bold(DOCS_DIR_NAME)} to .gitignore` + ) + } + console.log('') +} + +async function promptForOptions( + cwd: string +): Promise<{ nextVersion: string; targetFile: string }> { + // Detect Next.js version for default + const versionResult = getNextjsVersion(cwd) + const detectedVersion = versionResult.version + + console.log( + pc.cyan('\n@next/codemod agents-md - Next.js Documentation for AI Agents\n') + ) + + if (detectedVersion) { + console.log(pc.gray(` Detected Next.js version: ${detectedVersion}\n`)) + } + + const response = await prompts( + [ + { + type: 'text', + name: 'nextVersion', + message: 'Next.js version', + initial: detectedVersion || '', + validate: (value: string) => + value.trim() ? true : 'Please enter a Next.js version', + }, + { + type: 'select', + name: 'targetFile', + message: 'Target markdown file', + choices: [ + { title: 'CLAUDE.md', value: 'CLAUDE.md' }, + { title: 'AGENTS.md', value: 'AGENTS.md' }, + { title: 'Custom...', value: '__custom__' }, + ], + initial: 0, + }, + ], + { onCancel } + ) + + // Handle cancelled prompts + if (response.nextVersion === undefined || response.targetFile === undefined) { + console.log(pc.yellow('\nCancelled.')) + process.exit(0) + } + + let targetFile = response.targetFile + + if (targetFile === '__custom__') { + const customResponse = await prompts( + { + type: 'text', + name: 'customFile', + message: 'Enter custom file path', + initial: 'CLAUDE.md', + validate: (value: string) => + value.trim() ? true : 'Please enter a file path', + }, + { onCancel } + ) + + if (customResponse.customFile === undefined) { + console.log(pc.yellow('\nCancelled.')) + process.exit(0) + } + + targetFile = customResponse.customFile + } + + return { + nextVersion: response.nextVersion, + targetFile, + } +} diff --git a/packages/next-codemod/bin/next-codemod.ts b/packages/next-codemod/bin/next-codemod.ts index 517ee16db064bf..d49ecb8e4762ba 100644 --- a/packages/next-codemod/bin/next-codemod.ts +++ b/packages/next-codemod/bin/next-codemod.ts @@ -11,6 +11,7 @@ import { Command } from 'commander' import { runUpgrade } from './upgrade' +import { runAgentsMd } from './agents-md' import { runTransform } from './transform' import { BadInput } from './shared' @@ -77,4 +78,27 @@ program } }) +program + .command('agents-md') + .description( + 'Generate Next.js documentation index for AI coding agents (Claude, Cursor, etc.).' + ) + .option( + '--version ', + 'Next.js version (auto-detected if not provided)' + ) + .option('--output ', 'Target file path (e.g., CLAUDE.md, AGENTS.md)') + .action(async (options) => { + try { + await runAgentsMd(options) + } catch (error) { + if (error instanceof BadInput) { + console.error(error.message) + } else { + console.error(error) + } + process.exit(1) + } + }) + program.parse(process.argv) diff --git a/packages/next-codemod/lib/__tests__/agents-md.test.js b/packages/next-codemod/lib/__tests__/agents-md.test.js new file mode 100644 index 00000000000000..be4c0cd920e620 --- /dev/null +++ b/packages/next-codemod/lib/__tests__/agents-md.test.js @@ -0,0 +1,142 @@ +/* global jest */ +jest.autoMockOff() + +const { injectIntoClaudeMd, buildDocTree } = require('../agents-md') + +describe('agents-md', () => { + describe('injectIntoClaudeMd', () => { + const START_MARKER = '' + const END_MARKER = '' + + it('appends to empty file', () => { + const result = injectIntoClaudeMd('', 'index content') + // Empty string doesn't end with \n, so separator is \n\n + expect(result).toBe(`\n\n${START_MARKER}index content${END_MARKER}\n`) + }) + + it('appends to file without markers', () => { + const existing = '# My Project\n\nSome existing content.' + const result = injectIntoClaudeMd(existing, 'index content') + expect(result).toBe( + `${existing}\n\n${START_MARKER}index content${END_MARKER}\n` + ) + }) + + it('replaces content between existing markers', () => { + const existing = `# My Project + +Some content before. + +${START_MARKER}old index${END_MARKER} + +Some content after.` + const result = injectIntoClaudeMd(existing, 'new index') + expect(result).toBe(`# My Project + +Some content before. + +${START_MARKER}new index${END_MARKER} + +Some content after.`) + }) + + it('is idempotent - running twice produces same result', () => { + const initial = '# Project\n' + const first = injectIntoClaudeMd(initial, 'index v1') + const second = injectIntoClaudeMd(first, 'index v1') + expect(second).toBe(first) + }) + + it('preserves content before and after markers on update', () => { + const before = '# Header\n\nIntro paragraph.' + const after = '\n\n## Footer\n\nMore content.' + const existing = `${before}\n\n${START_MARKER}old${END_MARKER}${after}` + const result = injectIntoClaudeMd(existing, 'new') + expect(result).toContain(before) + expect(result).toContain(after) + expect(result).toContain(`${START_MARKER}new${END_MARKER}`) + expect(result).not.toContain('old') + }) + }) + + describe('buildDocTree', () => { + it('groups files by top-level directory', () => { + const files = [ + { relativePath: '01-getting-started/installation.mdx' }, + { relativePath: '01-getting-started/project-structure.mdx' }, + { relativePath: '02-app/routing.mdx' }, + ] + const tree = buildDocTree(files) + + expect(tree).toHaveLength(2) + expect(tree[0].name).toBe('01-getting-started') + expect(tree[0].files).toHaveLength(2) + expect(tree[1].name).toBe('02-app') + expect(tree[1].files).toHaveLength(1) + }) + + it('creates nested subsections for deeper paths', () => { + const files = [ + { relativePath: '02-app/01-building/layouts.mdx' }, + { relativePath: '02-app/01-building/pages.mdx' }, + { relativePath: '02-app/02-api/route-handlers.mdx' }, + ] + const tree = buildDocTree(files) + + expect(tree).toHaveLength(1) + const appSection = tree[0] + expect(appSection.name).toBe('02-app') + expect(appSection.files).toHaveLength(0) // no direct files + expect(appSection.subsections).toHaveLength(2) + + const building = appSection.subsections.find( + (s) => s.name === '01-building' + ) + expect(building).toBeDefined() + expect(building.files).toHaveLength(2) + + const api = appSection.subsections.find((s) => s.name === '02-api') + expect(api).toBeDefined() + expect(api.files).toHaveLength(1) + }) + + it('handles 4-level deep paths with sub-subsections', () => { + const files = [ + { relativePath: '02-app/01-building/01-routing/dynamic-routes.mdx' }, + { relativePath: '02-app/01-building/01-routing/parallel-routes.mdx' }, + ] + const tree = buildDocTree(files) + + const routing = tree[0].subsections[0].subsections[0] + expect(routing.name).toBe('01-routing') + expect(routing.files).toHaveLength(2) + }) + + it('skips single-segment paths (root-level files)', () => { + const files = [ + { relativePath: 'index.mdx' }, + { relativePath: '01-getting-started/intro.mdx' }, + ] + const tree = buildDocTree(files) + + // Root-level index.mdx should be skipped (parts.length < 2) + expect(tree).toHaveLength(1) + expect(tree[0].name).toBe('01-getting-started') + }) + + it('sorts sections and files alphabetically', () => { + const files = [ + { relativePath: 'z-section/b-file.mdx' }, + { relativePath: 'a-section/z-file.mdx' }, + { relativePath: 'a-section/a-file.mdx' }, + { relativePath: 'z-section/a-file.mdx' }, + ] + const tree = buildDocTree(files) + + expect(tree[0].name).toBe('a-section') + expect(tree[1].name).toBe('z-section') + expect(tree[0].files[0].relativePath).toBe('a-section/a-file.mdx') + expect(tree[0].files[1].relativePath).toBe('a-section/z-file.mdx') + }) + }) +}) diff --git a/packages/next-codemod/lib/agents-md.ts b/packages/next-codemod/lib/agents-md.ts new file mode 100644 index 00000000000000..2fb1efca5b8200 --- /dev/null +++ b/packages/next-codemod/lib/agents-md.ts @@ -0,0 +1,644 @@ +/** + * 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 { execSync } from 'child_process' +import fs from 'fs' +import path from 'path' +import os from 'os' + +export interface NextjsVersionResult { + version: string | null + error?: string +} + +export function getNextjsVersion(cwd: string): NextjsVersionResult { + const packageJsonPath = path.join(cwd, 'package.json') + + if (!fs.existsSync(packageJsonPath)) { + return { + version: null, + error: 'No package.json found in the current directory', + } + } + + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) + const dependencies = packageJson.dependencies || {} + const devDependencies = packageJson.devDependencies || {} + + const nextVersion = dependencies.next || devDependencies.next + + if (nextVersion) { + const cleanVersion = nextVersion.replace(/^[\^~>=<]+/, '') + return { version: cleanVersion } + } + + // 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.', + } + } catch (err) { + return { + version: null, + error: `Failed to parse package.json: ${err instanceof Error ? err.message : String(err)}`, + } + } +} + +function versionToGitHubTag(version: string): string { + return version.startsWith('v') ? version : `v${version}` +} + +export interface PullOptions { + cwd: string + version?: string + docsDir?: string +} + +export 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 { + execSync( + `git clone --depth 1 --filter=blob:none --sparse --branch ${tag} https://github.com/vercel/next.js.git .`, + { cwd: tempDir, stdio: 'pipe' } + ) + } 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 + } + + execSync('git sparse-checkout set docs', { cwd: tempDir, stdio: 'pipe' }) + + 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')) && + !f.endsWith('/index.mdx') && + !f.endsWith('/index.md') && + !f.startsWith('index.') + ) + .sort() + .map((f) => ({ relativePath: f })) +} + +export 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 +} + +export 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( + 'IMPORTANT: Prefer retrieval-led reasoning over pre-training-led reasoning for any Next.js tasks.' + ) + 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 = 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' +} + +export 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) { + const packageJsonPath = path.join(pkgPath, 'package.json') + if (!fs.existsSync(packageJsonPath)) continue + + try { + const content = fs.readFileSync(packageJsonPath, 'utf-8') + const pkg = JSON.parse(content) + const nextVersion = pkg.dependencies?.next || pkg.devDependencies?.next + if (nextVersion) { + versions.push(nextVersion.replace(/^[\^~>=<]+/, '')) + } + } catch { + // Skip invalid package.json + } + } + + 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 +}