Repository: vercel/next.js
Pull request: [Codemod] Fix agents-md on Windows

Fixes #89240

The `agents-md` codemod failed on Windows because it assumed forward slashes in file paths. This caused the doc tree builder to skip files, resulting in an empty index being injected into `AGENTS.md`.

The path handling code used string operations like `.split('/')` and `.endsWith('/index.mdx')` which don't work with Windows backslashes. Now uses regex patterns that match both separators: `/[/\\]/` for splitting and `!/[/\\]index\.mdx$/` for filtering. Also normalizes all paths to forward slashes in the output.

While here, switched from `execSync` to `execa` for git commands. This is the pattern already used elsewhere in the codemod package (`upgrade.ts`, `handle-package.ts`) and passes arguments as an array instead of shell string interpolation.

Tests were replaced with e2e tests that run the full CLI and include a cross-platform path normalization test case.

Changed files:
filename: packages/next-codemod/lib/__tests__/agents-md-e2e.test.js
patch: @@ -0,0 +1,294 @@
+/* global jest */
+jest.autoMockOff()
+
+const fs = require('fs')
+const path = require('path')
+const os = require('os')
+const { runAgentsMd } = require('../../bin/agents-md')
+
+/**
+ * TRUE E2E TESTS
+ * These tests invoke the actual CLI entry point (runAgentsMd),
+ * simulating what happens when a user runs:
+ * npx @next/codemod agents-md --version 15.0.0 --output CLAUDE.md
+ */
+describe('agents-md e2e (CLI invocation)', () => {
+  let testProjectDir
+  let originalConsoleLog
+  let consoleOutput
+
+  beforeEach(() => {
+    // Create isolated test project directory
+    const tmpBase = process.env.NEXT_TEST_DIR || os.tmpdir()
+    testProjectDir = path.join(
+      tmpBase,
+      `agents-md-e2e-${Date.now()}-${(Math.random() * 1000) | 0}`
+    )
+    fs.mkdirSync(testProjectDir, { recursive: true })
+
+    // Mock console.log to capture CLI output
+    originalConsoleLog = console.log
+    consoleOutput = []
+    console.log = (...args) => {
+      consoleOutput.push(args.join(' '))
+    }
+  })
+
+  afterEach(() => {
+    // Restore console.log
+    console.log = originalConsoleLog
+
+    // Clean up test directory
+    if (testProjectDir && fs.existsSync(testProjectDir)) {
+      fs.rmSync(testProjectDir, { recursive: true, force: true })
+    }
+  })
+
+  it('creates CLAUDE.md and .next-docs directory when run with --version and --output', async () => {
+    // Create a minimal package.json (not required, but realistic)
+    const packageJson = {
+      name: 'test-project',
+      version: '1.0.0',
+      dependencies: {
+        next: '15.0.0',
+      },
+    }
+    fs.writeFileSync(
+      path.join(testProjectDir, 'package.json'),
+      JSON.stringify(packageJson, null, 2)
+    )
+
+    // Change to test directory
+    const originalCwd = process.cwd()
+    process.chdir(testProjectDir)
+
+    try {
+      // Run the actual CLI command
+      await runAgentsMd({
+        version: '15.0.0',
+        output: 'CLAUDE.md',
+      })
+
+      // Verify .next-docs directory was created and populated
+      const docsDir = path.join(testProjectDir, '.next-docs')
+      expect(fs.existsSync(docsDir)).toBe(true)
+
+      const docFiles = fs.readdirSync(docsDir, { recursive: true })
+      expect(docFiles.length).toBeGreaterThan(0)
+
+      // Should contain mdx/md files
+      const mdxFiles = docFiles.filter(
+        (f) => f.endsWith('.mdx') || f.endsWith('.md')
+      )
+      expect(mdxFiles.length).toBeGreaterThan(0)
+
+      // Verify CLAUDE.md was created
+      const claudeMdPath = path.join(testProjectDir, 'CLAUDE.md')
+      expect(fs.existsSync(claudeMdPath)).toBe(true)
+
+      const claudeMdContent = fs.readFileSync(claudeMdPath, 'utf-8')
+
+      // Verify content structure
+      expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-START -->')
+      expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-END -->')
+      expect(claudeMdContent).toContain('[Next.js Docs Index]')
+      expect(claudeMdContent).toContain('root: ./.next-docs')
+
+      // Verify paths are normalized to forward slashes (cross-platform)
+      const lines = claudeMdContent.split('|')
+      const pathLines = lines.filter((line) => line.includes(':'))
+      pathLines.forEach((line) => {
+        // Should not contain Windows backslashes in the output
+        const pathPart = line.split(':')[0]
+        if (pathPart && pathPart.includes('/')) {
+          expect(line).not.toMatch(/[^:]\\/)
+        }
+      })
+
+      // Verify .gitignore was updated
+      const gitignorePath = path.join(testProjectDir, '.gitignore')
+      if (fs.existsSync(gitignorePath)) {
+        const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8')
+        expect(gitignoreContent).toContain('.next-docs')
+      }
+
+      // Verify console output
+      const output = consoleOutput.join('\n')
+      expect(output).toContain('Downloading Next.js')
+      expect(output).toContain('15.0.0')
+      expect(output).toContain('CLAUDE.md')
+    } finally {
+      // Restore original directory
+      process.chdir(originalCwd)
+    }
+  })
+
+  it('updates existing CLAUDE.md without losing content', async () => {
+    const originalCwd = process.cwd()
+    process.chdir(testProjectDir)
+
+    try {
+      // Create existing CLAUDE.md with custom content
+      const existingContent = `# My Project
+
+This is my project documentation.
+
+## Features
+- Feature 1
+- Feature 2
+`
+      fs.writeFileSync(
+        path.join(testProjectDir, 'CLAUDE.md'),
+        existingContent
+      )
+
+      // Run CLI
+      await runAgentsMd({
+        version: '15.0.0',
+        output: 'CLAUDE.md',
+      })
+
+      // Verify file was updated, not replaced
+      const claudeMdContent = fs.readFileSync(
+        path.join(testProjectDir, 'CLAUDE.md'),
+        'utf-8'
+      )
+
+      // Original content should still be there
+      expect(claudeMdContent).toContain('# My Project')
+      expect(claudeMdContent).toContain('This is my project documentation.')
+      expect(claudeMdContent).toContain('## Features')
+
+      // New index should be injected
+      expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-START -->')
+      expect(claudeMdContent).toContain('[Next.js Docs Index]')
+    } finally {
+      process.chdir(originalCwd)
+    }
+  })
+
+  it('handles custom output filename', async () => {
+    const originalCwd = process.cwd()
+    process.chdir(testProjectDir)
+
+    try {
+      // Run with custom output file
+      await runAgentsMd({
+        version: '15.0.0',
+        output: 'AGENTS.md',
+      })
+
+      // Verify AGENTS.md was created (not CLAUDE.md)
+      expect(fs.existsSync(path.join(testProjectDir, 'AGENTS.md'))).toBe(true)
+      expect(fs.existsSync(path.join(testProjectDir, 'CLAUDE.md'))).toBe(false)
+
+      const agentsMdContent = fs.readFileSync(
+        path.join(testProjectDir, 'AGENTS.md'),
+        'utf-8'
+      )
+      expect(agentsMdContent).toContain('[Next.js Docs Index]')
+    } finally {
+      process.chdir(originalCwd)
+    }
+  })
+
+  it('works when run from a subdirectory', async () => {
+    const originalCwd = process.cwd()
+
+    // Create a subdirectory
+    const subDir = path.join(testProjectDir, 'packages', 'app')
+    fs.mkdirSync(subDir, { recursive: true })
+
+    // Create package.json in root
+    const packageJson = {
+      dependencies: { next: '15.0.0' },
+    }
+    fs.writeFileSync(
+      path.join(testProjectDir, 'package.json'),
+      JSON.stringify(packageJson)
+    )
+
+    // Change to subdirectory
+    process.chdir(subDir)
+
+    try {
+      // Run from subdirectory - should create files in CWD (subdirectory)
+      await runAgentsMd({
+        version: '15.0.0',
+        output: 'CLAUDE.md',
+      })
+
+      // Verify files created in subdirectory
+      expect(fs.existsSync(path.join(subDir, 'CLAUDE.md'))).toBe(true)
+      expect(fs.existsSync(path.join(subDir, '.next-docs'))).toBe(true)
+    } finally {
+      process.chdir(originalCwd)
+    }
+  })
+
+  it('normalizes paths on Windows (cross-platform test)', async () => {
+    const originalCwd = process.cwd()
+    process.chdir(testProjectDir)
+
+    try {
+      await runAgentsMd({
+        version: '15.0.0',
+        output: 'CLAUDE.md',
+      })
+
+      const claudeMdContent = fs.readFileSync(
+        path.join(testProjectDir, 'CLAUDE.md'),
+        'utf-8'
+      )
+
+      // Extract the index content between markers
+      const startMarker = '<!-- NEXT-AGENTS-MD-START -->'
+      const endMarker = '<!-- NEXT-AGENTS-MD-END -->'
+      const startIdx = claudeMdContent.indexOf(startMarker) + startMarker.length
+      const endIdx = claudeMdContent.indexOf(endMarker)
+      const indexContent = claudeMdContent.slice(startIdx, endIdx)
+
+      // Parse the index (format: "dir:{file1,file2}|dir2:{file3}")
+      const sections = indexContent.split('|').filter((s) => s.includes(':'))
+
+      sections.forEach((section) => {
+        const [dirPath, filesStr] = section.split(':')
+        if (dirPath && dirPath.trim() && !dirPath.includes('root')) {
+          // Verify no Windows backslashes in directory paths
+          expect(dirPath).not.toContain('\\')
+          // Verify uses forward slashes
+          if (dirPath.includes('/')) {
+            expect(dirPath).toMatch(/^[^\\]+$/)
+          }
+        }
+      })
+    } finally {
+      process.chdir(originalCwd)
+    }
+  })
+
+  it('handles version that requires git clone from GitHub', async () => {
+    const originalCwd = process.cwd()
+    process.chdir(testProjectDir)
+
+    try {
+      // Use a known stable version
+      await runAgentsMd({
+        version: '14.2.0',
+        output: 'CLAUDE.md',
+      })
+
+      // Verify docs were downloaded
+      const docsDir = path.join(testProjectDir, '.next-docs')
+      expect(fs.existsSync(docsDir)).toBe(true)
+
+      const docFiles = fs.readdirSync(docsDir, { recursive: true })
+      const mdxFiles = docFiles.filter(
+        (f) => f.endsWith('.mdx') || f.endsWith('.md')
+      )
+      expect(mdxFiles.length).toBeGreaterThan(50) // Should have many doc files
+    } finally {
+      process.chdir(originalCwd)
+    }
+  }, 30000) // Increase timeout for git clone
+})

