next.js/packages/next/src/server/lib/generate-agent-files.ts
generate-agent-files.ts176 lines5.7 KB
/**
 * Auto-generate AGENTS.md / CLAUDE.md with the managed Next.js agent-rules
 * block when `next dev` detects an AI coding agent but the block is missing.
 *
 * Keep the marker and block content in sync with:
 *   - packages/create-next-app/helpers/generate-agent-files.ts
 *   - packages/next-codemod/lib/agents-md.ts
 */

import fs from 'fs'
import path from 'path'

export const AGENT_RULES_START_MARKER = '<!-- BEGIN:nextjs-agent-rules -->'
export const AGENT_RULES_END_MARKER = '<!-- END:nextjs-agent-rules -->'

/**
 * Markers written by the pre-bundled-docs version of `agents-md`.
 * Stripped on upsert so projects that ran the old codemod end up with
 * a single current block instead of two stale-and-current blocks.
 */
const LEGACY_AGENT_RULES_START_MARKER = '<!-- NEXT-AGENTS-MD-START -->'
const LEGACY_AGENT_RULES_END_MARKER = '<!-- NEXT-AGENTS-MD-END -->'

function buildAgentRulesBlock(): string {
  return `${AGENT_RULES_START_MARKER}
# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in \`node_modules/next/dist/docs/\` before writing any code. Heed deprecation notices.
${AGENT_RULES_END_MARKER}`
}

const CLAUDE_MD_CONTENT = `@AGENTS.md\n`

export type AgentFileAction = 'created' | 'updated' | 'unchanged' | 'skipped'

export interface AgentFilesResult {
  agentsMd: AgentFileAction
  claudeMd: AgentFileAction
}

/**
 * Returns true when `AGENTS.md` or `CLAUDE.md` at `dir` contains the
 * managed agent-rules marker.
 */
export function hasAgentRulesInstalled(dir: string): boolean {
  const agentsContent = tryReadFile(path.join(dir, 'AGENTS.md'))
  if (agentsContent?.includes(AGENT_RULES_START_MARKER)) return true

  const claudeContent = tryReadFile(path.join(dir, 'CLAUDE.md'))
  if (claudeContent?.includes(AGENT_RULES_START_MARKER)) return true

  return false
}

/**
 * Write the agent-rules block into `projectDir`, respecting whichever
 * file the user already uses:
 *
 *   - `AGENTS.md` exists → upsert into it, leave `CLAUDE.md` alone.
 *   - `CLAUDE.md` exists (but not `AGENTS.md`) → upsert into it.
 *   - Neither exists → create both (`AGENTS.md` + `CLAUDE.md` with
 *     `@AGENTS.md` import), matching `create-next-app`.
 *
 * Idempotent: a file already containing the canonical block is
 * reported as `unchanged`.
 */
export function writeAgentFiles(projectDir: string): AgentFilesResult {
  const agentsMdPath = path.join(projectDir, 'AGENTS.md')
  const claudeMdPath = path.join(projectDir, 'CLAUDE.md')
  const block = buildAgentRulesBlock()

  const agentsMdExists = fs.existsSync(agentsMdPath)
  const claudeMdExists = fs.existsSync(claudeMdPath)

  if (agentsMdExists) {
    return {
      agentsMd: upsertFile(agentsMdPath, block),
      claudeMd: 'skipped',
    }
  }

  if (claudeMdExists) {
    return {
      agentsMd: 'skipped',
      claudeMd: upsertFile(claudeMdPath, block),
    }
  }

  // Neither file exists — scaffold both, matching create-next-app.
  fs.writeFileSync(agentsMdPath, block + '\n', 'utf-8')
  fs.writeFileSync(claudeMdPath, CLAUDE_MD_CONTENT, 'utf-8')
  return { agentsMd: 'created', claudeMd: 'created' }
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

function tryReadFile(filePath: string): string | null {
  try {
    return fs.readFileSync(filePath, 'utf-8')
  } catch {
    return null
  }
}

function upsertFile(filePath: string, block: string): AgentFileAction {
  const existing = fs.readFileSync(filePath, 'utf-8')
  const updated = upsertAgentRulesBlock(existing, block)
  if (updated === existing) return 'unchanged'
  fs.writeFileSync(filePath, updated, 'utf-8')
  return 'updated'
}

/**
 * Detect the predominant line-ending style. Returns `'\r\n'` if any
 * CRLF is present, `'\n'` otherwise — avoids mixed EOLs on Windows.
 */
function detectEol(content: string): '\r\n' | '\n' {
  return /\r\n/.test(content) ? '\r\n' : '\n'
}

function normalizeEol(s: string, eol: '\r\n' | '\n'): string {
  return s.replace(/\r?\n/g, eol)
}

function upsertAgentRulesBlock(existing: string, block: string): string {
  const eol = detectEol(existing)
  const normalizedBlock = normalizeEol(block, eol)

  existing = stripLegacyAgentRulesBlock(existing, eol)

  const startIdx = existing.indexOf(AGENT_RULES_START_MARKER)
  const endIdx = existing.indexOf(AGENT_RULES_END_MARKER)

  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
    const before = existing.slice(0, startIdx)
    const after = existing.slice(endIdx + AGENT_RULES_END_MARKER.length)
    const replaced = before + normalizedBlock + after
    return replaced === existing ? existing : replaced
  }

  const separator =
    existing.length === 0 || /\r?\n$/.test(existing) ? eol : eol + eol
  return existing + separator + normalizedBlock + eol
}

function stripLegacyAgentRulesBlock(
  existing: string,
  eol: '\r\n' | '\n' = '\n'
): string {
  while (true) {
    const startIdx = existing.indexOf(LEGACY_AGENT_RULES_START_MARKER)
    if (startIdx === -1) return existing
    const endIdx = existing.indexOf(LEGACY_AGENT_RULES_END_MARKER, startIdx)
    if (endIdx === -1) return existing

    let cutStart = startIdx
    while (cutStart > 0 && /\s/.test(existing[cutStart - 1])) {
      cutStart--
    }
    let cutEnd = endIdx + LEGACY_AGENT_RULES_END_MARKER.length
    while (cutEnd < existing.length && /\s/.test(existing[cutEnd])) {
      cutEnd++
    }

    const before = existing.slice(0, cutStart)
    const after = existing.slice(cutEnd)

    existing =
      before.length > 0 && after.length > 0
        ? before + eol + eol + after
        : before + after
  }
}
Quest for Codev2.0.0
/
SIGN IN