Repository: vercel/next.js
Pull request: feat(next-codemod): add agents-md command for AI coding agents

## 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 <version>` | Specify Next.js version (auto-detected from `package.json` if not provided) |
| `--output <file>` | Target file path (e.g., `CLAUDE.md`, `AGENTS.md`) |

## Output format

The generated index is a single line with pipe separators:

```
<!-- NEXT-AGENTS-MD-START -->[Next.js Docs Index]|root: ./.next-docs|IMPORTANT: Prefer retrieval-led reasoning...|dir:{file1.mdx,file2.mdx}|...<!-- NEXT-AGENTS-MD-END -->
```

## 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

Changed files:
filename: packages/next-codemod/.gitignore
patch: @@ -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

filename: packages/next-codemod/bin/agents-md.ts
patch: @@ -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<void> {
+  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,
+  }
+}

filename: packages/next-codemod/bin/next-codemod.ts
patch: @@ -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 <version>',
+    'Next.js version (auto-detected if not provided)'
+  )
+  .option('--output <file>', '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)

filename: packages/next-codemod/lib/__tests__/agents-md.test.js
patch: @@ -0,0 +1,142 @@
+/* global jest */
+jest.autoMockOff()
+
+const { injectIntoClaudeMd, buildDocTree } = require('../agents-md')
+
+describe('agents-md', () => {
+  describe('injectIntoClaudeMd', () => {
+    const START_MARKER = '<!-- NEXT-AGENTS-MD-START -->'
+    const END_MARKER = '<!-- NEXT-AGENTS-MD-END -->'
+
+    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')
+    })
+  })
+})

filename: packages/next-codemod/lib/agents-md.ts
patch: @@ -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<PullResult> {
+  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<void> {
+  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<string, DocSection> = 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<string, string[]> {
+  const grouped = new Map<string, string[]>()
+
+  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 = '<!-- NEXT-AGENTS-MD-START -->'
+const END_MARKER = '<!-- NEXT-AGENTS-MD-END -->'
+
+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
+}