filename: packages/next-codemod/lib/__tests__/agents-md.test.js
patch: @@ -1,142 +0,0 @@
-/* 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: @@ -5,7 +5,7 @@
  * index of all doc files, and injects it into CLAUDE.md or AGENTS.md.
  */
 
-import { execSync } from 'child_process'
+import execa from 'execa'
 import fs from 'fs'
 import path from 'path'
 import os from 'os'
@@ -133,9 +133,20 @@ async function cloneDocsFolder(tag: string, destDir: string): Promise<void> {
 
   try {
     try {
-      execSync(
-        `git clone --depth 1 --filter=blob:none --sparse --branch ${tag} https://github.com/vercel/next.js.git .`,
-        { cwd: tempDir, stdio: 'pipe' }
+      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)
@@ -147,7 +158,7 @@ async function cloneDocsFolder(tag: string, destDir: string): Promise<void> {
       throw error
     }
 
-    execSync('git sparse-checkout set docs', { cwd: tempDir, stdio: 'pipe' })
+    await execa('git', ['sparse-checkout', 'set', 'docs'], { cwd: tempDir })
 
     const sourceDocsDir = path.join(tempDir, 'docs')
     if (!fs.existsSync(sourceDocsDir)) {
@@ -171,12 +182,12 @@ export function collectDocFiles(dir: string): { relativePath: string }[] {
     .filter(
       (f) =>
         (f.endsWith('.mdx') || f.endsWith('.md')) &&
-        !f.endsWith('/index.mdx') &&
-        !f.endsWith('/index.md') &&
+        !/[/\\]index\.mdx$/.test(f) &&
+        !/[/\\]index\.md$/.test(f) &&
         !f.startsWith('index.')
     )
     .sort()
-    .map((f) => ({ relativePath: f }))
+    .map((f) => ({ relativePath: f.replace(/\\/g, '/') }))
 }
 
 interface DocSection {
@@ -189,7 +200,7 @@ export function buildDocTree(files: { relativePath: string }[]): DocSection[] {
   const sections: Map<string, DocSection> = new Map()
 
   for (const file of files) {
-    const parts = file.relativePath.split('/')
+    const parts = file.relativePath.split(/[/\\]/)
     if (parts.length < 2) continue
 
     const topLevelDir = parts[0]
@@ -299,7 +310,10 @@ function groupByDirectory(files: string[]): Map<string, string[]> {
   const grouped = new Map<string, string[]>()
 
   for (const filePath of files) {
-    const lastSlash = filePath.lastIndexOf('/')
+    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)