Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/next-codemod/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
*.js
*.js.map
!transforms/__tests__/**/*.js
!transforms/__testfixtures__/**/*.js
!transforms/__testfixtures__/**/*.js
!lib/__tests__/**/*.js
207 changes: 207 additions & 0 deletions packages/next-codemod/bin/agents-md.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
24 changes: 24 additions & 0 deletions packages/next-codemod/bin/next-codemod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
142 changes: 142 additions & 0 deletions packages/next-codemod/lib/__tests__/agents-md.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading
Loading