mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
## 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
208 lines
5.5 KiB
TypeScript
208 lines
5.5 KiB
TypeScript
/**
|
|
* 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,
|
|
}
|
|
}
|