From a493ddc871de44bf2ef4dbea3b1e521b732faba7 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 19 Oct 2026 17:14:05 -0400 Subject: [PATCH 01/31] feat(shared): add AGENTS.md writer for Clerk agent rules Co-Authored-By: Claude Opus 5.5 --- .changeset/shared-agent-rules.md | 5 + packages/shared/package.json | 20 ++ .../shared/src/__tests__/agentRules.spec.ts | 145 ++++++++++ .../src/__tests__/runtimeEnvironment.spec.ts | 37 ++- packages/shared/src/agentRules/index.ts | 273 ++++++++++++++++++ .../shared/src/utils/runtimeEnvironment.ts | 16 + packages/shared/src/vite.ts | 30 ++ packages/shared/tsdown.config.mts | 1 + 8 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 .changeset/shared-agent-rules.md create mode 100644 packages/shared/src/__tests__/agentRules.spec.ts create mode 100644 packages/shared/src/agentRules/index.ts create mode 100644 packages/shared/src/vite.ts diff --git a/.changeset/shared-agent-rules.md b/.changeset/shared-agent-rules.md new file mode 100644 index 00000000000..886015e3be2 --- /dev/null +++ b/.changeset/shared-agent-rules.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Add `@clerk/shared/agentRules` and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`, and `isAgentEnvironment()` to `@clerk/shared/utils` for detecting AI coding agents. diff --git a/packages/shared/package.json b/packages/shared/package.json index 82f4692803b..dcfbe2c77c1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -61,6 +61,26 @@ "default": "./dist/keyless/index.js" } }, + "./agentRules": { + "import": { + "types": "./dist/agentRules/index.d.mts", + "default": "./dist/agentRules/index.mjs" + }, + "require": { + "types": "./dist/agentRules/index.d.ts", + "default": "./dist/agentRules/index.js" + } + }, + "./vite": { + "import": { + "types": "./dist/vite.d.mts", + "default": "./dist/vite.mjs" + }, + "require": { + "types": "./dist/vite.d.ts", + "default": "./dist/vite.js" + } + }, "./utils": { "import": { "types": "./dist/utils/index.d.mts", diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts new file mode 100644 index 00000000000..a359eb99fa5 --- /dev/null +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + CLERK_SDK_MCP_END, + CLERK_SDK_MCP_START, + type FileSystemAdapter, + renderClerkAgentRulesBlock, + renderClerkSdkMcpBlock, + writeClerkAgentRules, +} from '../agentRules'; + +const createMemoryFs = (files: Record = {}) => { + const store = new Map(Object.entries(files)); + const dirs = new Set(); + + const fs: FileSystemAdapter = { + existsSync: path => store.has(path) || dirs.has(path), + readFileSync: path => store.get(path) ?? '', + writeFileSync: (path, data) => { + store.set(path, data); + }, + mkdirSync: path => { + dirs.add(path); + }, + }; + + return { + fs, + read(path: string) { + return store.get(path); + }, + }; +}; + +const pathAdapter = { + dirname(path: string) { + const normalized = path.replace(/\/+$/, ''); + const index = normalized.lastIndexOf('/'); + return index <= 0 ? '/' : normalized.slice(0, index); + }, + join(...paths: string[]) { + return paths.join('/').replace(/\/+/g, '/'); + }, + parse() { + return { root: '/' }; + }, + resolve(...paths: string[]) { + return paths.join('/').replace(/\/+/g, '/'); + }, +}; + +describe('writeClerkAgentRules', () => { + beforeEach(() => { + process.env.NODE_ENV = 'test'; + }); + + it('creates AGENTS.md and CLAUDE.md when neither exists', () => { + const memory = createMemoryFs(); + + const result = writeClerkAgentRules({ + cwd: '/repo/app', + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + expect(result.agentsMd).toBe('created'); + expect(result.claudeMd).toBe('created'); + expect(memory.read('/repo/app/AGENTS.md')).toContain(''); + expect(memory.read('/repo/app/CLAUDE.md')).toBe('@AGENTS.md\n'); + }); + + it('targets the workspace root when one exists', () => { + const memory = createMemoryFs({ + '/repo/pnpm-workspace.yaml': 'packages:\n - apps/*\n', + }); + + writeClerkAgentRules({ + cwd: '/repo/apps/web', + fs: memory.fs, + path: pathAdapter, + sdks: ['react'], + }); + + expect(memory.read('/repo/AGENTS.md')).toContain(''); + expect(memory.read('/repo/apps/web/AGENTS.md')).toBeUndefined(); + }); + + it('replaces only Clerk blocks and preserves surrounding content', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Project rules\n\n\nold\n\n', + }); + + writeClerkAgentRules({ + cwd: '/repo', + fs: memory.fs, + path: pathAdapter, + sdks: ['vue'], + }); + + const content = memory.read('/repo/AGENTS.md'); + expect(content).toContain('Project rules'); + expect(content).toContain(''); + expect(content).not.toContain('\nold\n'); + }); + + it('does not write files in dry-run mode', () => { + const memory = createMemoryFs(); + + const result = writeClerkAgentRules({ + cwd: '/repo', + dryRun: true, + fs: memory.fs, + path: pathAdapter, + sdks: ['astro'], + }); + + expect(result.agentsMd).toBe('created'); + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + }); +}); + +describe('Clerk MCP rules', () => { + it('adds the MCP intro and the ask-before-changes rule to the root block', () => { + const block = renderClerkAgentRulesBlock(); + + expect(block).toContain('### Clerk MCP server'); + expect(block).toContain('https://mcp.clerk.com/mcp'); + expect(block).toContain('https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server'); + expect(block).toContain('Ask the user before calling any Clerk MCP tool'); + }); + + it('renders a marked per-SDK MCP block that names the installed package', () => { + const block = renderClerkSdkMcpBlock('nextjs'); + + expect(block.startsWith(CLERK_SDK_MCP_START)).toBe(true); + expect(block.endsWith(CLERK_SDK_MCP_END)).toBe(true); + expect(block).toContain('installed `@clerk/nextjs` types'); + expect(block).toContain('https://clerk.com/docs/llms.txt'); + }); + + it('uses the requested line endings', () => { + expect(renderClerkSdkMcpBlock('react', '\r\n')).not.toMatch(/[^\r]\n/); + }); +}); diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index e792bbb45a6..52cfe180eed 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { automatedEnvironmentVariables, isAutomatedEnvironment } from '../utils/runtimeEnvironment'; +import { + agentEnvironmentVariables, + automatedEnvironmentVariables, + isAgentEnvironment, + isAutomatedEnvironment, +} from '../utils/runtimeEnvironment'; describe('isAutomatedEnvironment', () => { beforeEach(() => { @@ -60,3 +65,33 @@ describe('isAutomatedEnvironment', () => { expect(isAutomatedEnvironment()).toBe(false); }); }); + +describe('isAgentEnvironment', () => { + beforeEach(() => { + agentEnvironmentVariables.forEach(name => { + vi.stubEnv(name, undefined); + vi.stubGlobal(name, undefined); + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('returns true when an agent environment variable is enabled', () => { + vi.stubEnv('CLAUDECODE', 'true'); + + expect(isAgentEnvironment()).toBe(true); + }); + + it('returns false when agent environment variables are explicitly falsey', () => { + const falseyValues = ['false', '0', 'off', 'no']; + + agentEnvironmentVariables.forEach((name, index) => { + vi.stubEnv(name, falseyValues[index % falseyValues.length]); + }); + + expect(isAgentEnvironment()).toBe(false); + }); +}); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts new file mode 100644 index 00000000000..18f63312abe --- /dev/null +++ b/packages/shared/src/agentRules/index.ts @@ -0,0 +1,273 @@ +export type ClerkAgentRulesSdk = + | 'astro' + | 'backend' + | 'chrome-extension' + | 'clerk-js' + | 'electron' + | 'expo' + | 'express' + | 'fastify' + | 'hono' + | 'nextjs' + | 'nuxt' + | 'react' + | 'react-router' + | 'tanstack-react-start' + | 'vue'; + +export interface FileSystemAdapter { + existsSync: (path: string) => boolean; + readFileSync: (path: string, options: { encoding: BufferEncoding }) => string; + writeFileSync: (path: string, data: string, options: { encoding: BufferEncoding; mode?: number }) => void; + mkdirSync?: (path: string, options: { recursive: boolean }) => void; +} + +export interface PathAdapter { + dirname: (path: string) => string; + join: (...paths: string[]) => string; + parse: (path: string) => { root: string }; + resolve: (...paths: string[]) => string; +} + +export interface WriteClerkAgentRulesOptions { + cwd: string; + dryRun?: boolean; + fs: FileSystemAdapter; + path: PathAdapter; + sdks: ClerkAgentRulesSdk[]; +} + +export type WriteClerkAgentRulesResult = { + targetDir: string; + agentsMd: 'created' | 'updated' | 'unchanged'; + claudeMd: 'created' | 'unchanged' | 'skipped'; +}; + +const GENERIC_START = ''; +const GENERIC_END = ''; + +const SDK_LABELS: Record = { + astro: 'Astro', + backend: 'Backend', + 'chrome-extension': 'Chrome Extension', + 'clerk-js': 'ClerkJS', + electron: 'Electron', + expo: 'Expo', + express: 'Express', + fastify: 'Fastify', + hono: 'Hono', + nextjs: 'Next.js', + nuxt: 'Nuxt', + react: 'React', + 'react-router': 'React Router', + 'tanstack-react-start': 'TanStack React Start', + vue: 'Vue', +}; + +const SDK_NOTES: Partial> = { + nextjs: ['Middleware lives in `proxy.ts` and uses `clerkMiddleware()`. `authMiddleware()` is gone.'], +}; + +const uniqueSdks = (sdks: ClerkAgentRulesSdk[]) => [...new Set(sdks)].sort(); + +const detectEol = (content: string) => (content.includes('\r\n') ? '\r\n' : '\n'); +const normalizeEol = (content: string, eol: string) => content.replace(/\r?\n/g, eol); + +export const isClerkAgentRulesSdk = (value: string): value is ClerkAgentRulesSdk => value in SDK_LABELS; + +export const clerkAgentRulesSdks = Object.keys(SDK_LABELS) as ClerkAgentRulesSdk[]; + +export const CLERK_SDK_MCP_START = ''; +export const CLERK_SDK_MCP_END = ''; + +const MCP_INTRO = `### Clerk MCP server + +Use this section when a \`clerk\` MCP server (\`https://mcp.clerk.com/mcp\`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server`; + +const MCP_ASK_BEFORE_CHANGES = + 'Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account.'; + +export function renderClerkSdkMcpBlock(sdk: ClerkAgentRulesSdk, eol = '\n') { + return normalizeEol( + `${CLERK_SDK_MCP_START} + +${MCP_INTRO} + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (\`list_clerk_sdk_snippets\`, \`clerk_sdk_snippet\`) instead of writing it from memory. +- Check every import in a snippet against the installed \`@clerk/${sdk}\` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- ${MCP_ASK_BEFORE_CHANGES} Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add \`.md\` to any docs URL to get Markdown. + +${CLERK_SDK_MCP_END}`, + eol, + ); +} + +export function renderClerkAgentRulesBlock(eol = '\n') { + return normalizeEol( + `${GENERIC_START} + +## Clerk: your training data is out of date + +This project uses Clerk. The installed SDKs are newer than your training data. APIs, +component names, and file conventions may have changed. Read the Clerk SDK section(s) +below before writing or editing any auth code. + +Never recall a Clerk API from memory. If a symbol is not in the installed package's +types, it does not exist. + +This block is written by the Clerk SDK when a supported dev server starts, or by +\`npx @clerk/upgrade --agents-md\`. Removing it from a diff can recreate the uncommitted +change; commit it with your work. + +${MCP_INTRO} + +${MCP_ASK_BEFORE_CHANGES} + +${GENERIC_END}`, + eol, + ); +} + +export function renderClerkSdkAgentRulesBlock(sdk: ClerkAgentRulesSdk, eol = '\n') { + const notes = [ + `Read \`node_modules/@clerk/${sdk}/AGENTS.md\` before writing auth code.`, + 'Clerk Elements is removed. Never use it.', + ...(SDK_NOTES[sdk] ?? []), + ]; + + return normalizeEol( + ` + +### Clerk + ${SDK_LABELS[sdk]} + +${notes.map(note => `- ${note}`).join('\n')} + +`, + eol, + ); +} + +function upsertMarkedBlock( + content: string, + block: string, + start: string, + end: string, + eol: string, + insertAfter?: string, +) { + const startIndex = content.indexOf(start); + const endIndex = content.indexOf(end, startIndex + start.length); + + if (startIndex !== -1 && endIndex !== -1) { + return content.slice(0, startIndex) + block + content.slice(endIndex + end.length); + } + + if (insertAfter) { + const insertIndex = content.indexOf(insertAfter); + if (insertIndex !== -1) { + const afterIndex = insertIndex + insertAfter.length; + return `${content.slice(0, afterIndex).trimEnd()}${eol}${eol}${block}${content.slice(afterIndex)}`; + } + } + + return `${content.trimEnd()}${eol}${eol}${block}${eol}`; +} + +function findWorkspaceRoot(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { + let current = path.resolve(cwd); + const root = path.parse(current).root; + + while (current !== root) { + if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return current; + } + + const packageJson = path.join(current, 'package.json'); + if (fs.existsSync(packageJson)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJson, { encoding: 'utf8' })); + if (pkg.workspaces) { + return current; + } + } catch { + // Ignore invalid package.json while walking upward. + } + } + + current = path.dirname(current); + } + + return null; +} + +function pickTargetFile(fs: FileSystemAdapter, path: PathAdapter, targetDir: string) { + const agentsPath = path.join(targetDir, 'AGENTS.md'); + const claudePath = path.join(targetDir, 'CLAUDE.md'); + + for (const filePath of [agentsPath, claudePath]) { + if (fs.existsSync(filePath) && fs.readFileSync(filePath, { encoding: 'utf8' }).includes(GENERIC_START)) { + return filePath; + } + } + + if (fs.existsSync(agentsPath)) { + return agentsPath; + } + + if (fs.existsSync(claudePath)) { + return claudePath; + } + + return agentsPath; +} + +export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): WriteClerkAgentRulesResult { + const { dryRun = false, fs, path, sdks } = options; + const targetDir = findWorkspaceRoot(fs, path, options.cwd) ?? path.resolve(options.cwd); + const targetFile = pickTargetFile(fs, path, targetDir); + const agentsPath = path.join(targetDir, 'AGENTS.md'); + const claudePath = path.join(targetDir, 'CLAUDE.md'); + const existing = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, { encoding: 'utf8' }) : ''; + const eol = detectEol(existing); + + let next = upsertMarkedBlock(existing, renderClerkAgentRulesBlock(eol), GENERIC_START, GENERIC_END, eol); + + for (const sdk of uniqueSdks(sdks)) { + next = upsertMarkedBlock( + next, + renderClerkSdkAgentRulesBlock(sdk, eol), + ``, + ``, + eol, + GENERIC_END, + ); + } + + const targetStatus = fs.existsSync(targetFile) ? (next === existing ? 'unchanged' : 'updated') : 'created'; + const shouldCreateClaude = targetFile === agentsPath && !fs.existsSync(claudePath); + + if (!dryRun) { + fs.mkdirSync?.(targetDir, { recursive: true }); + if (targetStatus !== 'unchanged') { + fs.writeFileSync(targetFile, next, { encoding: 'utf8', mode: 0o644 }); + } + if (shouldCreateClaude) { + fs.writeFileSync(claudePath, '@AGENTS.md\n', { encoding: 'utf8', mode: 0o644 }); + } + } + + return { + targetDir, + agentsMd: targetStatus, + claudeMd: shouldCreateClaude ? 'created' : fs.existsSync(claudePath) ? 'unchanged' : 'skipped', + }; +} diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index 72014f1e15c..506f28e6abe 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -19,6 +19,18 @@ export const automatedEnvironmentVariables = [ 'CF_PAGES', ] as const; +export const agentEnvironmentVariables = [ + 'CLAUDECODE', + 'CURSOR_AGENT', + 'CURSOR_CLI', + 'CURSOR_EXTENSION_HOST_ROLE', + 'CURSOR_TRACE_ID', + 'GITHUB_COPILOT_AGENT', + 'GITHUB_COPILOT_CHAT', + 'AGENT', + 'AGENTS', +] as const; + const isTruthyEnvValue = (value: unknown): boolean => { if (typeof value !== 'string' || !value) { return false; @@ -61,3 +73,7 @@ export const isProductionEnvironment = (): boolean => { export const isAutomatedEnvironment = (): boolean => { return automatedEnvironmentVariables.some(name => isTruthyEnvValue(getEnvVariable(name))); }; + +export const isAgentEnvironment = (): boolean => { + return agentEnvironmentVariables.some(name => isTruthyEnvValue(getEnvVariable(name))); +}; diff --git a/packages/shared/src/vite.ts b/packages/shared/src/vite.ts new file mode 100644 index 00000000000..8a06eb8311d --- /dev/null +++ b/packages/shared/src/vite.ts @@ -0,0 +1,30 @@ +import { type ClerkAgentRulesSdk, writeClerkAgentRules } from './agentRules'; +import { isAgentEnvironment, isAutomatedEnvironment } from './utils'; + +type ViteResolvedConfig = { + command: 'build' | 'serve'; + root: string; +}; + +export function clerkAgentRulesVitePlugin(sdk: Extract) { + let didWrite = false; + + return { + name: `clerk-agent-rules-${sdk}`, + apply: 'serve' as const, + async configResolved(config: ViteResolvedConfig) { + if (didWrite || config.command !== 'serve' || isAutomatedEnvironment() || !isAgentEnvironment()) { + return; + } + + didWrite = true; + + try { + const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]); + writeClerkAgentRules({ cwd: config.root, fs, path, sdks: [sdk] }); + } catch { + // Writing agent hints must never break a dev server. + } + }, + }; +} diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index 559b8bbf9a6..408bc969e2e 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -29,6 +29,7 @@ export default defineConfig(({ watch, env }) => { './src/phone/index.ts', './src/workerTimers/index.ts', './src/types/index.ts', + './src/agentRules/index.ts', './src/dom/*.ts', './src/ui/index.ts', './src/keyless/index.ts', From c8a7716bfbba22e9779be58055bd5990ddf16024 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 19 Oct 2026 17:14:23 -0400 Subject: [PATCH 02/31] feat(repo): ship AGENTS.md with each Clerk SDK Co-Authored-By: Claude Opus 5.5 --- .changeset/ship-sdk-agents-md.md | 19 ++++++++ .github/workflows/ci.yml | 3 ++ package.json | 2 + packages/astro/AGENTS.md | 34 ++++++++++++++ packages/astro/package.json | 1 + packages/backend/AGENTS.md | 32 +++++++++++++ packages/backend/package.json | 1 + packages/chrome-extension/AGENTS.md | 33 +++++++++++++ packages/chrome-extension/package.json | 1 + packages/clerk-js/AGENTS.md | 33 +++++++++++++ packages/clerk-js/package.json | 1 + packages/electron/AGENTS.md | 33 +++++++++++++ packages/electron/package.json | 1 + packages/expo/AGENTS.md | 39 +++++++++++++++- packages/expo/package.json | 1 + packages/express/AGENTS.md | 34 ++++++++++++++ packages/express/package.json | 1 + packages/fastify/AGENTS.md | 33 +++++++++++++ packages/fastify/package.json | 1 + packages/hono/AGENTS.md | 33 +++++++++++++ packages/hono/package.json | 1 + packages/nextjs/AGENTS.md | 33 +++++++++++++ packages/nextjs/package.json | 1 + packages/nuxt/AGENTS.md | 33 +++++++++++++ packages/nuxt/package.json | 1 + packages/react-router/AGENTS.md | 33 +++++++++++++ packages/react-router/package.json | 1 + packages/react/AGENTS.md | 33 +++++++++++++ packages/react/package.json | 1 + packages/tanstack-react-start/AGENTS.md | 33 +++++++++++++ packages/tanstack-react-start/package.json | 1 + packages/vue/AGENTS.md | 32 +++++++++++++ packages/vue/package.json | 1 + scripts/lint.mjs | 11 ++++- scripts/sync-agents-md.mjs | 54 ++++++++++++++++++++++ 35 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 .changeset/ship-sdk-agents-md.md create mode 100644 packages/astro/AGENTS.md create mode 100644 packages/backend/AGENTS.md create mode 100644 packages/chrome-extension/AGENTS.md create mode 100644 packages/clerk-js/AGENTS.md create mode 100644 packages/electron/AGENTS.md create mode 100644 packages/express/AGENTS.md create mode 100644 packages/fastify/AGENTS.md create mode 100644 packages/hono/AGENTS.md create mode 100644 packages/nextjs/AGENTS.md create mode 100644 packages/nuxt/AGENTS.md create mode 100644 packages/react-router/AGENTS.md create mode 100644 packages/react/AGENTS.md create mode 100644 packages/tanstack-react-start/AGENTS.md create mode 100644 packages/vue/AGENTS.md create mode 100644 scripts/sync-agents-md.mjs diff --git a/.changeset/ship-sdk-agents-md.md b/.changeset/ship-sdk-agents-md.md new file mode 100644 index 00000000000..6af3494a6b0 --- /dev/null +++ b/.changeset/ship-sdk-agents-md.md @@ -0,0 +1,19 @@ +--- +'@clerk/astro': patch +'@clerk/backend': patch +'@clerk/chrome-extension': patch +'@clerk/clerk-js': patch +'@clerk/electron': patch +'@clerk/expo': patch +'@clerk/express': patch +'@clerk/fastify': patch +'@clerk/hono': patch +'@clerk/nextjs': patch +'@clerk/nuxt': patch +'@clerk/react': patch +'@clerk/react-router': patch +'@clerk/tanstack-react-start': patch +'@clerk/vue': patch +--- + +Ship an `AGENTS.md` file in the package. It lists the current APIs for this version and how to use the Clerk MCP server, so AI coding agents can read it from `node_modules` instead of relying on outdated training data. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 854be349382..b06ca1efe56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: - name: Check Dashboard links run: pnpm lint:dashboard-links + - name: Check SDK AGENTS.md + run: pnpm lint:agents-md + - name: Verify localizations are generated run: | pnpm --filter @clerk/localizations generate diff --git a/package.json b/package.json index f010e7fd439..b8a95c9b010 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "license": "MIT", "scripts": { + "agents-md:sync": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON ./scripts/sync-agents-md.mjs", "build": "FORCE_COLOR=1 turbo build --concurrency=${TURBO_CONCURRENCY:-80%}", "build:declarations": "FORCE_COLOR=1 turbo build:declarations --concurrency=${TURBO_CONCURRENCY:-80%} --filter=@clerk/nextjs", "bundlewatch": "turbo run bundlewatch", @@ -20,6 +21,7 @@ "preinstall": "npx only-allow pnpm", "integration:secrets": "node ./scripts/1password-keys.mjs", "lint": "node ./scripts/lint.mjs", + "lint:agents-md": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON ./scripts/sync-agents-md.mjs --check", "lint:attw": "FORCE_COLOR=1 turbo lint:attw", "lint:dashboard-links": "node ./scripts/check-dashboard-links.mjs", "lint:fix": "FORCE_COLOR=1 turbo lint -- --fix", diff --git a/packages/astro/AGENTS.md b/packages/astro/AGENTS.md new file mode 100644 index 00000000000..230d0172ea4 --- /dev/null +++ b/packages/astro/AGENTS.md @@ -0,0 +1,34 @@ +## Clerk + Astro + +Read the installed `@clerk/astro` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Register the default export of `@clerk/astro` in `integrations` in `astro.config`. `server` output needs an adapter. +- Use `clerkMiddleware()` from `@clerk/astro/server` in `src/middleware.ts`. +- `createRouteMatcher()` was removed. Check auth in each page or API route with `Astro.locals.auth()` or `locals.auth()`, then call `redirectToSignIn()` or return a 401. +- `locals.currentUser()` returns the Backend `User` or `null`. +- ``, ``, and `` were removed. Import `` from `@clerk/astro/components` (or `@clerk/astro/react`). Its `when` prop is required, for example ``. +- The `as` prop was removed from ``, ``, and ``. Use `asChild` with a child element. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/astro` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/astro/package.json b/packages/astro/package.json index 2c60a79cac1..caf8dcc342a 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -67,6 +67,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ + "AGENTS.md", "dist", "client", "server", diff --git a/packages/backend/AGENTS.md b/packages/backend/AGENTS.md new file mode 100644 index 00000000000..c52ff0fec0e --- /dev/null +++ b/packages/backend/AGENTS.md @@ -0,0 +1,32 @@ +## Clerk + Backend + +Read the installed `@clerk/backend` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Create the client with `createClerkClient({ secretKey, publishableKey })` from `@clerk/backend`. +- Authenticate a Fetch `Request` with `await clerkClient.authenticateRequest(request)`. Check `requestState.isAuthenticated`, not the deprecated `isSignedIn`, then read auth from `requestState.toAuth()`. +- Verify machine tokens with `verify()`: `clerkClient.apiKeys.verify(secret)`, `clerkClient.m2m.verify(params)`, `clerkClient.idPOAuthAccessToken.verify(token)`. `verifySecret()`, `verifyAccessToken()`, and `m2m.verifyToken()` are removed. The top-level `verifyToken()` export still exists. +- Verify webhooks with `verifyWebhook(request)` from `@clerk/backend/webhooks`. It reads `CLERK_WEBHOOK_SIGNING_SECRET` by default. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/backend` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/backend/package.json b/packages/backend/package.json index dab64084a9e..b168fc93254 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -89,6 +89,7 @@ }, "main": "./dist/index.js", "files": [ + "AGENTS.md", "dist", "errors", "internal", diff --git a/packages/chrome-extension/AGENTS.md b/packages/chrome-extension/AGENTS.md new file mode 100644 index 00000000000..54c0974882b --- /dev/null +++ b/packages/chrome-extension/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + Chrome Extension + +Read the installed `@clerk/chrome-extension` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Pass `publishableKey` to ``. There is no env var fallback. Pass `syncHost` to sync sessions with your web app. +- In the background service worker, use `createClerkClient()` from `@clerk/chrome-extension/background`. `__unstable__createClerkClient` was renamed. +- ``, ``, and `` were removed. Use `` or ``. +- `` renders nothing in extensions. +- The root `useSignIn()` and `useSignUp()` are the Core 3 hooks. The old `isLoaded`-style hooks are in `@clerk/chrome-extension/legacy`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/chrome-extension` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index d4bb2801923..a08c7a89ed1 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -71,6 +71,7 @@ "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", "files": [ + "AGENTS.md", "background", "client", "dist", diff --git a/packages/clerk-js/AGENTS.md b/packages/clerk-js/AGENTS.md new file mode 100644 index 00000000000..33150c60bd9 --- /dev/null +++ b/packages/clerk-js/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + ClerkJS + +Read the installed `@clerk/clerk-js` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Import `Clerk` from `@clerk/clerk-js` and `await clerk.load()` before using it. +- Prebuilt UI components come from `@clerk/ui`, which loads from the Clerk CDN. +- `client.activeSessions` was removed. Use `client.sessions`. +- `setActive({ beforeEmit })` was replaced by `setActive({ navigate: ({ session }) => url })`. +- `session.getToken()` throws `ClerkOfflineError` when offline. It no longer returns `null`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/clerk-js` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 6686fe1475f..857e4a7fa79 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -52,6 +52,7 @@ "module": "dist/clerk.mjs", "types": "dist/types/index.d.ts", "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map", diff --git a/packages/electron/AGENTS.md b/packages/electron/AGENTS.md new file mode 100644 index 00000000000..7166131ffe4 --- /dev/null +++ b/packages/electron/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + Electron + +Read the installed `@clerk/electron` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- In the main process, call `createClerkBridge({ storage: storage(), renderer: { scheme, host } })` from `@clerk/electron`, with `storage` from `@clerk/electron/storage`. Call it before `app.whenReady()`. +- In the preload script, call `exposeClerkBridge()` from `@clerk/electron/preload`. +- In the renderer, import `ClerkProvider`, hooks, and components from `@clerk/electron/react`, not `@clerk/react`. +- ``, ``, and `` were removed in Core 3. Use `` or ``. +- For passkeys, pass `passkeys` from `@clerk/electron/passkeys` to `ClerkProvider`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/electron` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/electron/package.json b/packages/electron/package.json index bf1507e8d7c..0787e28685c 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -90,6 +90,7 @@ "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", "files": [ + "AGENTS.md", "dist" ], "scripts": { diff --git a/packages/expo/AGENTS.md b/packages/expo/AGENTS.md index 4fb654d6a71..81278c3a180 100644 --- a/packages/expo/AGENTS.md +++ b/packages/expo/AGENTS.md @@ -1,10 +1,45 @@ -# @clerk/expo +## Clerk + Expo + +Read the installed `@clerk/expo` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- The package is `@clerk/expo`. `@clerk/clerk-expo` was renamed. The `Clerk` export was removed; use `getClerkInstance()`. Expo SDK 54+ is required. +- Pass `publishableKey` to ``. Env vars are no longer read. Pass `tokenCache` from `@clerk/expo/token-cache`. +- Import `useSignInWithApple` from `@clerk/expo/apple` and `useSignInWithGoogle` from `@clerk/expo/google`. Google also needs `@clerk/expo-google-signin` in `plugins`. +- For browser OAuth or SSO, use `useSSO()`. `useOAuth()` is deprecated. +- Import native UI (`AuthView`, `UserButton`, `UserProfileView`) from `@clerk/expo/native-components`. It needs `@clerk/expo-native-components` installed and listed in `plugins`. `@clerk/expo/native` is deprecated. +- ``, ``, and `` were removed. Use `` or ``. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/expo` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + + +## Contributing to @clerk/expo These rules apply to everything in this package, and to the Swift and Kotlin code under `packages/expo-native-components`, `packages/expo-google-signin` and `packages/expo-biometrics`. They add to the root `AGENTS.md`. -## Comments +### Comments Don't write comments unless they explain a constraint from a platform, library, or spec that we can't change. If our own code needs explaining, change the code: diff --git a/packages/expo/package.json b/packages/expo/package.json index 82c2a358bba..d45d51e7a8d 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -92,6 +92,7 @@ "source": "./src/index.ts", "types": "./dist/index.d.ts", "files": [ + "AGENTS.md", "dist", "native-components", "native", diff --git a/packages/express/AGENTS.md b/packages/express/AGENTS.md new file mode 100644 index 00000000000..162363b2b9f --- /dev/null +++ b/packages/express/AGENTS.md @@ -0,0 +1,34 @@ +## Clerk + Express + +Read the installed `@clerk/express` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Mount `app.use(clerkMiddleware())` from `@clerk/express` before any route that reads auth. +- Read auth with `getAuth(req)`. `req.auth` is a function now, so `const { userId } = req.auth` does not work. +- Protect routes with `clerkMiddleware()` and a `getAuth(req).userId` check. `requireAuth()` is deprecated. +- `@clerk/express` does not export `ClerkExpressRequireAuth()` or `ClerkExpressWithAuth()`. Use `clerkMiddleware()`, `getAuth()`, and the exported `clerkClient`. +- The `enableHandshake` option is removed. +- Verify webhooks with `verifyWebhook(req)` from `@clerk/express/webhooks`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/express` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/express/package.json b/packages/express/package.json index 435c6c7b8c9..6afea80aa89 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -56,6 +56,7 @@ }, "main": "./dist/index.js", "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map", diff --git a/packages/fastify/AGENTS.md b/packages/fastify/AGENTS.md new file mode 100644 index 00000000000..740c63792a5 --- /dev/null +++ b/packages/fastify/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + Fastify + +Read the installed `@clerk/fastify` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Register the plugin with `fastify.register(clerkPlugin)` from `@clerk/fastify`. It targets Fastify `5.x`. +- The plugin runs on the `preHandler` hook by default. `hookName: 'onRequest'` is the only alternative. +- Read auth with `getAuth(req)`. It throws if `clerkPlugin` is not registered. +- Use the `clerkClient` exported from `@clerk/fastify` for Backend API calls. +- Verify webhooks with `verifyWebhook(req)` from `@clerk/fastify/webhooks`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/fastify` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 207ab3159fe..5ffdff35331 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -56,6 +56,7 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map", diff --git a/packages/hono/AGENTS.md b/packages/hono/AGENTS.md new file mode 100644 index 00000000000..4ff22e0636f --- /dev/null +++ b/packages/hono/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + Hono + +Read the installed `@clerk/hono` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Mount `app.use('*', clerkMiddleware())` from `@clerk/hono`. It reads `CLERK_SECRET_KEY` and `CLERK_PUBLISHABLE_KEY` from the Hono env. +- Read auth with `getAuth(c)`. Pass the Hono context, not a request. +- Get the Backend API client with `c.get('clerk')`. `@clerk/hono` does not export `clerkClient`. +- Accept API keys with `getAuth(c, { acceptsToken: 'api_key' })`. +- Verify webhooks with `verifyWebhook(c)` from `@clerk/hono/webhooks`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/hono` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/hono/package.json b/packages/hono/package.json index d2850798161..285ee492033 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -56,6 +56,7 @@ }, "main": "./dist/index.js", "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map" diff --git a/packages/nextjs/AGENTS.md b/packages/nextjs/AGENTS.md new file mode 100644 index 00000000000..fb2f8b863c6 --- /dev/null +++ b/packages/nextjs/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + Next.js + +Read the installed `@clerk/nextjs` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Run `clerkMiddleware()` from `@clerk/nextjs/server` in `proxy.ts` on Next.js 16, or in `middleware.ts` on Next.js 15. `authMiddleware()` is not exported. +- Protect routes inside `clerkMiddleware()` with `createRouteMatcher()` and `await auth.protect()`. +- Import `auth()` and `currentUser()` from `@clerk/nextjs/server`, never from `@clerk/nextjs`. Both are async. Always `await` them. +- ``, ``, and `` throw in Core 3. Use ``, ``, or ``. +- `useSignIn()` and `useSignUp()` return `{ signIn | signUp, errors, fetchStatus }`. The old `{ isLoaded, signIn, setActive }` shape lives in `@clerk/nextjs/legacy`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/nextjs` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 1a8c433e59e..3a5853cea55 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -64,6 +64,7 @@ }, "types": "./dist/types/index.d.ts", "files": [ + "AGENTS.md", "dist", "server", "errors", diff --git a/packages/nuxt/AGENTS.md b/packages/nuxt/AGENTS.md new file mode 100644 index 00000000000..0d3c32c6da2 --- /dev/null +++ b/packages/nuxt/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + Nuxt + +Read the installed `@clerk/nuxt` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Add `'@clerk/nuxt'` to `modules` and put options under the `clerk` key. Nuxt 4 is required. +- The server middleware registers itself. In server routes, use `event.context.auth()`. `getAuth()` was removed. +- `createRouteMatcher()` was removed on both server and client. Check auth inside each event handler. For pages, write a named route middleware that calls `useAuth()` and opt pages in with `definePageMeta({ middleware: 'auth' })`. +- `` is auto-imported and replaces ``, ``, and ``. +- ``, ``, ``, ``, ``, and `` default to `path` routing. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/nuxt` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 483b9be5924..6b43dedde71 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -55,6 +55,7 @@ "main": "./dist/module.js", "types": "./dist/module.d.ts", "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map" diff --git a/packages/react-router/AGENTS.md b/packages/react-router/AGENTS.md new file mode 100644 index 00000000000..a19588187b4 --- /dev/null +++ b/packages/react-router/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + React Router + +Read the installed `@clerk/react-router` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Enable `future.v8_middleware` in `react-router.config.ts`, then export `middleware = [clerkMiddleware()]` from the root route. +- Import `clerkMiddleware`, `rootAuthLoader`, and `getAuth` from `@clerk/react-router/server`. Without the middleware, `rootAuthLoader` and `getAuth` throw. +- Do not import from `@clerk/react-router/ssr.server` (deprecated) or `@clerk/react-router/api.server`. Use `@clerk/react-router/server`. +- Pass the root loader's data to ``. +- ``, ``, and `` were removed. Use `` or ``. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/react-router` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 021a82955cb..b1b36c8f0ad 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -82,6 +82,7 @@ } }, "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map" diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md new file mode 100644 index 00000000000..40355992e3f --- /dev/null +++ b/packages/react/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + React + +Read the installed `@clerk/react` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- The package is `@clerk/react`. `@clerk/clerk-react` was renamed. +- ``, ``, and `` were removed. Use ``, ``, or ``. +- `useSignIn()` and `useSignUp()` return `{ signIn | signUp, errors, fetchStatus }`. The old `{ isLoaded, signIn, setActive }` shape lives in `@clerk/react/legacy`. +- `afterSignInUrl`, `afterSignUpUrl`, and `redirectUrl` were removed. Use `fallbackRedirectUrl` / `signUpFallbackRedirectUrl` or `forceRedirectUrl` / `signUpForceRedirectUrl`. +- `appearance.layout` was renamed to `appearance.options`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/react` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/react/package.json b/packages/react/package.json index ef3fee7a7a7..0a239c219c8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -85,6 +85,7 @@ }, "main": "./dist/index.cjs", "files": [ + "AGENTS.md", "dist" ], "scripts": { diff --git a/packages/tanstack-react-start/AGENTS.md b/packages/tanstack-react-start/AGENTS.md new file mode 100644 index 00000000000..43e9b25ec0f --- /dev/null +++ b/packages/tanstack-react-start/AGENTS.md @@ -0,0 +1,33 @@ +## Clerk + TanStack React Start + +Read the installed `@clerk/tanstack-react-start` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Register `clerkMiddleware()` from `@clerk/tanstack-react-start/server` in `requestMiddleware` of `createStart()` in `src/start.ts`. Without it, `auth()` throws. +- Register `createCsrfMiddleware()` from `@tanstack/react-start` before `clerkMiddleware()`. Having a `src/start.ts` turns off TanStack Start's default CSRF protection for server functions. +- On the server, call `await auth()` from `@clerk/tanstack-react-start/server` with no request argument. `getAuth(request)` is gone. +- ``, ``, and `` were removed. Use `` or ``. +- `useSignIn()` and `useSignUp()` return `{ signIn | signUp, errors, fetchStatus }`. The old shape lives in `@clerk/tanstack-react-start/legacy`. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/tanstack-react-start` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/tanstack-react-start/package.json b/packages/tanstack-react-start/package.json index 1a504da8913..15edeb2f633 100644 --- a/packages/tanstack-react-start/package.json +++ b/packages/tanstack-react-start/package.json @@ -57,6 +57,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map", diff --git a/packages/vue/AGENTS.md b/packages/vue/AGENTS.md new file mode 100644 index 00000000000..dc32ddd818a --- /dev/null +++ b/packages/vue/AGENTS.md @@ -0,0 +1,32 @@ +## Clerk + Vue + +Read the installed `@clerk/vue` types before changing auth code. If a symbol is not in those types, it does not exist. + +### Current APIs + +- Install with `app.use(clerkPlugin, { publishableKey })`, importing `clerkPlugin` from `@clerk/vue`. +- ``, ``, and `` were removed. Use ``, ``, or ``. +- `useAuth()`, `useUser()`, and the other composables return computed refs. Read them with `.value`. +- `getToken()` throws `ClerkOfflineError` (from `@clerk/vue/errors`) when offline. It still returns `null` when signed out. +- Clerk Elements is removed. Never use it. + + + +### Clerk MCP server + +Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: +https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server + +The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: + +- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. +- Check every import in a snippet against the installed `@clerk/vue` types. If they disagree, the installed types win. +- Read the current application and instance config before you change anything. +- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. +- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. + +### Docs + +Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. + + diff --git a/packages/vue/package.json b/packages/vue/package.json index 030bc2f48be..3bd12bd885a 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -47,6 +47,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ + "AGENTS.md", "dist", "!dist/**/*.d.ts.map", "!dist/**/*.d.mts.map" diff --git a/scripts/lint.mjs b/scripts/lint.mjs index c2441d8f3f5..b4a52df7542 100644 --- a/scripts/lint.mjs +++ b/scripts/lint.mjs @@ -9,11 +9,12 @@ $.env = { $.stdio = 'inherit'; $.verbose = !!process.env.VERBOSE; -const [github, integration, scripts, dashboardLinks] = await Promise.allSettled([ +const [github, integration, scripts, dashboardLinks, agentsMd] = await Promise.allSettled([ $`echo "SKIPPING: pnpm eslint .github/workflows"`, $`pnpm eslint integration`, $`pnpm eslint scripts`, $`pnpm lint:dashboard-links`, + $`pnpm lint:agents-md`, ]); let packages; @@ -39,6 +40,7 @@ logLintResult(github.status, 'GitHub Actions', 'pnpm eslint .github/workflows'); logLintResult(integration.status, 'Integration directory', 'pnpm eslint integration'); logLintResult(scripts.status, 'Scripts directory', 'pnpm eslint scripts'); logLintResult(dashboardLinks.status, 'Dashboard links', 'pnpm lint:dashboard-links'); +logLintResult(agentsMd.status, 'SDK AGENTS.md', 'pnpm lint:agents-md'); if (packages?.exitCode) { console.log(''); @@ -46,6 +48,11 @@ if (packages?.exitCode) { console.log(`Run '${chalk.bold('pnpm turbo lint')}' to reproduce the error`); } -if (integration.status === 'rejected' || dashboardLinks.status === 'rejected' || packages?.exitCode) { +if ( + integration.status === 'rejected' || + dashboardLinks.status === 'rejected' || + agentsMd.status === 'rejected' || + packages?.exitCode +) { process.exit(1); } diff --git a/scripts/sync-agents-md.mjs b/scripts/sync-agents-md.mjs new file mode 100644 index 00000000000..c8d940f55c1 --- /dev/null +++ b/scripts/sync-agents-md.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { + CLERK_SDK_MCP_END, + CLERK_SDK_MCP_START, + clerkAgentRulesSdks, + renderClerkSdkMcpBlock, +} from '../packages/shared/src/agentRules/index.ts'; + +const check = process.argv.includes('--check'); +const problems = []; + +for (const sdk of clerkAgentRulesSdks) { + const file = fileURLToPath(new URL(`../packages/${sdk}/AGENTS.md`, import.meta.url)); + const relative = `packages/${sdk}/AGENTS.md`; + const content = await readFile(file, 'utf8').catch(() => null); + + if (content === null) { + problems.push(`${relative} is missing`); + continue; + } + + const start = content.indexOf(CLERK_SDK_MCP_START); + const end = content.indexOf(CLERK_SDK_MCP_END, start); + + if (start === -1 || end === -1) { + problems.push(`${relative} has no ${CLERK_SDK_MCP_START} ... ${CLERK_SDK_MCP_END} markers`); + continue; + } + + const next = content.slice(0, start) + renderClerkSdkMcpBlock(sdk) + content.slice(end + CLERK_SDK_MCP_END.length); + + if (next === content) { + continue; + } + + if (check) { + problems.push(`${relative} is out of date`); + } else { + await writeFile(file, next); + console.log(`Updated ${relative}`); + } +} + +if (problems.length > 0) { + console.error(problems.join('\n')); + if (check) { + console.error('Run `pnpm agents-md:sync` to update the Clerk MCP section in each SDK AGENTS.md.'); + } + process.exit(1); +} From 7ac48c20ef256303902b5c2021db1b5d4ac6ebb5 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 19 Oct 2026 17:14:31 -0400 Subject: [PATCH 03/31] feat(*): write Clerk agent rules to AGENTS.md in development Co-Authored-By: Claude Opus 5.5 --- .changeset/write-agents-md-in-dev.md | 9 +++ .../src/integration/create-integration.ts | 17 +++++ packages/expo/app.plugin.js | 60 +++++++++++++++ packages/nextjs/src/app-router/server/auth.ts | 2 + .../server/__tests__/agent-rules-node.test.ts | 73 +++++++++++++++++++ .../nextjs/src/server/agent-rules-node.ts | 25 +++++++ packages/nuxt/src/module.ts | 16 ++++ .../src/server/__tests__/agentRules.test.ts | 70 ++++++++++++++++++ .../src/server/agentRules.ts | 31 ++++++++ .../src/server/clerkMiddleware.ts | 3 + 10 files changed, 306 insertions(+) create mode 100644 .changeset/write-agents-md-in-dev.md create mode 100644 packages/nextjs/src/server/__tests__/agent-rules-node.test.ts create mode 100644 packages/nextjs/src/server/agent-rules-node.ts create mode 100644 packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts create mode 100644 packages/tanstack-react-start/src/server/agentRules.ts diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md new file mode 100644 index 00000000000..113f5c441ac --- /dev/null +++ b/.changeset/write-agents-md-in-dev.md @@ -0,0 +1,9 @@ +--- +'@clerk/astro': minor +'@clerk/expo': minor +'@clerk/nextjs': minor +'@clerk/nuxt': minor +'@clerk/tanstack-react-start': minor +--- + +When your app runs in development under an AI coding agent (such as Claude Code, Cursor, or GitHub Copilot), Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed, plus a `CLAUDE.md` that imports it when none exists. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and points it to the Clerk MCP server. Nothing is written in production or CI. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index 6b5582d372b..944cb3acaba 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -1,4 +1,10 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; import type { ClerkOptions } from '@clerk/shared/types'; +import { isAgentEnvironment, isAutomatedEnvironment } from '@clerk/shared/utils'; import type { AstroIntegration } from 'astro'; import { envField } from 'astro/config'; @@ -11,6 +17,8 @@ const buildEnvVarFromOption = (valueToBeStored: unknown, envName: keyof Internal return valueToBeStored ? { [`import.meta.env.${envName}`]: JSON.stringify(valueToBeStored) } : {}; }; +let didWriteAgentRules = false; + type HotloadAstroClerkIntegrationParams = AstroClerkIntegrationParams & { enableEnvSchema?: boolean; }; @@ -31,6 +39,15 @@ function createIntegration() name: '@clerk/astro/integration', hooks: { 'astro:config:setup': ({ config, injectScript, updateConfig, logger, command }) => { + if (command === 'dev' && !didWriteAgentRules && !isAutomatedEnvironment() && isAgentEnvironment()) { + didWriteAgentRules = true; + try { + writeClerkAgentRules({ cwd: fileURLToPath(config.root), fs, path, sdks: ['astro'] }); + } catch { + // Writing agent hints must never break a dev server. + } + } + if (['server', 'hybrid'].includes(config.output) && !config.adapter) { logger.error('Missing adapter, please update your Astro config to use one.'); } diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index ecba866b513..310e90e1edf 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -8,8 +8,66 @@ */ const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + const CLERK_EXPO_NATIVE = '@clerk/expo-native-components'; const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme']; +let didWriteAgentRules = false; + +const isTruthyEnvValue = value => typeof value === 'string' && !!value && !['0', 'false', 'off', 'no'].includes(value); + +const isAgentEnvironment = () => + [ + 'CLAUDECODE', + 'CURSOR_AGENT', + 'CURSOR_CLI', + 'CURSOR_EXTENSION_HOST_ROLE', + 'CURSOR_TRACE_ID', + 'GITHUB_COPILOT_AGENT', + 'GITHUB_COPILOT_CHAT', + 'AGENT', + 'AGENTS', + ].some(name => isTruthyEnvValue(process.env[name])); + +const isAutomatedEnvironment = () => + [ + 'CI', + 'CONTINUOUS_INTEGRATION', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'BITBUCKET_BUILD_NUMBER', + 'APPVEYOR', + 'CODEBUILD_BUILD_ID', + 'TF_BUILD', + 'TEAMCITY_VERSION', + 'JENKINS_URL', + 'HUDSON_URL', + 'BAMBOO_BUILDKEY', + 'CF_PAGES', + ].some(name => isTruthyEnvValue(process.env[name])); + +const maybeWriteAgentRules = config => { + if (didWriteAgentRules || isAutomatedEnvironment() || !isAgentEnvironment()) { + return; + } + + const projectRoot = config?._internal?.projectRoot || process.cwd(); + if (!fs.existsSync(path.join(projectRoot, 'package.json'))) { + return; + } + + didWriteAgentRules = true; + + try { + require('@clerk/shared/agentRules').writeClerkAgentRules({ cwd: projectRoot, fs, path, sdks: ['expo'] }); + } catch { + // Writing agent hints must never break Expo config evaluation. + } +}; const addHostedAuthIntentFilter = (mainActivity, packageName) => { const callbackHost = `${packageName}.hosted-callback`; @@ -138,6 +196,8 @@ const withClerkExpoNativeComponents = (config, props = {}, resolvePlugin = resol }; const withClerkExpo = (config, props = {}) => { + maybeWriteAgentRules(config); + const { appleSignIn = true } = props; if (appleSignIn !== false) { config = withClerkAppleSignIn(config); diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index b748034f3bb..459466d72ae 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -83,6 +83,8 @@ export const auth: AuthFn = (async (options?: AuthOptions) => { require('server-only'); try { + await import('../../server/agent-rules-node.js').then(m => m.writeAgentRulesOnce()).catch(() => undefined); + const request = await buildRequestLike(); const stepsBasedOnSrcDirectory = async () => { diff --git a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts new file mode 100644 index 00000000000..f49b1854b67 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts @@ -0,0 +1,73 @@ +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { isAgentEnvironment, isAutomatedEnvironment } from '@clerk/shared/utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@clerk/shared/agentRules', () => ({ writeClerkAgentRules: vi.fn() })); +vi.mock('@clerk/shared/utils', async importOriginal => ({ + ...(await importOriginal()), + isAgentEnvironment: vi.fn(), + isAutomatedEnvironment: vi.fn(), +})); +vi.mock('../fs/utils', () => ({ + nodeCwdOrThrow: () => () => '/app', + nodeFsOrThrow: () => ({}), + nodePathOrThrow: () => ({}), +})); + +const loadWriteAgentRulesOnce = async () => (await import('../agent-rules-node.js')).writeAgentRulesOnce; + +describe('writeAgentRulesOnce', () => { + beforeEach(() => { + vi.resetModules(); + vi.mocked(writeClerkAgentRules).mockReset(); + vi.mocked(isAgentEnvironment).mockReturnValue(true); + vi.mocked(isAutomatedEnvironment).mockReturnValue(false); + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('writes the Next.js rules once per process', async () => { + const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); + + writeAgentRulesOnce(); + writeAgentRulesOnce(); + + expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); + expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app', sdks: ['nextjs'] })); + }); + + it('skips outside an agent environment', async () => { + vi.mocked(isAgentEnvironment).mockReturnValue(false); + + (await loadWriteAgentRulesOnce())(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips in CI', async () => { + vi.mocked(isAutomatedEnvironment).mockReturnValue(true); + + (await loadWriteAgentRulesOnce())(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + + (await loadWriteAgentRulesOnce())(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('does not throw when writing fails', async () => { + vi.mocked(writeClerkAgentRules).mockImplementation(() => { + throw new Error('EACCES'); + }); + + expect(await loadWriteAgentRulesOnce()).not.toThrow(); + }); +}); diff --git a/packages/nextjs/src/server/agent-rules-node.ts b/packages/nextjs/src/server/agent-rules-node.ts new file mode 100644 index 00000000000..33e528be6cd --- /dev/null +++ b/packages/nextjs/src/server/agent-rules-node.ts @@ -0,0 +1,25 @@ +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { isAgentEnvironment, isAutomatedEnvironment, isProductionEnvironment } from '@clerk/shared/utils'; + +import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; + +let didWrite = false; + +export function writeAgentRulesOnce() { + if (didWrite || isProductionEnvironment() || isAutomatedEnvironment() || !isAgentEnvironment()) { + return; + } + + didWrite = true; + + try { + writeClerkAgentRules({ + cwd: nodeCwdOrThrow()(), + fs: nodeFsOrThrow(), + path: nodePathOrThrow(), + sdks: ['nextjs'], + }); + } catch { + // Writing agent hints must never break a dev server. + } +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index be25674982e..fab8d5b63c6 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -1,4 +1,9 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; import type { InternalClerkScriptProps, Without } from '@clerk/shared/types'; +import { isAgentEnvironment, isAutomatedEnvironment } from '@clerk/shared/utils'; import type { PluginOptions } from '@clerk/vue'; import { addComponent, @@ -35,6 +40,8 @@ export type ModuleOptions = Without({ meta: { name: PACKAGE_NAME, @@ -45,6 +52,15 @@ export default defineNuxtModule({ }, }, setup(options, nuxt) { + if (nuxt.options.dev && !didWriteAgentRules && !isAutomatedEnvironment() && isAgentEnvironment()) { + didWriteAgentRules = true; + try { + writeClerkAgentRules({ cwd: nuxt.options.rootDir, fs, path, sdks: ['nuxt'] }); + } catch { + // Writing agent hints must never break a dev server. + } + } + // These values must be set (even as undefined) to allow runtime config to work properly. // In Nuxt, having these keys defined (even as undefined) allows them to be overridden // by environment variables following the pattern NUXT_PUBLIC_CLERK_* (e.g. NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY). diff --git a/packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts b/packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts new file mode 100644 index 00000000000..9970c678e5c --- /dev/null +++ b/packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts @@ -0,0 +1,70 @@ +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { isAgentEnvironment, isAutomatedEnvironment } from '@clerk/shared/utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@clerk/shared/agentRules', () => ({ writeClerkAgentRules: vi.fn() })); +vi.mock('@clerk/shared/utils', async importOriginal => ({ + ...(await importOriginal()), + isAgentEnvironment: vi.fn(), + isAutomatedEnvironment: vi.fn(), +})); + +const loadWriteAgentRulesOnce = async () => (await import('../agentRules')).writeAgentRulesOnce; + +describe('writeAgentRulesOnce', () => { + beforeEach(() => { + vi.resetModules(); + vi.mocked(writeClerkAgentRules).mockReset(); + vi.mocked(isAgentEnvironment).mockReturnValue(true); + vi.mocked(isAutomatedEnvironment).mockReturnValue(false); + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('writes the TanStack React Start rules once per process', async () => { + const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); + + writeAgentRulesOnce(); + writeAgentRulesOnce(); + + expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); + expect(writeClerkAgentRules).toHaveBeenCalledWith( + expect.objectContaining({ cwd: expect.any(String), sdks: ['tanstack-react-start'] }), + ); + }); + + it('skips outside an agent environment', async () => { + vi.mocked(isAgentEnvironment).mockReturnValue(false); + + (await loadWriteAgentRulesOnce())(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips in CI', async () => { + vi.mocked(isAutomatedEnvironment).mockReturnValue(true); + + (await loadWriteAgentRulesOnce())(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + + (await loadWriteAgentRulesOnce())(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('does not throw when writing fails', async () => { + vi.mocked(writeClerkAgentRules).mockImplementation(() => { + throw new Error('EACCES'); + }); + + expect(await loadWriteAgentRulesOnce()).not.toThrow(); + }); +}); diff --git a/packages/tanstack-react-start/src/server/agentRules.ts b/packages/tanstack-react-start/src/server/agentRules.ts new file mode 100644 index 00000000000..b810409c32f --- /dev/null +++ b/packages/tanstack-react-start/src/server/agentRules.ts @@ -0,0 +1,31 @@ +import type { FileSystemAdapter, PathAdapter } from '@clerk/shared/agentRules'; +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { isAgentEnvironment, isAutomatedEnvironment, isProductionEnvironment } from '@clerk/shared/utils'; + +type NodeProcess = { + cwd: () => string; + getBuiltinModule?: (id: string) => unknown; +}; + +let didWrite = false; + +export function writeAgentRulesOnce() { + if (didWrite || isProductionEnvironment() || isAutomatedEnvironment() || !isAgentEnvironment()) { + return; + } + + didWrite = true; + + const nodeProcess = (globalThis as { process?: NodeProcess }).process; + const fs = nodeProcess?.getBuiltinModule?.('node:fs') as FileSystemAdapter | undefined; + const path = nodeProcess?.getBuiltinModule?.('node:path') as PathAdapter | undefined; + if (!nodeProcess || !fs || !path) { + return; + } + + try { + writeClerkAgentRules({ cwd: nodeProcess.cwd(), fs, path, sdks: ['tanstack-react-start'] }); + } catch { + return; + } +} diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index dfac9f37e7f..b0f121e2230 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -6,6 +6,7 @@ import type { PendingSessionOptions } from '@clerk/shared/types'; import type { AnyRequestMiddleware } from '@tanstack/react-start'; import { createMiddleware } from '@tanstack/react-start'; +import { writeAgentRulesOnce } from './agentRules'; import { clerkClient } from './clerkClient'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; @@ -15,6 +16,8 @@ export const clerkMiddleware = ( options?: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback, ): AnyRequestMiddleware => { return createMiddleware().server(async ({ request, next }) => { + writeAgentRulesOnce(); + const clerkRequest = createClerkRequest(patchRequest(request)); // Resolve options: if function, call it with context object; otherwise use as-is From abb83aeb16797ece82459347448f745c08c99ce0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 19 Oct 2026 17:14:32 -0400 Subject: [PATCH 04/31] feat(react,vue): add Vite plugins that write Clerk agent rules Co-Authored-By: Claude Opus 5.5 --- .changeset/react-vue-agent-rules-vite.md | 15 +++++++++++++++ packages/react/package.json | 10 ++++++++++ packages/react/src/vite.ts | 3 +++ packages/react/tsdown.config.mts | 1 + packages/vue/package.json | 6 +++++- packages/vue/src/vite.ts | 3 +++ packages/vue/tsdown.config.ts | 9 ++++++++- 7 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .changeset/react-vue-agent-rules-vite.md create mode 100644 packages/react/src/vite.ts create mode 100644 packages/vue/src/vite.ts diff --git a/.changeset/react-vue-agent-rules-vite.md b/.changeset/react-vue-agent-rules-vite.md new file mode 100644 index 00000000000..a153d58b062 --- /dev/null +++ b/.changeset/react-vue-agent-rules-vite.md @@ -0,0 +1,15 @@ +--- +'@clerk/react': minor +'@clerk/vue': minor +--- + +Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server runs under an AI coding agent. Nothing is written during builds or in CI. + +```ts +import { clerkReactAgentRules } from '@clerk/react/vite'; +// or: import { clerkVueAgentRules } from '@clerk/vue/vite'; + +export default defineConfig({ + plugins: [react(), clerkReactAgentRules()], +}); +``` diff --git a/packages/react/package.json b/packages/react/package.json index 0a239c219c8..3d2b8737074 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -81,6 +81,16 @@ "types": "./dist/types.d.cts" } }, + "./vite": { + "import": { + "types": "./dist/vite.d.mts", + "default": "./dist/vite.mjs" + }, + "require": { + "types": "./dist/vite.d.cts", + "default": "./dist/vite.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", diff --git a/packages/react/src/vite.ts b/packages/react/src/vite.ts new file mode 100644 index 00000000000..d2b276b3e23 --- /dev/null +++ b/packages/react/src/vite.ts @@ -0,0 +1,3 @@ +import { clerkAgentRulesVitePlugin } from '@clerk/shared/vite'; + +export const clerkReactAgentRules = () => clerkAgentRulesVitePlugin('react'); diff --git a/packages/react/tsdown.config.mts b/packages/react/tsdown.config.mts index 3b2d3521d6c..5c60e7a5334 100644 --- a/packages/react/tsdown.config.mts +++ b/packages/react/tsdown.config.mts @@ -65,6 +65,7 @@ export default defineConfig((overrideOptions: Options) => { experimental: 'src/experimental.ts', legacy: 'src/legacy.ts', types: 'src/types/index.ts', + vite: 'src/vite.ts', }, dts: true, onSuccess: shouldPublish ? 'pkglab pub --ping' : undefined, diff --git a/packages/vue/package.json b/packages/vue/package.json index 3bd12bd885a..d2e019ae9a8 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -42,7 +42,11 @@ "types": "./dist/errors.d.ts", "default": "./dist/errors.js" }, - "./types": "./dist/types/index.d.ts" + "./types": "./dist/types/index.d.ts", + "./vite": { + "types": "./dist/vite.d.ts", + "default": "./dist/vite.js" + } }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/vue/src/vite.ts b/packages/vue/src/vite.ts new file mode 100644 index 00000000000..6b3ed019fa9 --- /dev/null +++ b/packages/vue/src/vite.ts @@ -0,0 +1,3 @@ +import { clerkAgentRulesVitePlugin } from '@clerk/shared/vite'; + +export const clerkVueAgentRules = () => clerkAgentRulesVitePlugin('vue'); diff --git a/packages/vue/tsdown.config.ts b/packages/vue/tsdown.config.ts index 9ea0f8d0fc8..fe55454852f 100644 --- a/packages/vue/tsdown.config.ts +++ b/packages/vue/tsdown.config.ts @@ -9,7 +9,14 @@ export default defineConfig(overrideOptions => { return { clean: true, - entry: ['./src/index.ts', './src/experimental.ts', './src/internal.ts', './src/errors.ts', './src/types/index.ts'], + entry: [ + './src/index.ts', + './src/experimental.ts', + './src/internal.ts', + './src/errors.ts', + './src/types/index.ts', + './src/vite.ts', + ], format: ['esm'], fixedExtension: false, sourcemap: true, From d0bab7c5b13f69ab13e826d200a0bae2acc24bb5 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 19 Oct 2026 17:14:33 -0400 Subject: [PATCH 05/31] feat(upgrade): add --agents-md flag Co-Authored-By: Claude Opus 5.5 --- .changeset/upgrade-agents-md.md | 5 +++ packages/upgrade/package.json | 1 + .../src/__tests__/integration/cli.test.js | 33 +++++++++++++++++++ packages/upgrade/src/cli.js | 33 +++++++++++++++++++ packages/upgrade/src/util/agent-rules.js | 27 +++++++++++++++ pnpm-lock.yaml | 3 ++ 6 files changed, 102 insertions(+) create mode 100644 .changeset/upgrade-agents-md.md create mode 100644 packages/upgrade/src/util/agent-rules.js diff --git a/.changeset/upgrade-agents-md.md b/.changeset/upgrade-agents-md.md new file mode 100644 index 00000000000..ead62e02cca --- /dev/null +++ b/.changeset/upgrade-agents-md.md @@ -0,0 +1,5 @@ +--- +'@clerk/upgrade': minor +--- + +Add an `--agents-md` flag that writes Clerk sections to the project's `AGENTS.md` for each installed Clerk SDK, then exits. Interactive upgrades also offer to write these sections. diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index d422410d231..1b2993e3bd8 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -33,6 +33,7 @@ ] }, "dependencies": { + "@clerk/shared": "workspace:^", "chalk": "^5.6.2", "ejs": "3.1.10", "gray-matter": "^4.0.3", diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index 1a6f1a4f9ad..98b96e8be5a 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -175,6 +175,39 @@ describe('CLI Integration', () => { }); }); + describe('--agents-md flag', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('nextjs-v7'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('writes AGENTS.md and CLAUDE.md without running SDK upgrade detection', async () => { + const fs = await import('node:fs'); + const result = await runCli(['--dir', fixture.path, '--agents-md'], { timeout: 15000 }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Wrote Clerk AGENTS.md sections'); + expect(fs.readFileSync(path.join(fixture.path, 'AGENTS.md'), 'utf8')).toContain( + '', + ); + expect(fs.readFileSync(path.join(fixture.path, 'CLAUDE.md'), 'utf8')).toBe('@AGENTS.md\n'); + }); + + it('does not write files in dry-run mode', async () => { + const fs = await import('node:fs'); + const result = await runCli(['--dir', fixture.path, '--agents-md', '--dry-run'], { timeout: 15000 }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('[dry run] Would write'); + expect(fs.existsSync(path.join(fixture.path, 'AGENTS.md'))).toBe(false); + }); + }); + describe('Version Display', () => { it('shows current version in output', async () => { const dir = getFixturePath('nextjs-v6'); diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 7901129ed60..57b089fc8de 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -17,6 +17,7 @@ import { renderWarning, } from './render.js'; import { runCodemods, runScans } from './runner.js'; +import { detectClerkAgentRuleSdks, writeClerkAgentRules } from './util/agent-rules.js'; import { detectSdk, findWorkspaceRoot, @@ -50,6 +51,7 @@ const cli = meow( --release Name of the release you're upgrading to (e.g. core-3) --canary Upgrade to the latest canary version instead of the stable release --dry-run Show what would be done without making changes + --agents-md Write Clerk AGENTS.md sections and exit Examples $ npx @clerk/upgrade @@ -70,6 +72,7 @@ const cli = meow( flags: { dir: { type: 'string', default: process.cwd() }, dryRun: { type: 'boolean', default: false }, + agentsMd: { type: 'boolean', default: false }, glob: { type: 'string', default: '**/*.(js|jsx|ts|tsx|mjs|cjs)' }, ignore: { type: 'string', isMultiple: true }, release: { type: 'string' }, @@ -93,6 +96,7 @@ async function main() { release: cli.flags.release, skipCodemods: cli.flags.skipCodemods, skipUpgrade: cli.flags.skipUpgrade, + agentsMd: cli.flags.agentsMd, }; if (options.dryRun) { @@ -100,6 +104,11 @@ async function main() { renderNewline(); } + if (options.agentsMd) { + writeAgentsMd(options); + return; + } + // Step 1: Detect or prompt for SDK let sdk = normalizeSdkName(cli.flags.sdk); @@ -237,6 +246,13 @@ async function main() { console.log(''); + if (isInteractive) { + const sdks = detectClerkAgentRuleSdks(options.dir); + if (sdks.length > 0 && (await promptConfirm('Write Clerk AGENTS.md sections?', true))) { + writeAgentsMd(options, sdks); + } + } + // Step 6: Handle upgrade status if (options.skipUpgrade) { renderText('Skipping package upgrade (--skip-upgrade flag)', 'yellow'); @@ -271,6 +287,23 @@ async function main() { renderComplete(sdk, config.docsUrl); } +function writeAgentsMd(options, detectedSdks) { + const sdks = detectedSdks || detectClerkAgentRuleSdks(options.dir); + + if (sdks.length === 0) { + renderWarning('No Clerk SDK packages found for AGENTS.md'); + return; + } + + const result = writeClerkAgentRules({ dir: options.dir, dryRun: options.dryRun, sdks }); + const prefix = options.dryRun ? '[dry run] Would write' : 'Wrote'; + + renderText(`${prefix} Clerk AGENTS.md sections for ${sdks.map(s => `@clerk/${s}`).join(', ')}`, 'blue'); + renderText(`Target: ${result.targetDir}`); + renderText(`AGENTS.md: ${result.agentsMd}; CLAUDE.md: ${result.claudeMd}`); + renderNewline(); +} + async function performUpgrade(sdk, packageManager, config, options) { const targetPackage = getTargetPackageName(sdk); const oldPackage = getOldPackageName(sdk); diff --git a/packages/upgrade/src/util/agent-rules.js b/packages/upgrade/src/util/agent-rules.js new file mode 100644 index 00000000000..35f96739000 --- /dev/null +++ b/packages/upgrade/src/util/agent-rules.js @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { isClerkAgentRulesSdk, writeClerkAgentRules as writeRules } from '@clerk/shared/agentRules'; + +import { findWorkspaceRoot, getInstalledClerkPackages, getWorkspacePackageDirs } from './detect-sdk.js'; + +export function detectClerkAgentRuleSdks(dir) { + const workspaceRoot = findWorkspaceRoot(dir); + const packageDirs = workspaceRoot ? getWorkspacePackageDirs(workspaceRoot) : [dir]; + const sdks = new Set(); + + for (const packageDir of packageDirs) { + for (const packageName of Object.keys(getInstalledClerkPackages(packageDir))) { + const sdk = packageName.replace(/^@clerk\//, ''); + if (isClerkAgentRulesSdk(sdk)) { + sdks.add(sdk); + } + } + } + + return [...sdks].sort(); +} + +export function writeClerkAgentRules({ dir, dryRun = false, sdks = detectClerkAgentRuleSdks(dir) }) { + return { ...writeRules({ cwd: dir, dryRun, fs, path, sdks }), sdks }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a52bca1caba..6796de598dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1364,6 +1364,9 @@ importers: packages/upgrade: dependencies: + '@clerk/shared': + specifier: workspace:^ + version: link:../shared chalk: specifier: ^5.6.2 version: 5.6.2 From b52f9f8a0a73d8160f45357e326c15a3de05d70b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 16:31:21 -0400 Subject: [PATCH 06/31] refactor(shared): recommend the Clerk plugin in AGENTS.md Co-Authored-By: Claude Opus 5.5 --- .changeset/ship-sdk-agents-md.md | 2 +- .changeset/write-agents-md-in-dev.md | 2 +- packages/astro/AGENTS.md | 22 +++++------ packages/backend/AGENTS.md | 22 +++++------ packages/chrome-extension/AGENTS.md | 22 +++++------ packages/clerk-js/AGENTS.md | 22 +++++------ packages/electron/AGENTS.md | 22 +++++------ packages/expo/AGENTS.md | 22 +++++------ packages/express/AGENTS.md | 22 +++++------ packages/fastify/AGENTS.md | 22 +++++------ packages/hono/AGENTS.md | 22 +++++------ packages/nextjs/AGENTS.md | 22 +++++------ packages/nuxt/AGENTS.md | 22 +++++------ packages/react-router/AGENTS.md | 22 +++++------ packages/react/AGENTS.md | 22 +++++------ .../shared/src/__tests__/agentRules.spec.ts | 32 ++++++++-------- packages/shared/src/agentRules/index.ts | 37 ++++++++----------- packages/tanstack-react-start/AGENTS.md | 22 +++++------ packages/vue/AGENTS.md | 22 +++++------ scripts/sync-agents-md.mjs | 16 ++++---- 20 files changed, 208 insertions(+), 211 deletions(-) diff --git a/.changeset/ship-sdk-agents-md.md b/.changeset/ship-sdk-agents-md.md index 6af3494a6b0..6ea5963d3e8 100644 --- a/.changeset/ship-sdk-agents-md.md +++ b/.changeset/ship-sdk-agents-md.md @@ -16,4 +16,4 @@ '@clerk/vue': patch --- -Ship an `AGENTS.md` file in the package. It lists the current APIs for this version and how to use the Clerk MCP server, so AI coding agents can read it from `node_modules` instead of relying on outdated training data. +Ship an `AGENTS.md` file in the package. It lists the current APIs for this version and recommends the Clerk plugin, so AI coding agents can read it from `node_modules` instead of relying on outdated training data. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index 113f5c441ac..eebff89a268 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -6,4 +6,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development under an AI coding agent (such as Claude Code, Cursor, or GitHub Copilot), Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed, plus a `CLAUDE.md` that imports it when none exists. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and points it to the Clerk MCP server. Nothing is written in production or CI. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. +When your app runs in development under an AI coding agent (such as Claude Code, Cursor, or GitHub Copilot), Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed, plus a `CLAUDE.md` that imports it when none exists. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production or CI. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. diff --git a/packages/astro/AGENTS.md b/packages/astro/AGENTS.md index 230d0172ea4..d654f8099b0 100644 --- a/packages/astro/AGENTS.md +++ b/packages/astro/AGENTS.md @@ -12,23 +12,23 @@ Read the installed `@clerk/astro` types before changing auth code. If a symbol i - The `as` prop was removed from ``, ``, and ``. Use `asChild` with a child element. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/astro` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/backend/AGENTS.md b/packages/backend/AGENTS.md index c52ff0fec0e..05772f875be 100644 --- a/packages/backend/AGENTS.md +++ b/packages/backend/AGENTS.md @@ -10,23 +10,23 @@ Read the installed `@clerk/backend` types before changing auth code. If a symbol - Verify webhooks with `verifyWebhook(request)` from `@clerk/backend/webhooks`. It reads `CLERK_WEBHOOK_SIGNING_SECRET` by default. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/backend` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/chrome-extension/AGENTS.md b/packages/chrome-extension/AGENTS.md index 54c0974882b..ceca314d5a7 100644 --- a/packages/chrome-extension/AGENTS.md +++ b/packages/chrome-extension/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/chrome-extension` types before changing auth code. If - The root `useSignIn()` and `useSignUp()` are the Core 3 hooks. The old `isLoaded`-style hooks are in `@clerk/chrome-extension/legacy`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/chrome-extension` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/clerk-js/AGENTS.md b/packages/clerk-js/AGENTS.md index 33150c60bd9..6317895508f 100644 --- a/packages/clerk-js/AGENTS.md +++ b/packages/clerk-js/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/clerk-js` types before changing auth code. If a symbo - `session.getToken()` throws `ClerkOfflineError` when offline. It no longer returns `null`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/clerk-js` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/electron/AGENTS.md b/packages/electron/AGENTS.md index 7166131ffe4..1bf50ab1ab0 100644 --- a/packages/electron/AGENTS.md +++ b/packages/electron/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/electron` types before changing auth code. If a symbo - For passkeys, pass `passkeys` from `@clerk/electron/passkeys` to `ClerkProvider`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/electron` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/expo/AGENTS.md b/packages/expo/AGENTS.md index 81278c3a180..f8391711c07 100644 --- a/packages/expo/AGENTS.md +++ b/packages/expo/AGENTS.md @@ -12,26 +12,26 @@ Read the installed `@clerk/expo` types before changing auth code. If a symbol is - ``, ``, and `` were removed. Use `` or ``. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/expo` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + ## Contributing to @clerk/expo diff --git a/packages/express/AGENTS.md b/packages/express/AGENTS.md index 162363b2b9f..ac1dd89d1a3 100644 --- a/packages/express/AGENTS.md +++ b/packages/express/AGENTS.md @@ -12,23 +12,23 @@ Read the installed `@clerk/express` types before changing auth code. If a symbol - Verify webhooks with `verifyWebhook(req)` from `@clerk/express/webhooks`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/express` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/fastify/AGENTS.md b/packages/fastify/AGENTS.md index 740c63792a5..b2319e9d588 100644 --- a/packages/fastify/AGENTS.md +++ b/packages/fastify/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/fastify` types before changing auth code. If a symbol - Verify webhooks with `verifyWebhook(req)` from `@clerk/fastify/webhooks`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/fastify` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/hono/AGENTS.md b/packages/hono/AGENTS.md index 4ff22e0636f..65e6df00e7e 100644 --- a/packages/hono/AGENTS.md +++ b/packages/hono/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/hono` types before changing auth code. If a symbol is - Verify webhooks with `verifyWebhook(c)` from `@clerk/hono/webhooks`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/hono` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/nextjs/AGENTS.md b/packages/nextjs/AGENTS.md index fb2f8b863c6..1cf65db099b 100644 --- a/packages/nextjs/AGENTS.md +++ b/packages/nextjs/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/nextjs` types before changing auth code. If a symbol - `useSignIn()` and `useSignUp()` return `{ signIn | signUp, errors, fetchStatus }`. The old `{ isLoaded, signIn, setActive }` shape lives in `@clerk/nextjs/legacy`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/nextjs` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/nuxt/AGENTS.md b/packages/nuxt/AGENTS.md index 0d3c32c6da2..c59cc5a0ae5 100644 --- a/packages/nuxt/AGENTS.md +++ b/packages/nuxt/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/nuxt` types before changing auth code. If a symbol is - ``, ``, ``, ``, ``, and `` default to `path` routing. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/nuxt` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/react-router/AGENTS.md b/packages/react-router/AGENTS.md index a19588187b4..5f1b2c99e67 100644 --- a/packages/react-router/AGENTS.md +++ b/packages/react-router/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/react-router` types before changing auth code. If a s - ``, ``, and `` were removed. Use `` or ``. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/react-router` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md index 40355992e3f..4a1e864693e 100644 --- a/packages/react/AGENTS.md +++ b/packages/react/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/react` types before changing auth code. If a symbol i - `appearance.layout` was renamed to `appearance.options`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/react` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index a359eb99fa5..5d3c3ae944f 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { - CLERK_SDK_MCP_END, - CLERK_SDK_MCP_START, + CLERK_SDK_PLUGIN_END, + CLERK_SDK_PLUGIN_START, type FileSystemAdapter, renderClerkAgentRulesBlock, - renderClerkSdkMcpBlock, + renderClerkSdkPluginBlock, writeClerkAgentRules, } from '../agentRules'; @@ -120,26 +120,28 @@ describe('writeClerkAgentRules', () => { }); }); -describe('Clerk MCP rules', () => { - it('adds the MCP intro and the ask-before-changes rule to the root block', () => { +describe('Clerk plugin rules', () => { + it('recommends the Clerk plugin and keeps the ask-before-changes rule in the root block', () => { const block = renderClerkAgentRulesBlock(); - expect(block).toContain('### Clerk MCP server'); - expect(block).toContain('https://mcp.clerk.com/mcp'); - expect(block).toContain('https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server'); - expect(block).toContain('Ask the user before calling any Clerk MCP tool'); + expect(block).toContain('### Clerk plugin'); + expect(block).toContain('npx plugins add clerk/skills'); + expect(block).toContain('https://clerk.com/docs/guides/ai/overview'); + expect(block).toContain('https://github.com/clerk/skills'); + expect(block).toContain('Ask the user before calling any Clerk tool'); + expect(block).not.toContain('mcp.clerk.com'); }); - it('renders a marked per-SDK MCP block that names the installed package', () => { - const block = renderClerkSdkMcpBlock('nextjs'); + it('renders a marked per-SDK plugin block with the docs entry point', () => { + const block = renderClerkSdkPluginBlock(); - expect(block.startsWith(CLERK_SDK_MCP_START)).toBe(true); - expect(block.endsWith(CLERK_SDK_MCP_END)).toBe(true); - expect(block).toContain('installed `@clerk/nextjs` types'); + expect(block.startsWith(CLERK_SDK_PLUGIN_START)).toBe(true); + expect(block.endsWith(CLERK_SDK_PLUGIN_END)).toBe(true); + expect(block).toContain('npx plugins add clerk/skills'); expect(block).toContain('https://clerk.com/docs/llms.txt'); }); it('uses the requested line endings', () => { - expect(renderClerkSdkMcpBlock('react', '\r\n')).not.toMatch(/[^\r]\n/); + expect(renderClerkSdkPluginBlock('\r\n')).not.toMatch(/[^\r]\n/); }); }); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 18f63312abe..6a5abc19b13 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -77,36 +77,33 @@ export const isClerkAgentRulesSdk = (value: string): value is ClerkAgentRulesSdk export const clerkAgentRulesSdks = Object.keys(SDK_LABELS) as ClerkAgentRulesSdk[]; -export const CLERK_SDK_MCP_START = ''; -export const CLERK_SDK_MCP_END = ''; +export const CLERK_SDK_PLUGIN_START = ''; +export const CLERK_SDK_PLUGIN_END = ''; -const MCP_INTRO = `### Clerk MCP server +const PLUGIN_SECTION = `### Clerk plugin -Use this section when a \`clerk\` MCP server (\`https://mcp.clerk.com/mcp\`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server`; +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -const MCP_ASK_BEFORE_CHANGES = - 'Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account.'; +\`\`\`sh +npx plugins add clerk/skills +\`\`\` -export function renderClerkSdkMcpBlock(sdk: ClerkAgentRulesSdk, eol = '\n') { - return normalizeEol( - `${CLERK_SDK_MCP_START} +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills -${MCP_INTRO} +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account.`; -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +export function renderClerkSdkPluginBlock(eol = '\n') { + return normalizeEol( + `${CLERK_SDK_PLUGIN_START} -- Get Clerk code from the SDK snippet tools (\`list_clerk_sdk_snippets\`, \`clerk_sdk_snippet\`) instead of writing it from memory. -- Check every import in a snippet against the installed \`@clerk/${sdk}\` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- ${MCP_ASK_BEFORE_CHANGES} Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +${PLUGIN_SECTION} ### Docs Start at https://clerk.com/docs/llms.txt. Add \`.md\` to any docs URL to get Markdown. -${CLERK_SDK_MCP_END}`, +${CLERK_SDK_PLUGIN_END}`, eol, ); } @@ -128,9 +125,7 @@ This block is written by the Clerk SDK when a supported dev server starts, or by \`npx @clerk/upgrade --agents-md\`. Removing it from a diff can recreate the uncommitted change; commit it with your work. -${MCP_INTRO} - -${MCP_ASK_BEFORE_CHANGES} +${PLUGIN_SECTION} ${GENERIC_END}`, eol, diff --git a/packages/tanstack-react-start/AGENTS.md b/packages/tanstack-react-start/AGENTS.md index 43e9b25ec0f..2a8a640b07a 100644 --- a/packages/tanstack-react-start/AGENTS.md +++ b/packages/tanstack-react-start/AGENTS.md @@ -11,23 +11,23 @@ Read the installed `@clerk/tanstack-react-start` types before changing auth code - `useSignIn()` and `useSignUp()` return `{ signIn | signUp, errors, fetchStatus }`. The old shape lives in `@clerk/tanstack-react-start/legacy`. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/tanstack-react-start` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/packages/vue/AGENTS.md b/packages/vue/AGENTS.md index dc32ddd818a..19ac8c70c32 100644 --- a/packages/vue/AGENTS.md +++ b/packages/vue/AGENTS.md @@ -10,23 +10,23 @@ Read the installed `@clerk/vue` types before changing auth code. If a symbol is - `getToken()` throws `ClerkOfflineError` (from `@clerk/vue/errors`) when offline. It still returns `null` when signed out. - Clerk Elements is removed. Never use it. - + -### Clerk MCP server +### Clerk plugin -Use this section when a `clerk` MCP server (`https://mcp.clerk.com/mcp`) is connected. If it is not connected, tell the user they can add it: -https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server +The Clerk plugin gives agents Clerk's skills and MCP server. If it is not installed, tell the user they can install it. Do not run the command yourself: -The server lists its own tools and describes what each one does. Follow these rules on top of those descriptions: +```sh +npx plugins add clerk/skills +``` -- Get Clerk code from the SDK snippet tools (`list_clerk_sdk_snippets`, `clerk_sdk_snippet`) instead of writing it from memory. -- Check every import in a snippet against the installed `@clerk/vue` types. If they disagree, the installed types win. -- Read the current application and instance config before you change anything. -- Ask the user before calling any Clerk MCP tool that creates or changes something in their Clerk account. Run config changes as a dry run first and show the user the result. -- The server never returns secret keys. Do not ask the user to paste one into chat. Point them to https://dashboard.clerk.com/~/api-keys. +Install steps for each tool: https://clerk.com/docs/guides/ai/overview +Source: https://github.com/clerk/skills + +Ask the user before calling any Clerk tool that creates or changes something in their Clerk account. ### Docs Start at https://clerk.com/docs/llms.txt. Add `.md` to any docs URL to get Markdown. - + diff --git a/scripts/sync-agents-md.mjs b/scripts/sync-agents-md.mjs index c8d940f55c1..2e04cd19798 100644 --- a/scripts/sync-agents-md.mjs +++ b/scripts/sync-agents-md.mjs @@ -4,10 +4,10 @@ import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { - CLERK_SDK_MCP_END, - CLERK_SDK_MCP_START, + CLERK_SDK_PLUGIN_END, + CLERK_SDK_PLUGIN_START, clerkAgentRulesSdks, - renderClerkSdkMcpBlock, + renderClerkSdkPluginBlock, } from '../packages/shared/src/agentRules/index.ts'; const check = process.argv.includes('--check'); @@ -23,15 +23,15 @@ for (const sdk of clerkAgentRulesSdks) { continue; } - const start = content.indexOf(CLERK_SDK_MCP_START); - const end = content.indexOf(CLERK_SDK_MCP_END, start); + const start = content.indexOf(CLERK_SDK_PLUGIN_START); + const end = content.indexOf(CLERK_SDK_PLUGIN_END, start); if (start === -1 || end === -1) { - problems.push(`${relative} has no ${CLERK_SDK_MCP_START} ... ${CLERK_SDK_MCP_END} markers`); + problems.push(`${relative} has no ${CLERK_SDK_PLUGIN_START} ... ${CLERK_SDK_PLUGIN_END} markers`); continue; } - const next = content.slice(0, start) + renderClerkSdkMcpBlock(sdk) + content.slice(end + CLERK_SDK_MCP_END.length); + const next = content.slice(0, start) + renderClerkSdkPluginBlock() + content.slice(end + CLERK_SDK_PLUGIN_END.length); if (next === content) { continue; @@ -48,7 +48,7 @@ for (const sdk of clerkAgentRulesSdks) { if (problems.length > 0) { console.error(problems.join('\n')); if (check) { - console.error('Run `pnpm agents-md:sync` to update the Clerk MCP section in each SDK AGENTS.md.'); + console.error('Run `pnpm agents-md:sync` to update the Clerk plugin section in each SDK AGENTS.md.'); } process.exit(1); } From f5415dc37de7abc5dc672469c7c1d57ec32a6402 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:09:48 -0400 Subject: [PATCH 07/31] fix(shared): import AGENTS.md from an existing CLAUDE.md Co-Authored-By: Claude Opus 5.5 --- .changeset/write-agents-md-in-dev.md | 2 +- .../shared/src/__tests__/agentRules.spec.ts | 93 +++++++++++++++++++ packages/shared/src/agentRules/index.ts | 30 +++++- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index eebff89a268..52672b11693 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -6,4 +6,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development under an AI coding agent (such as Claude Code, Cursor, or GitHub Copilot), Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed, plus a `CLAUDE.md` that imports it when none exists. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production or CI. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. +When your app runs in development under an AI coding agent (such as Claude Code, Cursor, or GitHub Copilot), Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production or CI. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 5d3c3ae944f..91fd6393d67 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -120,6 +120,99 @@ describe('writeClerkAgentRules', () => { }); }); +describe('writeClerkAgentRules with an existing CLAUDE.md', () => { + it('adds an @AGENTS.md import when CLAUDE.md does not load AGENTS.md', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Agent rules\n', + '/repo/CLAUDE.md': 'Claude rules\n', + }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.claudeMd).toBe('updated'); + expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\n\n@AGENTS.md\n'); + expect(memory.read('/repo/AGENTS.md')).toContain(''); + }); + + it('adds the import only once across runs', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Agent rules\n', + '/repo/CLAUDE.md': 'Claude rules\n', + }); + + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.claudeMd).toBe('unchanged'); + expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\n\n@AGENTS.md\n'); + }); + + it.each(['@AGENTS.md', '@./AGENTS.md'])('leaves CLAUDE.md alone when it already imports %s', importLine => { + const claude = `Claude rules\n\n${importLine}\n`; + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Agent rules\n', + '/repo/CLAUDE.md': claude, + }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.claudeMd).toBe('unchanged'); + expect(memory.read('/repo/CLAUDE.md')).toBe(claude); + }); + + it('leaves CLAUDE.md alone when it mirrors AGENTS.md, as a symlink does', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Shared rules\n', + '/repo/CLAUDE.md': 'Shared rules\n', + }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.claudeMd).toBe('unchanged'); + expect(memory.read('/repo/CLAUDE.md')).toBe('Shared rules\n'); + }); + + it('writes the Clerk block into CLAUDE.md and skips the import when only CLAUDE.md exists', () => { + const memory = createMemoryFs({ '/repo/CLAUDE.md': 'Claude rules\n' }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.agentsMd).toBe('updated'); + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + expect(memory.read('/repo/CLAUDE.md')).toContain(''); + expect(memory.read('/repo/CLAUDE.md')).not.toContain('@AGENTS.md'); + }); + + it('keeps CRLF line endings in CLAUDE.md', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Agent rules\r\n', + '/repo/CLAUDE.md': 'Claude rules\r\n', + }); + + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\r\n\r\n@AGENTS.md\r\n'); + }); + + it('reports the import but does not write it in dry-run mode', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Agent rules\n', + '/repo/CLAUDE.md': 'Claude rules\n', + }); + + const result = writeClerkAgentRules({ + cwd: '/repo', + dryRun: true, + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + expect(result.claudeMd).toBe('updated'); + expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\n'); + }); +}); + describe('Clerk plugin rules', () => { it('recommends the Clerk plugin and keeps the ask-before-changes rule in the root block', () => { const block = renderClerkAgentRulesBlock(); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 6a5abc19b13..84bdd06b5f5 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -40,7 +40,7 @@ export interface WriteClerkAgentRulesOptions { export type WriteClerkAgentRulesResult = { targetDir: string; agentsMd: 'created' | 'updated' | 'unchanged'; - claudeMd: 'created' | 'unchanged' | 'skipped'; + claudeMd: 'created' | 'updated' | 'unchanged' | 'skipped'; }; const GENERIC_START = ''; @@ -73,6 +73,13 @@ const uniqueSdks = (sdks: ClerkAgentRulesSdk[]) => [...new Set(sdks)].sort(); const detectEol = (content: string) => (content.includes('\r\n') ? '\r\n' : '\n'); const normalizeEol = (content: string, eol: string) => content.replace(/\r?\n/g, eol); +const CLAUDE_AGENTS_IMPORT = /^\s*@(?:\.\/)?AGENTS\.md\s*$/m; + +const appendAgentsImport = (content: string) => { + const eol = detectEol(content); + return content.trim() ? `${content.trimEnd()}${eol}${eol}@AGENTS.md${eol}` : `@AGENTS.md${eol}`; +}; + export const isClerkAgentRulesSdk = (value: string): value is ClerkAgentRulesSdk => value in SDK_LABELS; export const clerkAgentRulesSdks = Object.keys(SDK_LABELS) as ClerkAgentRulesSdk[]; @@ -248,7 +255,15 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ } const targetStatus = fs.existsSync(targetFile) ? (next === existing ? 'unchanged' : 'updated') : 'created'; - const shouldCreateClaude = targetFile === agentsPath && !fs.existsSync(claudePath); + const claudeExists = fs.existsSync(claudePath); + const shouldCreateClaude = targetFile === agentsPath && !claudeExists; + const claudeContent = + targetFile === agentsPath && claudeExists ? fs.readFileSync(claudePath, { encoding: 'utf8' }) : null; + const shouldImportAgentsInClaude = + claudeContent !== null && + claudeContent !== existing && + !claudeContent.includes(GENERIC_START) && + !CLAUDE_AGENTS_IMPORT.test(claudeContent); if (!dryRun) { fs.mkdirSync?.(targetDir, { recursive: true }); @@ -258,11 +273,20 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ if (shouldCreateClaude) { fs.writeFileSync(claudePath, '@AGENTS.md\n', { encoding: 'utf8', mode: 0o644 }); } + if (shouldImportAgentsInClaude && claudeContent !== null) { + fs.writeFileSync(claudePath, appendAgentsImport(claudeContent), { encoding: 'utf8', mode: 0o644 }); + } } return { targetDir, agentsMd: targetStatus, - claudeMd: shouldCreateClaude ? 'created' : fs.existsSync(claudePath) ? 'unchanged' : 'skipped', + claudeMd: shouldCreateClaude + ? 'created' + : shouldImportAgentsInClaude + ? 'updated' + : claudeExists + ? 'unchanged' + : 'skipped', }; } From be256c36969cf2b0e2bcc20614ccabc22b6e073f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:29:11 -0400 Subject: [PATCH 08/31] refactor(shared): shorten the AGENTS.md intro and link the Markdown docs Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/__tests__/agentRules.spec.ts | 4 ++++ packages/shared/src/agentRules/index.ts | 11 ++--------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 91fd6393d67..8f8df657580 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -237,4 +237,8 @@ describe('Clerk plugin rules', () => { it('uses the requested line endings', () => { expect(renderClerkSdkPluginBlock('\r\n')).not.toMatch(/[^\r]\n/); }); + + it('links agents to the Markdown Clerk docs in the root block', () => { + expect(renderClerkAgentRulesBlock()).toContain('Clerk docs: https://clerk.com/docs.md'); + }); }); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 84bdd06b5f5..72383863458 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -121,16 +121,9 @@ export function renderClerkAgentRulesBlock(eol = '\n') { ## Clerk: your training data is out of date -This project uses Clerk. The installed SDKs are newer than your training data. APIs, -component names, and file conventions may have changed. Read the Clerk SDK section(s) -below before writing or editing any auth code. +Read the Clerk sections below before touching auth code. Only use Clerk APIs that exist in the installed package's types. Clerk docs: https://clerk.com/docs.md -Never recall a Clerk API from memory. If a symbol is not in the installed package's -types, it does not exist. - -This block is written by the Clerk SDK when a supported dev server starts, or by -\`npx @clerk/upgrade --agents-md\`. Removing it from a diff can recreate the uncommitted -change; commit it with your work. +The Clerk SDK writes this block. Commit it with your work. ${PLUGIN_SECTION} From f029953f1be5d99c35be3a6523cdd5e1bf99910f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:50:04 -0400 Subject: [PATCH 09/31] fix(*): address review feedback on Clerk agent rules Co-Authored-By: Claude Opus 5.5 --- packages/expo/app.plugin.js | 39 +--------- .../__tests__/appPlugin.agentRules.test.js | 74 +++++++++++++++++++ .../shared/src/__tests__/agentRules.spec.ts | 30 ++++++++ .../src/__tests__/runtimeEnvironment.spec.ts | 32 ++++++++ packages/shared/src/agentRules/index.ts | 4 + .../shared/src/utils/runtimeEnvironment.ts | 5 +- .../__tests__/integration/agent-rules.test.js | 38 ++++++++++ .../src/__tests__/integration/cli.test.js | 21 ++++++ packages/upgrade/src/cli.js | 15 ++-- packages/upgrade/src/util/agent-rules.js | 2 +- 10 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 packages/expo/src/__tests__/appPlugin.agentRules.test.js create mode 100644 packages/upgrade/src/__tests__/integration/agent-rules.test.js diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 310e90e1edf..80cffc3dc56 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -7,6 +7,7 @@ * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs */ const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins'); +const { isAgentEnvironment, isAutomatedEnvironment, isProductionEnvironment } = require('@clerk/shared/utils'); const fs = require('fs'); const path = require('path'); @@ -15,43 +16,8 @@ const CLERK_EXPO_NATIVE = '@clerk/expo-native-components'; const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme']; let didWriteAgentRules = false; -const isTruthyEnvValue = value => typeof value === 'string' && !!value && !['0', 'false', 'off', 'no'].includes(value); - -const isAgentEnvironment = () => - [ - 'CLAUDECODE', - 'CURSOR_AGENT', - 'CURSOR_CLI', - 'CURSOR_EXTENSION_HOST_ROLE', - 'CURSOR_TRACE_ID', - 'GITHUB_COPILOT_AGENT', - 'GITHUB_COPILOT_CHAT', - 'AGENT', - 'AGENTS', - ].some(name => isTruthyEnvValue(process.env[name])); - -const isAutomatedEnvironment = () => - [ - 'CI', - 'CONTINUOUS_INTEGRATION', - 'GITHUB_ACTIONS', - 'GITLAB_CI', - 'CIRCLECI', - 'TRAVIS', - 'BUILDKITE', - 'BITBUCKET_BUILD_NUMBER', - 'APPVEYOR', - 'CODEBUILD_BUILD_ID', - 'TF_BUILD', - 'TEAMCITY_VERSION', - 'JENKINS_URL', - 'HUDSON_URL', - 'BAMBOO_BUILDKEY', - 'CF_PAGES', - ].some(name => isTruthyEnvValue(process.env[name])); - const maybeWriteAgentRules = config => { - if (didWriteAgentRules || isAutomatedEnvironment() || !isAgentEnvironment()) { + if (didWriteAgentRules || isProductionEnvironment() || isAutomatedEnvironment() || !isAgentEnvironment()) { return; } @@ -211,6 +177,7 @@ const withClerkExpo = (config, props = {}) => { module.exports = withClerkExpo; module.exports._testing = { addHostedAuthIntentFilter, + maybeWriteAgentRules, withClerkExpoNativeComponents, withClerkFaceIDPermission, }; diff --git a/packages/expo/src/__tests__/appPlugin.agentRules.test.js b/packages/expo/src/__tests__/appPlugin.agentRules.test.js new file mode 100644 index 00000000000..29f4a2af242 --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.agentRules.test.js @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const nodeRequire = createRequire(import.meta.url); +const pluginPath = nodeRequire.resolve('../../app.plugin.js'); + +const loadMaybeWriteAgentRules = () => { + delete nodeRequire.cache[pluginPath]; + return nodeRequire(pluginPath)._testing.maybeWriteAgentRules; +}; + +describe('maybeWriteAgentRules', () => { + let projectRoot; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-agent-rules-')); + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); + for (const name of [...agentEnvironmentVariables, ...automatedEnvironmentVariables]) { + vi.stubEnv(name, ''); + } + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + const agentsMdPath = () => path.join(projectRoot, 'AGENTS.md'); + const config = () => ({ _internal: { projectRoot } }); + + test('writes the Expo rules once per process under an AI agent', () => { + vi.stubEnv('CLAUDECODE', '1'); + const maybeWriteAgentRules = loadMaybeWriteAgentRules(); + + maybeWriteAgentRules(config()); + expect(fs.readFileSync(agentsMdPath(), 'utf8')).toContain(''); + + fs.rmSync(agentsMdPath()); + maybeWriteAgentRules(config()); + expect(fs.existsSync(agentsMdPath())).toBe(false); + }); + + test('skips when the agent variable is set to a false value', () => { + vi.stubEnv('CLAUDECODE', 'False'); + + loadMaybeWriteAgentRules()(config()); + + expect(fs.existsSync(agentsMdPath())).toBe(false); + }); + + test('skips in production', () => { + vi.stubEnv('CLAUDECODE', '1'); + vi.stubEnv('NODE_ENV', 'production'); + + loadMaybeWriteAgentRules()(config()); + + expect(fs.existsSync(agentsMdPath())).toBe(false); + }); + + test('skips in CI', () => { + vi.stubEnv('CLAUDECODE', '1'); + vi.stubEnv('CI', 'true'); + + loadMaybeWriteAgentRules()(config()); + + expect(fs.existsSync(agentsMdPath())).toBe(false); + }); +}); diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 8f8df657580..53239de8d19 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -120,6 +120,36 @@ describe('writeClerkAgentRules', () => { }); }); +describe('writeClerkAgentRules with an unmatched BEGIN marker', () => { + it('keeps text after an orphan shared BEGIN marker across runs', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': 'Team rules\n\n\nold\n\n## My notes\nKeep this.\n', + }); + + for (let run = 0; run < 2; run++) { + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + const content = memory.read('/repo/AGENTS.md'); + expect(content).toContain('Keep this.'); + expect(content?.match(//g)).toHaveLength(1); + } + }); + + it('keeps text after an orphan SDK BEGIN marker across runs', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': '\nold\n\n## My notes\nKeep this.\n', + }); + + for (let run = 0; run < 2; run++) { + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + const content = memory.read('/repo/AGENTS.md'); + expect(content).toContain('Keep this.'); + expect(content?.match(//g)).toHaveLength(1); + } + }); +}); + describe('writeClerkAgentRules with an existing CLAUDE.md', () => { it('adds an @AGENTS.md import when CLAUDE.md does not load AGENTS.md', () => { const memory = createMemoryFs({ diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index 52cfe180eed..ee171ca8ec5 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -95,3 +95,35 @@ describe('isAgentEnvironment', () => { expect(isAgentEnvironment()).toBe(false); }); }); + +describe('isAgentEnvironment with Cursor', () => { + beforeEach(() => { + agentEnvironmentVariables.forEach(name => { + vi.stubEnv(name, undefined); + vi.stubGlobal(name, undefined); + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('returns true when CURSOR_EXTENSION_HOST_ROLE is agent-exec', () => { + vi.stubEnv('CURSOR_EXTENSION_HOST_ROLE', 'agent-exec'); + + expect(isAgentEnvironment()).toBe(true); + }); + + it('returns false for any other CURSOR_EXTENSION_HOST_ROLE value', () => { + vi.stubEnv('CURSOR_EXTENSION_HOST_ROLE', 'user'); + + expect(isAgentEnvironment()).toBe(false); + }); + + it('returns true when CURSOR_TRACE_ID is set', () => { + vi.stubEnv('CURSOR_TRACE_ID', 'abc123'); + + expect(isAgentEnvironment()).toBe(true); + }); +}); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 72383863458..babbad83dbd 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -166,6 +166,10 @@ function upsertMarkedBlock( return content.slice(0, startIndex) + block + content.slice(endIndex + end.length); } + if (startIndex !== -1) { + return content; + } + if (insertAfter) { const insertIndex = content.indexOf(insertAfter); if (insertIndex !== -1) { diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index 506f28e6abe..846a37dc5ec 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -75,5 +75,8 @@ export const isAutomatedEnvironment = (): boolean => { }; export const isAgentEnvironment = (): boolean => { - return agentEnvironmentVariables.some(name => isTruthyEnvValue(getEnvVariable(name))); + return agentEnvironmentVariables.some(name => { + const value = getEnvVariable(name); + return name === 'CURSOR_EXTENSION_HOST_ROLE' ? value === 'agent-exec' : isTruthyEnvValue(value); + }); }; diff --git a/packages/upgrade/src/__tests__/integration/agent-rules.test.js b/packages/upgrade/src/__tests__/integration/agent-rules.test.js new file mode 100644 index 00000000000..fb91dc9e927 --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/agent-rules.test.js @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { detectClerkAgentRuleSdks } from '../../util/agent-rules.js'; + +describe('detectClerkAgentRuleSdks', () => { + let root; + + const writePackage = (dir, pkg) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-upgrade-agent-rules-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('includes SDKs declared in the workspace root package.json', () => { + writePackage(root, { name: 'app', workspaces: ['packages/*'], dependencies: { '@clerk/nextjs': '^7.0.0' } }); + writePackage(path.join(root, 'packages', 'ui'), { name: 'ui', dependencies: { react: '^19.0.0' } }); + + expect(detectClerkAgentRuleSdks(root)).toEqual(['nextjs']); + }); + + it('combines SDKs from the workspace root and its packages', () => { + writePackage(root, { name: 'app', workspaces: ['packages/*'], dependencies: { '@clerk/nextjs': '^7.0.0' } }); + writePackage(path.join(root, 'packages', 'api'), { name: 'api', dependencies: { '@clerk/express': '^2.0.0' } }); + + expect(detectClerkAgentRuleSdks(path.join(root, 'packages', 'api'))).toEqual(['express', 'nextjs']); + }); +}); diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index 98b96e8be5a..11c714def5a 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -208,6 +208,27 @@ describe('CLI Integration', () => { }); }); + describe('--agents-md flag without a Clerk SDK', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('no-clerk'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('exits with a failure status and writes nothing', async () => { + const fs = await import('node:fs'); + const result = await runCli(['--dir', fixture.path, '--agents-md'], { timeout: 15000 }); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('No Clerk SDK packages found for AGENTS.md'); + expect(fs.existsSync(path.join(fixture.path, 'AGENTS.md'))).toBe(false); + }); + }); + describe('Version Display', () => { it('shows current version in output', async () => { const dir = getFixturePath('nextjs-v6'); diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 57b089fc8de..00d76fea1c3 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -246,13 +246,6 @@ async function main() { console.log(''); - if (isInteractive) { - const sdks = detectClerkAgentRuleSdks(options.dir); - if (sdks.length > 0 && (await promptConfirm('Write Clerk AGENTS.md sections?', true))) { - writeAgentsMd(options, sdks); - } - } - // Step 6: Handle upgrade status if (options.skipUpgrade) { renderText('Skipping package upgrade (--skip-upgrade flag)', 'yellow'); @@ -268,6 +261,13 @@ async function main() { await performPackageReplacements(packageManager, config, options); } + if (isInteractive) { + const sdks = detectClerkAgentRuleSdks(options.dir); + if (sdks.length > 0 && (await promptConfirm('Write Clerk AGENTS.md sections?', true))) { + writeAgentsMd(options, sdks); + } + } + // Step 7: Run codemods if (config.codemods?.length > 0) { renderText(`Running ${config.codemods.length} codemod(s)...`, 'blue'); @@ -292,6 +292,7 @@ function writeAgentsMd(options, detectedSdks) { if (sdks.length === 0) { renderWarning('No Clerk SDK packages found for AGENTS.md'); + process.exitCode = 1; return; } diff --git a/packages/upgrade/src/util/agent-rules.js b/packages/upgrade/src/util/agent-rules.js index 35f96739000..f515080530b 100644 --- a/packages/upgrade/src/util/agent-rules.js +++ b/packages/upgrade/src/util/agent-rules.js @@ -7,7 +7,7 @@ import { findWorkspaceRoot, getInstalledClerkPackages, getWorkspacePackageDirs } export function detectClerkAgentRuleSdks(dir) { const workspaceRoot = findWorkspaceRoot(dir); - const packageDirs = workspaceRoot ? getWorkspacePackageDirs(workspaceRoot) : [dir]; + const packageDirs = workspaceRoot ? [workspaceRoot, ...getWorkspacePackageDirs(workspaceRoot)] : [dir]; const sdks = new Set(); for (const packageDir of packageDirs) { From 1afb3e9a50136a1e6fcf7866148dc57a3e19d568 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 14:59:06 -0400 Subject: [PATCH 10/31] feat(*): write Clerk agent rules on every dev run, not only under an agent Co-Authored-By: Claude Opus 5.5 --- .changeset/react-vue-agent-rules-vite.md | 2 +- .changeset/shared-agent-rules.md | 2 +- .changeset/write-agents-md-in-dev.md | 2 +- .../src/integration/create-integration.ts | 4 +- packages/expo/app.plugin.js | 7 +-- .../__tests__/appPlugin.agentRules.test.js | 14 +++--- .../server/__tests__/agent-rules-node.test.ts | 30 +++++------ .../nextjs/src/server/agent-rules-node.ts | 4 +- packages/nuxt/src/module.ts | 4 +- .../src/__tests__/runtimeEnvironment.spec.ts | 43 ++++++++++++++++ packages/shared/src/__tests__/vite.spec.ts | 50 +++++++++++++++++++ .../shared/src/utils/runtimeEnvironment.ts | 5 ++ packages/shared/src/vite.ts | 4 +- .../src/server/__tests__/agentRules.test.ts | 30 +++++------ .../src/server/agentRules.ts | 4 +- 15 files changed, 151 insertions(+), 54 deletions(-) create mode 100644 packages/shared/src/__tests__/vite.spec.ts diff --git a/.changeset/react-vue-agent-rules-vite.md b/.changeset/react-vue-agent-rules-vite.md index a153d58b062..a85f7531d12 100644 --- a/.changeset/react-vue-agent-rules-vite.md +++ b/.changeset/react-vue-agent-rules-vite.md @@ -3,7 +3,7 @@ '@clerk/vue': minor --- -Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server runs under an AI coding agent. Nothing is written during builds or in CI. +Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server starts. Nothing is written during builds or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. ```ts import { clerkReactAgentRules } from '@clerk/react/vite'; diff --git a/.changeset/shared-agent-rules.md b/.changeset/shared-agent-rules.md index 886015e3be2..9821115b54b 100644 --- a/.changeset/shared-agent-rules.md +++ b/.changeset/shared-agent-rules.md @@ -2,4 +2,4 @@ '@clerk/shared': minor --- -Add `@clerk/shared/agentRules` and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`, and `isAgentEnvironment()` to `@clerk/shared/utils` for detecting AI coding agents. +Add `@clerk/shared/agentRules` and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` in production, in CI, or when `CLERK_AGENTS_MD=false`, and `isAgentEnvironment()` for detecting AI coding agents. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index 52672b11693..e67f99f100f 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -6,4 +6,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development under an AI coding agent (such as Claude Code, Cursor, or GitHub Copilot), Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production or CI. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. +When your app runs in development, Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production or CI, and setting `CLERK_AGENTS_MD=false` turns it off. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index 944cb3acaba..aaaee763693 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { writeClerkAgentRules } from '@clerk/shared/agentRules'; import type { ClerkOptions } from '@clerk/shared/types'; -import { isAgentEnvironment, isAutomatedEnvironment } from '@clerk/shared/utils'; +import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; import type { AstroIntegration } from 'astro'; import { envField } from 'astro/config'; @@ -39,7 +39,7 @@ function createIntegration() name: '@clerk/astro/integration', hooks: { 'astro:config:setup': ({ config, injectScript, updateConfig, logger, command }) => { - if (command === 'dev' && !didWriteAgentRules && !isAutomatedEnvironment() && isAgentEnvironment()) { + if (command === 'dev' && !didWriteAgentRules && isClerkAgentRulesWriteAllowed()) { didWriteAgentRules = true; try { writeClerkAgentRules({ cwd: fileURLToPath(config.root), fs, path, sdks: ['astro'] }); diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 80cffc3dc56..de29d46eaa2 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -7,7 +7,8 @@ * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs */ const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins'); -const { isAgentEnvironment, isAutomatedEnvironment, isProductionEnvironment } = require('@clerk/shared/utils'); +const { writeClerkAgentRules } = require('@clerk/shared/agentRules'); +const { isClerkAgentRulesWriteAllowed } = require('@clerk/shared/utils'); const fs = require('fs'); const path = require('path'); @@ -17,7 +18,7 @@ const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme']; let didWriteAgentRules = false; const maybeWriteAgentRules = config => { - if (didWriteAgentRules || isProductionEnvironment() || isAutomatedEnvironment() || !isAgentEnvironment()) { + if (didWriteAgentRules || !isClerkAgentRulesWriteAllowed()) { return; } @@ -29,7 +30,7 @@ const maybeWriteAgentRules = config => { didWriteAgentRules = true; try { - require('@clerk/shared/agentRules').writeClerkAgentRules({ cwd: projectRoot, fs, path, sdks: ['expo'] }); + writeClerkAgentRules({ cwd: projectRoot, fs, path, sdks: ['expo'] }); } catch { // Writing agent hints must never break Expo config evaluation. } diff --git a/packages/expo/src/__tests__/appPlugin.agentRules.test.js b/packages/expo/src/__tests__/appPlugin.agentRules.test.js index 29f4a2af242..90ee8c0cfaa 100644 --- a/packages/expo/src/__tests__/appPlugin.agentRules.test.js +++ b/packages/expo/src/__tests__/appPlugin.agentRules.test.js @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; -import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; const nodeRequire = createRequire(import.meta.url); @@ -20,9 +20,10 @@ describe('maybeWriteAgentRules', () => { beforeEach(() => { projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-agent-rules-')); fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); - for (const name of [...agentEnvironmentVariables, ...automatedEnvironmentVariables]) { + for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } + vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); }); @@ -34,8 +35,7 @@ describe('maybeWriteAgentRules', () => { const agentsMdPath = () => path.join(projectRoot, 'AGENTS.md'); const config = () => ({ _internal: { projectRoot } }); - test('writes the Expo rules once per process under an AI agent', () => { - vi.stubEnv('CLAUDECODE', '1'); + test('writes the Expo rules once per process in development, with or without an AI agent', () => { const maybeWriteAgentRules = loadMaybeWriteAgentRules(); maybeWriteAgentRules(config()); @@ -46,8 +46,8 @@ describe('maybeWriteAgentRules', () => { expect(fs.existsSync(agentsMdPath())).toBe(false); }); - test('skips when the agent variable is set to a false value', () => { - vi.stubEnv('CLAUDECODE', 'False'); + test('skips when CLERK_AGENTS_MD is false', () => { + vi.stubEnv('CLERK_AGENTS_MD', 'False'); loadMaybeWriteAgentRules()(config()); @@ -55,7 +55,6 @@ describe('maybeWriteAgentRules', () => { }); test('skips in production', () => { - vi.stubEnv('CLAUDECODE', '1'); vi.stubEnv('NODE_ENV', 'production'); loadMaybeWriteAgentRules()(config()); @@ -64,7 +63,6 @@ describe('maybeWriteAgentRules', () => { }); test('skips in CI', () => { - vi.stubEnv('CLAUDECODE', '1'); vi.stubEnv('CI', 'true'); loadMaybeWriteAgentRules()(config()); diff --git a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts index f49b1854b67..ead72cbbe4a 100644 --- a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts +++ b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts @@ -1,12 +1,10 @@ import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { isAgentEnvironment, isAutomatedEnvironment } from '@clerk/shared/utils'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@clerk/shared/agentRules', () => ({ writeClerkAgentRules: vi.fn() })); -vi.mock('@clerk/shared/utils', async importOriginal => ({ - ...(await importOriginal()), - isAgentEnvironment: vi.fn(), - isAutomatedEnvironment: vi.fn(), +vi.mock('@clerk/shared/agentRules', async importOriginal => ({ + ...(await importOriginal()), + writeClerkAgentRules: vi.fn(), })); vi.mock('../fs/utils', () => ({ nodeCwdOrThrow: () => () => '/app', @@ -20,8 +18,10 @@ describe('writeAgentRulesOnce', () => { beforeEach(() => { vi.resetModules(); vi.mocked(writeClerkAgentRules).mockReset(); - vi.mocked(isAgentEnvironment).mockReturnValue(true); - vi.mocked(isAutomatedEnvironment).mockReturnValue(false); + for (const name of automatedEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); }); @@ -29,7 +29,7 @@ describe('writeAgentRulesOnce', () => { vi.unstubAllEnvs(); }); - it('writes the Next.js rules once per process', async () => { + it('writes the rules once per process in development, with or without an AI agent', async () => { const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); writeAgentRulesOnce(); @@ -39,24 +39,24 @@ describe('writeAgentRulesOnce', () => { expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app', sdks: ['nextjs'] })); }); - it('skips outside an agent environment', async () => { - vi.mocked(isAgentEnvironment).mockReturnValue(false); + it('skips in CI', async () => { + vi.stubEnv('CI', 'true'); (await loadWriteAgentRulesOnce())(); expect(writeClerkAgentRules).not.toHaveBeenCalled(); }); - it('skips in CI', async () => { - vi.mocked(isAutomatedEnvironment).mockReturnValue(true); + it('skips in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); (await loadWriteAgentRulesOnce())(); expect(writeClerkAgentRules).not.toHaveBeenCalled(); }); - it('skips in production', async () => { - vi.stubEnv('NODE_ENV', 'production'); + it('skips when CLERK_AGENTS_MD is false', async () => { + vi.stubEnv('CLERK_AGENTS_MD', 'false'); (await loadWriteAgentRulesOnce())(); diff --git a/packages/nextjs/src/server/agent-rules-node.ts b/packages/nextjs/src/server/agent-rules-node.ts index 33e528be6cd..fdbf0a94407 100644 --- a/packages/nextjs/src/server/agent-rules-node.ts +++ b/packages/nextjs/src/server/agent-rules-node.ts @@ -1,12 +1,12 @@ import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { isAgentEnvironment, isAutomatedEnvironment, isProductionEnvironment } from '@clerk/shared/utils'; +import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; let didWrite = false; export function writeAgentRulesOnce() { - if (didWrite || isProductionEnvironment() || isAutomatedEnvironment() || !isAgentEnvironment()) { + if (didWrite || !isClerkAgentRulesWriteAllowed()) { return; } diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index fab8d5b63c6..b0a50e18564 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { writeClerkAgentRules } from '@clerk/shared/agentRules'; import type { InternalClerkScriptProps, Without } from '@clerk/shared/types'; -import { isAgentEnvironment, isAutomatedEnvironment } from '@clerk/shared/utils'; +import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; import type { PluginOptions } from '@clerk/vue'; import { addComponent, @@ -52,7 +52,7 @@ export default defineNuxtModule({ }, }, setup(options, nuxt) { - if (nuxt.options.dev && !didWriteAgentRules && !isAutomatedEnvironment() && isAgentEnvironment()) { + if (nuxt.options.dev && !didWriteAgentRules && isClerkAgentRulesWriteAllowed()) { didWriteAgentRules = true; try { writeClerkAgentRules({ cwd: nuxt.options.rootDir, fs, path, sdks: ['nuxt'] }); diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index ee171ca8ec5..29953924557 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -5,6 +5,7 @@ import { automatedEnvironmentVariables, isAgentEnvironment, isAutomatedEnvironment, + isClerkAgentRulesWriteAllowed, } from '../utils/runtimeEnvironment'; describe('isAutomatedEnvironment', () => { @@ -127,3 +128,45 @@ describe('isAgentEnvironment with Cursor', () => { expect(isAgentEnvironment()).toBe(true); }); }); + +describe('isClerkAgentRulesWriteAllowed', () => { + beforeEach(() => { + for (const name of automatedEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLERK_AGENTS_MD', ''); + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('allows writes in development without an AI agent', () => { + expect(isClerkAgentRulesWriteAllowed()).toBe(true); + }); + + it('blocks writes in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + + expect(isClerkAgentRulesWriteAllowed()).toBe(false); + }); + + it('blocks writes in CI', () => { + vi.stubEnv('CI', 'true'); + + expect(isClerkAgentRulesWriteAllowed()).toBe(false); + }); + + it.each(['false', 'FALSE', '0', 'off', 'no'])('blocks writes when CLERK_AGENTS_MD is %s', value => { + vi.stubEnv('CLERK_AGENTS_MD', value); + + expect(isClerkAgentRulesWriteAllowed()).toBe(false); + }); + + it('allows writes when CLERK_AGENTS_MD is true', () => { + vi.stubEnv('CLERK_AGENTS_MD', 'true'); + + expect(isClerkAgentRulesWriteAllowed()).toBe(true); + }); +}); diff --git a/packages/shared/src/__tests__/vite.spec.ts b/packages/shared/src/__tests__/vite.spec.ts new file mode 100644 index 00000000000..396650506e2 --- /dev/null +++ b/packages/shared/src/__tests__/vite.spec.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as AgentRules from '../agentRules'; +import { writeClerkAgentRules } from '../agentRules'; +import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; +import { clerkAgentRulesVitePlugin } from '../vite'; + +vi.mock('../agentRules', async importOriginal => ({ + ...(await importOriginal()), + writeClerkAgentRules: vi.fn(), +})); + +describe('clerkAgentRulesVitePlugin', () => { + beforeEach(() => { + vi.mocked(writeClerkAgentRules).mockReset(); + for (const name of automatedEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLERK_AGENTS_MD', ''); + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('writes the rules once when the dev server starts', async () => { + const plugin = clerkAgentRulesVitePlugin('react'); + + await plugin.configResolved({ command: 'serve', root: '/app' }); + await plugin.configResolved({ command: 'serve', root: '/app' }); + + expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); + expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app', sdks: ['react'] })); + }); + + it('skips builds', async () => { + await clerkAgentRulesVitePlugin('vue').configResolved({ command: 'build', root: '/app' }); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips when CLERK_AGENTS_MD is false', async () => { + vi.stubEnv('CLERK_AGENTS_MD', 'false'); + + await clerkAgentRulesVitePlugin('react').configResolved({ command: 'serve', root: '/app' }); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index 846a37dc5ec..9ecc700744a 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -80,3 +80,8 @@ export const isAgentEnvironment = (): boolean => { return name === 'CURSOR_EXTENSION_HOST_ROLE' ? value === 'agent-exec' : isTruthyEnvValue(value); }); }; + +export const isClerkAgentRulesWriteAllowed = (): boolean => { + const optOut = getEnvVariable('CLERK_AGENTS_MD').toLowerCase(); + return !['0', 'false', 'off', 'no'].includes(optOut) && !isProductionEnvironment() && !isAutomatedEnvironment(); +}; diff --git a/packages/shared/src/vite.ts b/packages/shared/src/vite.ts index 8a06eb8311d..cd040762314 100644 --- a/packages/shared/src/vite.ts +++ b/packages/shared/src/vite.ts @@ -1,5 +1,5 @@ import { type ClerkAgentRulesSdk, writeClerkAgentRules } from './agentRules'; -import { isAgentEnvironment, isAutomatedEnvironment } from './utils'; +import { isClerkAgentRulesWriteAllowed } from './utils'; type ViteResolvedConfig = { command: 'build' | 'serve'; @@ -13,7 +13,7 @@ export function clerkAgentRulesVitePlugin(sdk: Extract ({ writeClerkAgentRules: vi.fn() })); -vi.mock('@clerk/shared/utils', async importOriginal => ({ - ...(await importOriginal()), - isAgentEnvironment: vi.fn(), - isAutomatedEnvironment: vi.fn(), +vi.mock('@clerk/shared/agentRules', async importOriginal => ({ + ...(await importOriginal()), + writeClerkAgentRules: vi.fn(), })); const loadWriteAgentRulesOnce = async () => (await import('../agentRules')).writeAgentRulesOnce; @@ -15,8 +13,10 @@ describe('writeAgentRulesOnce', () => { beforeEach(() => { vi.resetModules(); vi.mocked(writeClerkAgentRules).mockReset(); - vi.mocked(isAgentEnvironment).mockReturnValue(true); - vi.mocked(isAutomatedEnvironment).mockReturnValue(false); + for (const name of automatedEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); }); @@ -24,7 +24,7 @@ describe('writeAgentRulesOnce', () => { vi.unstubAllEnvs(); }); - it('writes the TanStack React Start rules once per process', async () => { + it('writes the rules once per process in development, with or without an AI agent', async () => { const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); writeAgentRulesOnce(); @@ -36,24 +36,24 @@ describe('writeAgentRulesOnce', () => { ); }); - it('skips outside an agent environment', async () => { - vi.mocked(isAgentEnvironment).mockReturnValue(false); + it('skips in CI', async () => { + vi.stubEnv('CI', 'true'); (await loadWriteAgentRulesOnce())(); expect(writeClerkAgentRules).not.toHaveBeenCalled(); }); - it('skips in CI', async () => { - vi.mocked(isAutomatedEnvironment).mockReturnValue(true); + it('skips in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); (await loadWriteAgentRulesOnce())(); expect(writeClerkAgentRules).not.toHaveBeenCalled(); }); - it('skips in production', async () => { - vi.stubEnv('NODE_ENV', 'production'); + it('skips when CLERK_AGENTS_MD is false', async () => { + vi.stubEnv('CLERK_AGENTS_MD', 'false'); (await loadWriteAgentRulesOnce())(); diff --git a/packages/tanstack-react-start/src/server/agentRules.ts b/packages/tanstack-react-start/src/server/agentRules.ts index b810409c32f..ecec28cf00c 100644 --- a/packages/tanstack-react-start/src/server/agentRules.ts +++ b/packages/tanstack-react-start/src/server/agentRules.ts @@ -1,6 +1,6 @@ import type { FileSystemAdapter, PathAdapter } from '@clerk/shared/agentRules'; import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { isAgentEnvironment, isAutomatedEnvironment, isProductionEnvironment } from '@clerk/shared/utils'; +import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; type NodeProcess = { cwd: () => string; @@ -10,7 +10,7 @@ type NodeProcess = { let didWrite = false; export function writeAgentRulesOnce() { - if (didWrite || isProductionEnvironment() || isAutomatedEnvironment() || !isAgentEnvironment()) { + if (didWrite || !isClerkAgentRulesWriteAllowed()) { return; } From ab8aa825ee729162f5cb752789b67cc9bb3363c4 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 15:02:34 -0400 Subject: [PATCH 11/31] refactor(*): drop isAgentEnvironment and test the Astro and Nuxt agent rules triggers Co-Authored-By: Claude Opus 5.5 --- .changeset/shared-agent-rules.md | 2 +- .../integration/__tests__/agent-rules.test.ts | 71 +++++++++++++++++++ packages/astro/src/integration/agent-rules.ts | 22 ++++++ .../src/integration/create-integration.ts | 18 +---- .../nuxt/src/__tests__/agent-rules.test.ts | 71 +++++++++++++++++++ packages/nuxt/src/agent-rules.ts | 21 ++++++ packages/nuxt/src/module.ts | 18 ++--- .../src/__tests__/runtimeEnvironment.spec.ts | 64 ----------------- .../shared/src/utils/runtimeEnvironment.ts | 19 ----- 9 files changed, 193 insertions(+), 113 deletions(-) create mode 100644 packages/astro/src/integration/__tests__/agent-rules.test.ts create mode 100644 packages/astro/src/integration/agent-rules.ts create mode 100644 packages/nuxt/src/__tests__/agent-rules.test.ts create mode 100644 packages/nuxt/src/agent-rules.ts diff --git a/.changeset/shared-agent-rules.md b/.changeset/shared-agent-rules.md index 9821115b54b..1f0b9e144a4 100644 --- a/.changeset/shared-agent-rules.md +++ b/.changeset/shared-agent-rules.md @@ -2,4 +2,4 @@ '@clerk/shared': minor --- -Add `@clerk/shared/agentRules` and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` in production, in CI, or when `CLERK_AGENTS_MD=false`, and `isAgentEnvironment()` for detecting AI coding agents. +Add `@clerk/shared/agentRules` and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` in production, in CI, or when `CLERK_AGENTS_MD=false`. diff --git a/packages/astro/src/integration/__tests__/agent-rules.test.ts b/packages/astro/src/integration/__tests__/agent-rules.test.ts new file mode 100644 index 00000000000..0ff0d61427c --- /dev/null +++ b/packages/astro/src/integration/__tests__/agent-rules.test.ts @@ -0,0 +1,71 @@ +import type * as AgentRules from '@clerk/shared/agentRules'; +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@clerk/shared/agentRules', async importOriginal => ({ + ...(await importOriginal()), + writeClerkAgentRules: vi.fn(), +})); + +const loadWriteAgentRulesOnce = async () => (await import('../agent-rules')).writeAgentRulesOnce; + +describe('writeAgentRulesOnce', () => { + beforeEach(() => { + vi.resetModules(); + vi.mocked(writeClerkAgentRules).mockReset(); + for (const name of automatedEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLERK_AGENTS_MD', ''); + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('writes the Astro rules once per process', async () => { + const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); + + writeAgentRulesOnce(new URL('file:///app/')); + writeAgentRulesOnce(new URL('file:///app/')); + + expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); + expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app/', sdks: ['astro'] })); + }); + + it('skips in CI', async () => { + vi.stubEnv('CI', 'true'); + + (await loadWriteAgentRulesOnce())(new URL('file:///app/')); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + + (await loadWriteAgentRulesOnce())(new URL('file:///app/')); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips when CLERK_AGENTS_MD is false', async () => { + vi.stubEnv('CLERK_AGENTS_MD', 'false'); + + (await loadWriteAgentRulesOnce())(new URL('file:///app/')); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('does not throw when writing fails', async () => { + vi.mocked(writeClerkAgentRules).mockImplementation(() => { + throw new Error('EACCES'); + }); + + const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); + + expect(() => writeAgentRulesOnce(new URL('file:///app/'))).not.toThrow(); + }); +}); diff --git a/packages/astro/src/integration/agent-rules.ts b/packages/astro/src/integration/agent-rules.ts new file mode 100644 index 00000000000..252caef47c5 --- /dev/null +++ b/packages/astro/src/integration/agent-rules.ts @@ -0,0 +1,22 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; + +let didWrite = false; + +export function writeAgentRulesOnce(root: URL) { + if (didWrite || !isClerkAgentRulesWriteAllowed()) { + return; + } + + didWrite = true; + + try { + writeClerkAgentRules({ cwd: fileURLToPath(root), fs, path, sdks: ['astro'] }); + } catch { + // Writing agent hints must never break a dev server. + } +} diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index aaaee763693..48fd5f07078 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -1,15 +1,10 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { writeClerkAgentRules } from '@clerk/shared/agentRules'; import type { ClerkOptions } from '@clerk/shared/types'; -import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; import type { AstroIntegration } from 'astro'; import { envField } from 'astro/config'; import { name as packageName, version as packageVersion } from '../../package.json'; import type { AstroClerkIntegrationParams } from '../types'; +import { writeAgentRulesOnce } from './agent-rules'; import { buildBeforeHydrationSnippet, buildPageLoadSnippet } from './snippets'; import { vitePluginAstroConfig } from './vite-plugin-astro-config'; @@ -17,8 +12,6 @@ const buildEnvVarFromOption = (valueToBeStored: unknown, envName: keyof Internal return valueToBeStored ? { [`import.meta.env.${envName}`]: JSON.stringify(valueToBeStored) } : {}; }; -let didWriteAgentRules = false; - type HotloadAstroClerkIntegrationParams = AstroClerkIntegrationParams & { enableEnvSchema?: boolean; }; @@ -39,13 +32,8 @@ function createIntegration() name: '@clerk/astro/integration', hooks: { 'astro:config:setup': ({ config, injectScript, updateConfig, logger, command }) => { - if (command === 'dev' && !didWriteAgentRules && isClerkAgentRulesWriteAllowed()) { - didWriteAgentRules = true; - try { - writeClerkAgentRules({ cwd: fileURLToPath(config.root), fs, path, sdks: ['astro'] }); - } catch { - // Writing agent hints must never break a dev server. - } + if (command === 'dev') { + writeAgentRulesOnce(config.root); } if (['server', 'hybrid'].includes(config.output) && !config.adapter) { diff --git a/packages/nuxt/src/__tests__/agent-rules.test.ts b/packages/nuxt/src/__tests__/agent-rules.test.ts new file mode 100644 index 00000000000..90c11508dab --- /dev/null +++ b/packages/nuxt/src/__tests__/agent-rules.test.ts @@ -0,0 +1,71 @@ +import type * as AgentRules from '@clerk/shared/agentRules'; +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@clerk/shared/agentRules', async importOriginal => ({ + ...(await importOriginal()), + writeClerkAgentRules: vi.fn(), +})); + +const loadWriteAgentRulesOnce = async () => (await import('../agent-rules')).writeAgentRulesOnce; + +describe('writeAgentRulesOnce', () => { + beforeEach(() => { + vi.resetModules(); + vi.mocked(writeClerkAgentRules).mockReset(); + for (const name of automatedEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLERK_AGENTS_MD', ''); + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('writes the Nuxt rules once per process', async () => { + const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); + + writeAgentRulesOnce('/app'); + writeAgentRulesOnce('/app'); + + expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); + expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app', sdks: ['nuxt'] })); + }); + + it('skips in CI', async () => { + vi.stubEnv('CI', 'true'); + + (await loadWriteAgentRulesOnce())('/app'); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + + (await loadWriteAgentRulesOnce())('/app'); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips when CLERK_AGENTS_MD is false', async () => { + vi.stubEnv('CLERK_AGENTS_MD', 'false'); + + (await loadWriteAgentRulesOnce())('/app'); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('does not throw when writing fails', async () => { + vi.mocked(writeClerkAgentRules).mockImplementation(() => { + throw new Error('EACCES'); + }); + + const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); + + expect(() => writeAgentRulesOnce('/app')).not.toThrow(); + }); +}); diff --git a/packages/nuxt/src/agent-rules.ts b/packages/nuxt/src/agent-rules.ts new file mode 100644 index 00000000000..4eb6a9c92f2 --- /dev/null +++ b/packages/nuxt/src/agent-rules.ts @@ -0,0 +1,21 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; + +let didWrite = false; + +export function writeAgentRulesOnce(rootDir: string) { + if (didWrite || !isClerkAgentRulesWriteAllowed()) { + return; + } + + didWrite = true; + + try { + writeClerkAgentRules({ cwd: rootDir, fs, path, sdks: ['nuxt'] }); + } catch { + // Writing agent hints must never break a dev server. + } +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index b0a50e18564..b0809209bea 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -1,9 +1,4 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import { writeClerkAgentRules } from '@clerk/shared/agentRules'; import type { InternalClerkScriptProps, Without } from '@clerk/shared/types'; -import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; import type { PluginOptions } from '@clerk/vue'; import { addComponent, @@ -17,6 +12,8 @@ import { updateRuntimeConfig, } from '@nuxt/kit'; +import { writeAgentRulesOnce } from './agent-rules'; + export type ModuleOptions = Without & InternalClerkScriptProps & { publishableKey?: string; @@ -40,8 +37,6 @@ export type ModuleOptions = Without({ meta: { name: PACKAGE_NAME, @@ -52,13 +47,8 @@ export default defineNuxtModule({ }, }, setup(options, nuxt) { - if (nuxt.options.dev && !didWriteAgentRules && isClerkAgentRulesWriteAllowed()) { - didWriteAgentRules = true; - try { - writeClerkAgentRules({ cwd: nuxt.options.rootDir, fs, path, sdks: ['nuxt'] }); - } catch { - // Writing agent hints must never break a dev server. - } + if (nuxt.options.dev) { + writeAgentRulesOnce(nuxt.options.rootDir); } // These values must be set (even as undefined) to allow runtime config to work properly. diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index 29953924557..750787a5ca7 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -1,9 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - agentEnvironmentVariables, automatedEnvironmentVariables, - isAgentEnvironment, isAutomatedEnvironment, isClerkAgentRulesWriteAllowed, } from '../utils/runtimeEnvironment'; @@ -67,68 +65,6 @@ describe('isAutomatedEnvironment', () => { }); }); -describe('isAgentEnvironment', () => { - beforeEach(() => { - agentEnvironmentVariables.forEach(name => { - vi.stubEnv(name, undefined); - vi.stubGlobal(name, undefined); - }); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - }); - - it('returns true when an agent environment variable is enabled', () => { - vi.stubEnv('CLAUDECODE', 'true'); - - expect(isAgentEnvironment()).toBe(true); - }); - - it('returns false when agent environment variables are explicitly falsey', () => { - const falseyValues = ['false', '0', 'off', 'no']; - - agentEnvironmentVariables.forEach((name, index) => { - vi.stubEnv(name, falseyValues[index % falseyValues.length]); - }); - - expect(isAgentEnvironment()).toBe(false); - }); -}); - -describe('isAgentEnvironment with Cursor', () => { - beforeEach(() => { - agentEnvironmentVariables.forEach(name => { - vi.stubEnv(name, undefined); - vi.stubGlobal(name, undefined); - }); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - }); - - it('returns true when CURSOR_EXTENSION_HOST_ROLE is agent-exec', () => { - vi.stubEnv('CURSOR_EXTENSION_HOST_ROLE', 'agent-exec'); - - expect(isAgentEnvironment()).toBe(true); - }); - - it('returns false for any other CURSOR_EXTENSION_HOST_ROLE value', () => { - vi.stubEnv('CURSOR_EXTENSION_HOST_ROLE', 'user'); - - expect(isAgentEnvironment()).toBe(false); - }); - - it('returns true when CURSOR_TRACE_ID is set', () => { - vi.stubEnv('CURSOR_TRACE_ID', 'abc123'); - - expect(isAgentEnvironment()).toBe(true); - }); -}); - describe('isClerkAgentRulesWriteAllowed', () => { beforeEach(() => { for (const name of automatedEnvironmentVariables) { diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index 9ecc700744a..b949c86e202 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -19,18 +19,6 @@ export const automatedEnvironmentVariables = [ 'CF_PAGES', ] as const; -export const agentEnvironmentVariables = [ - 'CLAUDECODE', - 'CURSOR_AGENT', - 'CURSOR_CLI', - 'CURSOR_EXTENSION_HOST_ROLE', - 'CURSOR_TRACE_ID', - 'GITHUB_COPILOT_AGENT', - 'GITHUB_COPILOT_CHAT', - 'AGENT', - 'AGENTS', -] as const; - const isTruthyEnvValue = (value: unknown): boolean => { if (typeof value !== 'string' || !value) { return false; @@ -74,13 +62,6 @@ export const isAutomatedEnvironment = (): boolean => { return automatedEnvironmentVariables.some(name => isTruthyEnvValue(getEnvVariable(name))); }; -export const isAgentEnvironment = (): boolean => { - return agentEnvironmentVariables.some(name => { - const value = getEnvVariable(name); - return name === 'CURSOR_EXTENSION_HOST_ROLE' ? value === 'agent-exec' : isTruthyEnvValue(value); - }); -}; - export const isClerkAgentRulesWriteAllowed = (): boolean => { const optOut = getEnvVariable('CLERK_AGENTS_MD').toLowerCase(); return !['0', 'false', 'off', 'no'].includes(optOut) && !isProductionEnvironment() && !isAutomatedEnvironment(); From cadd2c95a8444adb232182b2998a862deca16f0c Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 15:33:59 -0400 Subject: [PATCH 12/31] feat(*): write Clerk agent rules from server middleware, Electron, and Next.js clerkMiddleware Co-Authored-By: Claude Opus 5.5 --- .changeset/react-vue-agent-rules-vite.md | 2 +- .changeset/shared-agent-rules.md | 2 +- .changeset/write-agents-md-in-dev.md | 7 +- .../src/main/__tests__/agent-rules.test.ts | 49 +++++++++++ packages/electron/src/main/agent-rules.ts | 16 ++++ .../electron/src/main/create-clerk-bridge.ts | 3 + .../express/src/__tests__/agentRules.test.ts | 26 ++++++ packages/express/src/clerkMiddleware.ts | 5 ++ .../fastify/src/__tests__/agentRules.test.ts | 30 +++++++ packages/fastify/src/clerkPlugin.ts | 5 ++ .../hono/src/__tests__/agentRules.test.ts | 26 ++++++ packages/hono/src/clerkMiddleware.ts | 5 ++ .../clerkMiddleware.agentRules.test.ts | 39 +++++++++ packages/nextjs/src/server/clerkMiddleware.ts | 2 + .../src/server/__tests__/agentRules.test.ts | 26 ++++++ .../src/server/clerkMiddleware.ts | 5 ++ packages/shared/package.json | 10 +++ .../src/__tests__/agentRulesWriter.spec.ts | 85 +++++++++++++++++++ .../src/__tests__/runtimeEnvironment.spec.ts | 12 +++ packages/shared/src/agentRulesWriter.ts | 32 +++++++ .../shared/src/utils/runtimeEnvironment.ts | 9 +- .../src/server/__tests__/agentRules.test.ts | 77 +++-------------- .../src/server/agentRules.ts | 32 +------ 23 files changed, 406 insertions(+), 99 deletions(-) create mode 100644 packages/electron/src/main/__tests__/agent-rules.test.ts create mode 100644 packages/electron/src/main/agent-rules.ts create mode 100644 packages/express/src/__tests__/agentRules.test.ts create mode 100644 packages/fastify/src/__tests__/agentRules.test.ts create mode 100644 packages/hono/src/__tests__/agentRules.test.ts create mode 100644 packages/nextjs/src/server/__tests__/clerkMiddleware.agentRules.test.ts create mode 100644 packages/react-router/src/server/__tests__/agentRules.test.ts create mode 100644 packages/shared/src/__tests__/agentRulesWriter.spec.ts create mode 100644 packages/shared/src/agentRulesWriter.ts diff --git a/.changeset/react-vue-agent-rules-vite.md b/.changeset/react-vue-agent-rules-vite.md index a85f7531d12..fe94fdb6157 100644 --- a/.changeset/react-vue-agent-rules-vite.md +++ b/.changeset/react-vue-agent-rules-vite.md @@ -3,7 +3,7 @@ '@clerk/vue': minor --- -Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server starts. Nothing is written during builds or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. +Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server starts. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. ```ts import { clerkReactAgentRules } from '@clerk/react/vite'; diff --git a/.changeset/shared-agent-rules.md b/.changeset/shared-agent-rules.md index 1f0b9e144a4..9e5e5db0e69 100644 --- a/.changeset/shared-agent-rules.md +++ b/.changeset/shared-agent-rules.md @@ -2,4 +2,4 @@ '@clerk/shared': minor --- -Add `@clerk/shared/agentRules` and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` in production, in CI, or when `CLERK_AGENTS_MD=false`. +Add `@clerk/shared/agentRules`, `@clerk/shared/agentRulesWriter`, and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` in production, in tests, in CI, or when `CLERK_AGENTS_MD=false`. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index e67f99f100f..dda80b4218d 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -1,9 +1,14 @@ --- '@clerk/astro': minor +'@clerk/electron': minor '@clerk/expo': minor +'@clerk/express': minor +'@clerk/fastify': minor +'@clerk/hono': minor '@clerk/nextjs': minor '@clerk/nuxt': minor +'@clerk/react-router': minor '@clerk/tanstack-react-start': minor --- -When your app runs in development, Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production or CI, and setting `CLERK_AGENTS_MD=false` turns it off. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call in Next.js, and TanStack React Start's `clerkMiddleware()`. +When your app runs in development, Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/electron/src/main/__tests__/agent-rules.test.ts b/packages/electron/src/main/__tests__/agent-rules.test.ts new file mode 100644 index 00000000000..e4e36c2f280 --- /dev/null +++ b/packages/electron/src/main/__tests__/agent-rules.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { writeAgentRulesOnce } from '../agent-rules'; + +const { createdFor, writeAgentRules } = vi.hoisted(() => ({ createdFor: [] as string[], writeAgentRules: vi.fn() })); + +vi.mock('@clerk/shared/agentRulesWriter', () => ({ + createClerkAgentRulesWriter: (sdk: string) => { + createdFor.push(sdk); + return writeAgentRules; + }, +})); + +const electronApp = vi.hoisted(() => ({ isPackaged: false, getAppPath: () => '/projects/my-app' })); + +vi.mock('electron', () => ({ app: electronApp })); + +describe('writeAgentRulesOnce', () => { + beforeEach(() => { + writeAgentRules.mockReset(); + electronApp.isPackaged = false; + }); + + it('creates a writer for the electron SDK', () => { + expect(createdFor).toEqual(['electron']); + }); + + it('writes to the app path when the app is not packaged', () => { + writeAgentRulesOnce(); + + expect(writeAgentRules).toHaveBeenCalledWith('/projects/my-app'); + }); + + it('skips packaged apps', () => { + electronApp.isPackaged = true; + + writeAgentRulesOnce(); + + expect(writeAgentRules).not.toHaveBeenCalled(); + }); + + it('does not throw when writing fails', () => { + writeAgentRules.mockImplementation(() => { + throw new Error('EACCES'); + }); + + expect(() => writeAgentRulesOnce()).not.toThrow(); + }); +}); diff --git a/packages/electron/src/main/agent-rules.ts b/packages/electron/src/main/agent-rules.ts new file mode 100644 index 00000000000..75cb2c02e1b --- /dev/null +++ b/packages/electron/src/main/agent-rules.ts @@ -0,0 +1,16 @@ +import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; +import { app } from 'electron'; + +const writeAgentRules = createClerkAgentRulesWriter('electron'); + +export function writeAgentRulesOnce() { + if (app.isPackaged) { + return; + } + + try { + writeAgentRules(app.getAppPath()); + } catch { + return; + } +} diff --git a/packages/electron/src/main/create-clerk-bridge.ts b/packages/electron/src/main/create-clerk-bridge.ts index e65a5ae860b..15b444ad6ff 100644 --- a/packages/electron/src/main/create-clerk-bridge.ts +++ b/packages/electron/src/main/create-clerk-bridge.ts @@ -1,6 +1,7 @@ import { app, protocol } from 'electron'; import type { ClerkBridge, CreateClerkBridgeOptions } from '../shared/types'; +import { writeAgentRulesOnce } from './agent-rules'; import { setupTokenCacheIpcHandlers } from './ipc-handlers'; import { setupMissingRendererOAuthTransportIpcHandlers, setupOAuthTransportIpcHandlers } from './oauth-transport'; import { setupPasskeysMain } from './passkey-handlers'; @@ -48,6 +49,8 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg ); } + writeAgentRulesOnce(); + let ownsSingleInstanceLock = false; if (options.renderer) { diff --git a/packages/express/src/__tests__/agentRules.test.ts b/packages/express/src/__tests__/agentRules.test.ts new file mode 100644 index 00000000000..7839d1df615 --- /dev/null +++ b/packages/express/src/__tests__/agentRules.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clerkMiddleware } from '../clerkMiddleware'; + +const { createdFor, writeAgentRules } = vi.hoisted(() => ({ createdFor: [] as string[], writeAgentRules: vi.fn() })); + +vi.mock('@clerk/shared/agentRulesWriter', () => ({ + createClerkAgentRulesWriter: (sdk: string) => { + createdFor.push(sdk); + return writeAgentRules; + }, +})); + +describe('Clerk agent rules', () => { + it('creates a writer for the express SDK', () => { + expect(createdFor).toEqual(['express']); + }); + + it('writes the agent rules when the app sets up Clerk', () => { + writeAgentRules.mockClear(); + + clerkMiddleware(); + + expect(writeAgentRules).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/express/src/clerkMiddleware.ts b/packages/express/src/clerkMiddleware.ts index 43a86437461..b16835d1da0 100644 --- a/packages/express/src/clerkMiddleware.ts +++ b/packages/express/src/clerkMiddleware.ts @@ -1,8 +1,11 @@ +import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; import type { RequestHandler } from 'express'; import { authenticateAndDecorateRequest } from './authenticateRequest'; import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; +const writeAgentRulesOnce = createClerkAgentRulesWriter('express'); + /** * Middleware that integrates Clerk authentication into your Express application. * It checks the request's cookies and headers for a session JWT and, if found, @@ -31,6 +34,8 @@ import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './t export const clerkMiddleware = ( options: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback = {}, ): RequestHandler => { + writeAgentRulesOnce(); + if (typeof options !== 'function') { const authMiddleware = authenticateAndDecorateRequest({ ...options, diff --git a/packages/fastify/src/__tests__/agentRules.test.ts b/packages/fastify/src/__tests__/agentRules.test.ts new file mode 100644 index 00000000000..59afc4bb215 --- /dev/null +++ b/packages/fastify/src/__tests__/agentRules.test.ts @@ -0,0 +1,30 @@ +import Fastify from 'fastify'; +import { describe, expect, it, vi } from 'vitest'; + +import { clerkPlugin } from '../clerkPlugin'; + +const { createdFor, writeAgentRules } = vi.hoisted(() => ({ createdFor: [] as string[], writeAgentRules: vi.fn() })); + +vi.mock('@clerk/shared/agentRulesWriter', () => ({ + createClerkAgentRulesWriter: (sdk: string) => { + createdFor.push(sdk); + return writeAgentRules; + }, +})); + +describe('Clerk agent rules', () => { + it('creates a writer for the fastify SDK', () => { + expect(createdFor).toEqual(['fastify']); + }); + + it('writes the agent rules when the plugin is registered', async () => { + writeAgentRules.mockClear(); + const fastify = Fastify(); + + await fastify.register(clerkPlugin, { publishableKey: 'pk_test_xxx', secretKey: 'sk_test_xxx' }); + await fastify.ready(); + + expect(writeAgentRules).toHaveBeenCalledTimes(1); + await fastify.close(); + }); +}); diff --git a/packages/fastify/src/clerkPlugin.ts b/packages/fastify/src/clerkPlugin.ts index d6e95f6bb20..d5237857087 100644 --- a/packages/fastify/src/clerkPlugin.ts +++ b/packages/fastify/src/clerkPlugin.ts @@ -1,3 +1,4 @@ +import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; import type { FastifyInstance, FastifyPluginCallback } from 'fastify'; import fp from 'fastify-plugin'; @@ -5,11 +6,15 @@ import type { ClerkFastifyOptions } from './types'; import { ALLOWED_HOOKS } from './types'; import { withClerkMiddleware } from './withClerkMiddleware'; +const writeAgentRulesOnce = createClerkAgentRulesWriter('fastify'); + const plugin: FastifyPluginCallback = ( instance: FastifyInstance, opts: ClerkFastifyOptions, done, ) => { + writeAgentRulesOnce(); + instance.decorateRequest('auth', null); instance.decorateRequest('clerk', null as any); diff --git a/packages/hono/src/__tests__/agentRules.test.ts b/packages/hono/src/__tests__/agentRules.test.ts new file mode 100644 index 00000000000..7e448c65754 --- /dev/null +++ b/packages/hono/src/__tests__/agentRules.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clerkMiddleware } from '../clerkMiddleware'; + +const { createdFor, writeAgentRules } = vi.hoisted(() => ({ createdFor: [] as string[], writeAgentRules: vi.fn() })); + +vi.mock('@clerk/shared/agentRulesWriter', () => ({ + createClerkAgentRulesWriter: (sdk: string) => { + createdFor.push(sdk); + return writeAgentRules; + }, +})); + +describe('Clerk agent rules', () => { + it('creates a writer for the hono SDK', () => { + expect(createdFor).toEqual(['hono']); + }); + + it('writes the agent rules when the app sets up Clerk', () => { + writeAgentRules.mockClear(); + + clerkMiddleware(); + + expect(writeAgentRules).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/hono/src/clerkMiddleware.ts b/packages/hono/src/clerkMiddleware.ts index 67bf53686c5..73a1930c203 100644 --- a/packages/hono/src/clerkMiddleware.ts +++ b/packages/hono/src/clerkMiddleware.ts @@ -3,11 +3,14 @@ import { createClerkClient } from '@clerk/backend'; import type { AuthenticateRequestOptions, AuthOptions, GetAuthFnNoRequest } from '@clerk/backend/internal'; import { getAuthObjectForAcceptedToken } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath, stripTrailingSlashes } from '@clerk/backend/proxy'; +import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; import type { MiddlewareHandler } from 'hono'; import { env } from 'hono/adapter'; import type { FrontendApiProxyOptions } from './types'; +const writeAgentRulesOnce = createClerkAgentRulesWriter('hono'); + type ClerkEnv = { CLERK_SECRET_KEY: string; CLERK_PUBLISHABLE_KEY: string; @@ -39,6 +42,8 @@ export type ClerkMiddlewareOptions = Omit { + writeAgentRulesOnce(); + return async (c, next) => { const clerkEnv = env(c); const { diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.agentRules.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.agentRules.test.ts new file mode 100644 index 00000000000..c7ac2d75045 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.agentRules.test.ts @@ -0,0 +1,39 @@ +import { TokenType } from '@clerk/backend/internal'; +import type { NextFetchEvent } from 'next/server'; +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { writeAgentRulesOnce } from '../agent-rules-node'; +import { clerkClient } from '../clerkClient'; +import { clerkMiddleware } from '../clerkMiddleware'; + +vi.mock('../clerkClient'); +vi.mock('../agent-rules-node', () => ({ writeAgentRulesOnce: vi.fn() })); +vi.mock(import('../constants.js'), async importOriginal => ({ + ...(await importOriginal()), + PUBLISHABLE_KEY: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', + SECRET_KEY: 'sk_test_xxxxxxxxxxxxxxxxxx', +})); + +describe('clerkMiddleware agent rules', () => { + beforeEach(() => { + vi.mocked(writeAgentRulesOnce).mockClear(); + vi.mocked(clerkClient).mockResolvedValue({ + authenticateRequest: vi.fn().mockResolvedValue({ + toAuth: () => ({ tokenType: TokenType.SessionToken, debug: (d: unknown) => d }), + headers: new Headers(), + publishableKey: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', + }), + // @ts-expect-error - mock + telemetry: { record: vi.fn() }, + }); + }); + + it('writes the agent rules on a request, even when the app never calls auth()', async () => { + const request = new NextRequest('https://www.clerk.com/protected'); + + await clerkMiddleware()(request, {} as NextFetchEvent); + + expect(writeAgentRulesOnce).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index a0e0fb9ceeb..3089d2d7f8b 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -147,6 +147,8 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const [handler, params] = parseHandlerAndOptions(args); const baseNextMiddleware: NextMiddleware = withLogger('clerkMiddleware', logger => async (request, event) => { + await import('./agent-rules-node.js').then(m => m.writeAgentRulesOnce()).catch(() => undefined); + // Handles the case where `options` is a callback function to dynamically access `NextRequest` const resolvedParams = typeof params === 'function' ? await params(request) : params; diff --git a/packages/react-router/src/server/__tests__/agentRules.test.ts b/packages/react-router/src/server/__tests__/agentRules.test.ts new file mode 100644 index 00000000000..6ef4ab6bf33 --- /dev/null +++ b/packages/react-router/src/server/__tests__/agentRules.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clerkMiddleware } from '../clerkMiddleware'; + +const { createdFor, writeAgentRules } = vi.hoisted(() => ({ createdFor: [] as string[], writeAgentRules: vi.fn() })); + +vi.mock('@clerk/shared/agentRulesWriter', () => ({ + createClerkAgentRulesWriter: (sdk: string) => { + createdFor.push(sdk); + return writeAgentRules; + }, +})); + +describe('Clerk agent rules', () => { + it('creates a writer for the react-router SDK', () => { + expect(createdFor).toEqual(['react-router']); + }); + + it('writes the agent rules when the app sets up Clerk', () => { + writeAgentRules.mockClear(); + + clerkMiddleware(); + + expect(writeAgentRules).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 7b6e9a81413..a14333e0f8d 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -1,6 +1,7 @@ import type { AuthObject } from '@clerk/backend'; import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; +import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import { patchRequest } from '@clerk/shared/patchRequest'; import type { PendingSessionOptions } from '@clerk/shared/types'; @@ -31,7 +32,11 @@ export const requestStateContext = createContext => { + writeAgentRulesOnce(); + return async (args, next) => { const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); diff --git a/packages/shared/package.json b/packages/shared/package.json index dcfbe2c77c1..bb0c75240ab 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -71,6 +71,16 @@ "default": "./dist/agentRules/index.js" } }, + "./agentRulesWriter": { + "import": { + "types": "./dist/agentRulesWriter.d.mts", + "default": "./dist/agentRulesWriter.mjs" + }, + "require": { + "types": "./dist/agentRulesWriter.d.ts", + "default": "./dist/agentRulesWriter.js" + } + }, "./vite": { "import": { "types": "./dist/vite.d.mts", diff --git a/packages/shared/src/__tests__/agentRulesWriter.spec.ts b/packages/shared/src/__tests__/agentRulesWriter.spec.ts new file mode 100644 index 00000000000..2e4146653f7 --- /dev/null +++ b/packages/shared/src/__tests__/agentRulesWriter.spec.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as AgentRules from '../agentRules'; +import { writeClerkAgentRules } from '../agentRules'; +import { createClerkAgentRulesWriter } from '../agentRulesWriter'; +import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; + +vi.mock('../agentRules', async importOriginal => ({ + ...(await importOriginal()), + writeClerkAgentRules: vi.fn(), +})); + +describe('createClerkAgentRulesWriter', () => { + beforeEach(() => { + vi.mocked(writeClerkAgentRules).mockReset(); + for (const name of automatedEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLERK_AGENTS_MD', ''); + vi.stubEnv('NODE_ENV', 'development'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('writes once per writer from the process working directory', () => { + const write = createClerkAgentRulesWriter('express'); + + write(); + write(); + + expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); + expect(writeClerkAgentRules).toHaveBeenCalledWith( + expect.objectContaining({ cwd: process.cwd(), sdks: ['express'] }), + ); + }); + + it('writes to the directory it is given', () => { + createClerkAgentRulesWriter('electron')('/app'); + + expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app', sdks: ['electron'] })); + }); + + it('skips in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + + createClerkAgentRulesWriter('hono')(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips in CI', () => { + vi.stubEnv('CI', 'true'); + + createClerkAgentRulesWriter('hono')(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips when CLERK_AGENTS_MD is false', () => { + vi.stubEnv('CLERK_AGENTS_MD', 'false'); + + createClerkAgentRulesWriter('fastify')(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('skips when the runtime has no Node file system', () => { + vi.spyOn(process, 'getBuiltinModule').mockReturnValue(undefined as never); + + createClerkAgentRulesWriter('hono')(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + }); + + it('does not throw when writing fails', () => { + vi.mocked(writeClerkAgentRules).mockImplementation(() => { + throw new Error('EACCES'); + }); + + expect(() => createClerkAgentRulesWriter('react-router')()).not.toThrow(); + }); +}); diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index 750787a5ca7..9d9dc5b03c0 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -94,12 +94,24 @@ describe('isClerkAgentRulesWriteAllowed', () => { expect(isClerkAgentRulesWriteAllowed()).toBe(false); }); + it('blocks writes in tests', () => { + vi.stubEnv('NODE_ENV', 'test'); + + expect(isClerkAgentRulesWriteAllowed()).toBe(false); + }); + it.each(['false', 'FALSE', '0', 'off', 'no'])('blocks writes when CLERK_AGENTS_MD is %s', value => { vi.stubEnv('CLERK_AGENTS_MD', value); expect(isClerkAgentRulesWriteAllowed()).toBe(false); }); + it('allows writes when CLERK_AGENTS_MD is unset', () => { + vi.stubEnv('CLERK_AGENTS_MD', undefined); + + expect(isClerkAgentRulesWriteAllowed()).toBe(true); + }); + it('allows writes when CLERK_AGENTS_MD is true', () => { vi.stubEnv('CLERK_AGENTS_MD', 'true'); diff --git a/packages/shared/src/agentRulesWriter.ts b/packages/shared/src/agentRulesWriter.ts new file mode 100644 index 00000000000..2fb32e28aeb --- /dev/null +++ b/packages/shared/src/agentRulesWriter.ts @@ -0,0 +1,32 @@ +import { type ClerkAgentRulesSdk, type FileSystemAdapter, type PathAdapter, writeClerkAgentRules } from './agentRules'; +import { isClerkAgentRulesWriteAllowed } from './utils'; + +type NodeProcess = { + cwd: () => string; + getBuiltinModule?: (id: string) => unknown; +}; + +export function createClerkAgentRulesWriter(sdk: ClerkAgentRulesSdk) { + let didWrite = false; + + return (cwd?: string) => { + if (didWrite || !isClerkAgentRulesWriteAllowed()) { + return; + } + + didWrite = true; + + const nodeProcess = (globalThis as { process?: NodeProcess }).process; + const fs = nodeProcess?.getBuiltinModule?.('node:fs') as FileSystemAdapter | undefined; + const path = nodeProcess?.getBuiltinModule?.('node:path') as PathAdapter | undefined; + if (!nodeProcess || !fs || !path) { + return; + } + + try { + writeClerkAgentRules({ cwd: cwd ?? nodeProcess.cwd(), fs, path, sdks: [sdk] }); + } catch { + return; + } + }; +} diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index b949c86e202..cd96dd16dae 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -63,6 +63,11 @@ export const isAutomatedEnvironment = (): boolean => { }; export const isClerkAgentRulesWriteAllowed = (): boolean => { - const optOut = getEnvVariable('CLERK_AGENTS_MD').toLowerCase(); - return !['0', 'false', 'off', 'no'].includes(optOut) && !isProductionEnvironment() && !isAutomatedEnvironment(); + const optOut = (getEnvVariable('CLERK_AGENTS_MD') || '').toLowerCase(); + return ( + !['0', 'false', 'off', 'no'].includes(optOut) && + !isProductionEnvironment() && + !isTestEnvironment() && + !isAutomatedEnvironment() + ); }; diff --git a/packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts b/packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts index 220e40fdb3d..67df6289af7 100644 --- a/packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts +++ b/packages/tanstack-react-start/src/server/__tests__/agentRules.test.ts @@ -1,70 +1,19 @@ -import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { automatedEnvironmentVariables } from '@clerk/shared/utils'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -vi.mock('@clerk/shared/agentRules', async importOriginal => ({ - ...(await importOriginal()), - writeClerkAgentRules: vi.fn(), -})); - -const loadWriteAgentRulesOnce = async () => (await import('../agentRules')).writeAgentRulesOnce; - -describe('writeAgentRulesOnce', () => { - beforeEach(() => { - vi.resetModules(); - vi.mocked(writeClerkAgentRules).mockReset(); - for (const name of automatedEnvironmentVariables) { - vi.stubEnv(name, ''); - } - vi.stubEnv('CLERK_AGENTS_MD', ''); - vi.stubEnv('NODE_ENV', 'development'); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('writes the rules once per process in development, with or without an AI agent', async () => { - const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); - - writeAgentRulesOnce(); - writeAgentRulesOnce(); - - expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); - expect(writeClerkAgentRules).toHaveBeenCalledWith( - expect.objectContaining({ cwd: expect.any(String), sdks: ['tanstack-react-start'] }), - ); - }); - - it('skips in CI', async () => { - vi.stubEnv('CI', 'true'); +import { writeAgentRulesOnce } from '../agentRules'; - (await loadWriteAgentRulesOnce())(); +const { createdFor, writeAgentRules } = vi.hoisted(() => ({ createdFor: [] as string[], writeAgentRules: vi.fn() })); - expect(writeClerkAgentRules).not.toHaveBeenCalled(); - }); - - it('skips in production', async () => { - vi.stubEnv('NODE_ENV', 'production'); - - (await loadWriteAgentRulesOnce())(); - - expect(writeClerkAgentRules).not.toHaveBeenCalled(); - }); - - it('skips when CLERK_AGENTS_MD is false', async () => { - vi.stubEnv('CLERK_AGENTS_MD', 'false'); - - (await loadWriteAgentRulesOnce())(); - - expect(writeClerkAgentRules).not.toHaveBeenCalled(); - }); - - it('does not throw when writing fails', async () => { - vi.mocked(writeClerkAgentRules).mockImplementation(() => { - throw new Error('EACCES'); - }); +vi.mock('@clerk/shared/agentRulesWriter', () => ({ + createClerkAgentRulesWriter: (sdk: string) => { + createdFor.push(sdk); + return writeAgentRules; + }, +})); - expect(await loadWriteAgentRulesOnce()).not.toThrow(); +describe('writeAgentRulesOnce', () => { + it('is the shared writer for the tanstack-react-start SDK', () => { + expect(createdFor).toEqual(['tanstack-react-start']); + expect(writeAgentRulesOnce).toBe(writeAgentRules); }); }); diff --git a/packages/tanstack-react-start/src/server/agentRules.ts b/packages/tanstack-react-start/src/server/agentRules.ts index ecec28cf00c..d02da24465f 100644 --- a/packages/tanstack-react-start/src/server/agentRules.ts +++ b/packages/tanstack-react-start/src/server/agentRules.ts @@ -1,31 +1,3 @@ -import type { FileSystemAdapter, PathAdapter } from '@clerk/shared/agentRules'; -import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; +import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; -type NodeProcess = { - cwd: () => string; - getBuiltinModule?: (id: string) => unknown; -}; - -let didWrite = false; - -export function writeAgentRulesOnce() { - if (didWrite || !isClerkAgentRulesWriteAllowed()) { - return; - } - - didWrite = true; - - const nodeProcess = (globalThis as { process?: NodeProcess }).process; - const fs = nodeProcess?.getBuiltinModule?.('node:fs') as FileSystemAdapter | undefined; - const path = nodeProcess?.getBuiltinModule?.('node:path') as PathAdapter | undefined; - if (!nodeProcess || !fs || !path) { - return; - } - - try { - writeClerkAgentRules({ cwd: nodeProcess.cwd(), fs, path, sdks: ['tanstack-react-start'] }); - } catch { - return; - } -} +export const writeAgentRulesOnce = createClerkAgentRulesWriter('tanstack-react-start'); From 60b1ab70e89110940c8318a7ba1d4536294c5635 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 16:26:35 -0400 Subject: [PATCH 13/31] feat(chrome-extension): add Vite plugin that writes Clerk agent rules Co-Authored-By: Claude Opus 5.5 --- .changeset/chrome-extension-agent-rules-vite.md | 14 ++++++++++++++ packages/chrome-extension/package.json | 13 ++++++++++++- packages/chrome-extension/src/vite.ts | 3 +++ packages/chrome-extension/tsup.config.ts | 1 + packages/chrome-extension/vite/package.json | 5 +++++ packages/shared/src/vite.ts | 2 +- 6 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 .changeset/chrome-extension-agent-rules-vite.md create mode 100644 packages/chrome-extension/src/vite.ts create mode 100644 packages/chrome-extension/vite/package.json diff --git a/.changeset/chrome-extension-agent-rules-vite.md b/.changeset/chrome-extension-agent-rules-vite.md new file mode 100644 index 00000000000..c0519885406 --- /dev/null +++ b/.changeset/chrome-extension-agent-rules-vite.md @@ -0,0 +1,14 @@ +--- +'@clerk/chrome-extension': minor +--- + +Add a Vite plugin that writes a Clerk section to the project's `AGENTS.md` when the Vite dev server starts, for extensions built with WXT, CRXJS, or a custom Vite setup. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. + +```ts +// wxt.config.ts +import { clerkChromeExtensionAgentRules } from '@clerk/chrome-extension/vite'; + +export default defineConfig({ + vite: () => ({ plugins: [clerkChromeExtensionAgentRules()] }), +}); +``` diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index a08c7a89ed1..c1c83716afb 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -65,6 +65,16 @@ "./types": { "types": "./dist/types/types/index.d.ts" }, + "./vite": { + "import": { + "types": "./dist/types/vite.d.ts", + "default": "./dist/esm/vite.js" + }, + "require": { + "types": "./dist/types/vite.d.ts", + "default": "./dist/cjs/vite.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/cjs/index.js", @@ -78,7 +88,8 @@ "internal", "legacy", "react", - "types" + "types", + "vite" ], "scripts": { "build": "tsup", diff --git a/packages/chrome-extension/src/vite.ts b/packages/chrome-extension/src/vite.ts new file mode 100644 index 00000000000..1b2a21ebfe2 --- /dev/null +++ b/packages/chrome-extension/src/vite.ts @@ -0,0 +1,3 @@ +import { clerkAgentRulesVitePlugin } from '@clerk/shared/vite'; + +export const clerkChromeExtensionAgentRules = () => clerkAgentRulesVitePlugin('chrome-extension'); diff --git a/packages/chrome-extension/tsup.config.ts b/packages/chrome-extension/tsup.config.ts index d6fee15bbee..57ee7e29509 100644 --- a/packages/chrome-extension/tsup.config.ts +++ b/packages/chrome-extension/tsup.config.ts @@ -16,6 +16,7 @@ export default defineConfig(overrideOptions => { './src/legacy.ts', './src/react/index.ts', './src/types/index.ts', + './src/vite.ts', ], bundle: true, clean: true, diff --git a/packages/chrome-extension/vite/package.json b/packages/chrome-extension/vite/package.json new file mode 100644 index 00000000000..4bcd8d3656f --- /dev/null +++ b/packages/chrome-extension/vite/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/cjs/vite.js", + "module": "../dist/esm/vite.js", + "types": "../dist/types/vite.d.ts" +} diff --git a/packages/shared/src/vite.ts b/packages/shared/src/vite.ts index cd040762314..1d26a7f1fd2 100644 --- a/packages/shared/src/vite.ts +++ b/packages/shared/src/vite.ts @@ -6,7 +6,7 @@ type ViteResolvedConfig = { root: string; }; -export function clerkAgentRulesVitePlugin(sdk: Extract) { +export function clerkAgentRulesVitePlugin(sdk: Extract) { let didWrite = false; return { From f83f36253ac42d47957dd4a161164c99ba6fb274 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 16:45:40 -0400 Subject: [PATCH 14/31] fix(shared): write Clerk agent rules to the nearest package.json folder The dev-time writers started from process.cwd(), so a server started outside its project wrote AGENTS.md and CLAUDE.md into whatever folder the shell was in, including the home folder. The writer now targets the workspace root, or the nearest folder at or above the start with a package.json, and skips the write when there is neither. Co-Authored-By: Claude Opus 5.5 --- packages/expo/app.plugin.js | 6 +---- .../shared/src/__tests__/agentRules.spec.ts | 25 ++++++++++++++++--- packages/shared/src/agentRules/index.ts | 14 ++++++++--- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index de29d46eaa2..8d848e6de70 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -22,14 +22,10 @@ const maybeWriteAgentRules = config => { return; } - const projectRoot = config?._internal?.projectRoot || process.cwd(); - if (!fs.existsSync(path.join(projectRoot, 'package.json'))) { - return; - } - didWriteAgentRules = true; try { + const projectRoot = config?._internal?.projectRoot || process.cwd(); writeClerkAgentRules({ cwd: projectRoot, fs, path, sdks: ['expo'] }); } catch { // Writing agent hints must never break Expo config evaluation. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 53239de8d19..165c09d2f7d 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -9,8 +9,8 @@ import { writeClerkAgentRules, } from '../agentRules'; -const createMemoryFs = (files: Record = {}) => { - const store = new Map(Object.entries(files)); +const createMemoryFs = (files: Record = {}, { withRepoPackageJson = true } = {}) => { + const store = new Map(Object.entries(withRepoPackageJson ? { '/repo/package.json': '{}', ...files } : files)); const dirs = new Set(); const fs: FileSystemAdapter = { @@ -55,7 +55,7 @@ describe('writeClerkAgentRules', () => { }); it('creates AGENTS.md and CLAUDE.md when neither exists', () => { - const memory = createMemoryFs(); + const memory = createMemoryFs({ '/repo/app/package.json': '{}' }); const result = writeClerkAgentRules({ cwd: '/repo/app', @@ -70,6 +70,25 @@ describe('writeClerkAgentRules', () => { expect(memory.read('/repo/app/CLAUDE.md')).toBe('@AGENTS.md\n'); }); + it('writes nothing when no package.json is at or above the starting folder', () => { + const memory = createMemoryFs({}, { withRepoPackageJson: false }); + + const result = writeClerkAgentRules({ cwd: '/home/user', fs: memory.fs, path: pathAdapter, sdks: ['express'] }); + + expect(result).toEqual({ targetDir: '/home/user', agentsMd: 'skipped', claudeMd: 'skipped' }); + expect(memory.read('/home/user/AGENTS.md')).toBeUndefined(); + expect(memory.read('/home/user/CLAUDE.md')).toBeUndefined(); + }); + + it('targets the nearest folder with a package.json when started in a subfolder', () => { + const memory = createMemoryFs(); + + writeClerkAgentRules({ cwd: '/repo/src', fs: memory.fs, path: pathAdapter, sdks: ['react'] }); + + expect(memory.read('/repo/AGENTS.md')).toContain(''); + expect(memory.read('/repo/src/AGENTS.md')).toBeUndefined(); + }); + it('targets the workspace root when one exists', () => { const memory = createMemoryFs({ '/repo/pnpm-workspace.yaml': 'packages:\n - apps/*\n', diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index babbad83dbd..3c5bc8d2bb7 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -39,7 +39,7 @@ export interface WriteClerkAgentRulesOptions { export type WriteClerkAgentRulesResult = { targetDir: string; - agentsMd: 'created' | 'updated' | 'unchanged'; + agentsMd: 'created' | 'updated' | 'unchanged' | 'skipped'; claudeMd: 'created' | 'updated' | 'unchanged' | 'skipped'; }; @@ -181,9 +181,10 @@ function upsertMarkedBlock( return `${content.trimEnd()}${eol}${eol}${block}${eol}`; } -function findWorkspaceRoot(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { +function findTargetDir(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { let current = path.resolve(cwd); const root = path.parse(current).root; + let nearestPackageDir: string | null = null; while (current !== root) { if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { @@ -192,6 +193,7 @@ function findWorkspaceRoot(fs: FileSystemAdapter, path: PathAdapter, cwd: string const packageJson = path.join(current, 'package.json'); if (fs.existsSync(packageJson)) { + nearestPackageDir ??= current; try { const pkg = JSON.parse(fs.readFileSync(packageJson, { encoding: 'utf8' })); if (pkg.workspaces) { @@ -205,7 +207,7 @@ function findWorkspaceRoot(fs: FileSystemAdapter, path: PathAdapter, cwd: string current = path.dirname(current); } - return null; + return nearestPackageDir; } function pickTargetFile(fs: FileSystemAdapter, path: PathAdapter, targetDir: string) { @@ -231,7 +233,11 @@ function pickTargetFile(fs: FileSystemAdapter, path: PathAdapter, targetDir: str export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): WriteClerkAgentRulesResult { const { dryRun = false, fs, path, sdks } = options; - const targetDir = findWorkspaceRoot(fs, path, options.cwd) ?? path.resolve(options.cwd); + const targetDir = findTargetDir(fs, path, options.cwd); + if (!targetDir) { + return { targetDir: path.resolve(options.cwd), agentsMd: 'skipped', claudeMd: 'skipped' }; + } + const targetFile = pickTargetFile(fs, path, targetDir); const agentsPath = path.join(targetDir, 'AGENTS.md'); const claudePath = path.join(targetDir, 'CLAUDE.md'); From 40fc2abff6152b4c47d925dedf3f457ba8d6c1c9 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 16:45:53 -0400 Subject: [PATCH 15/31] feat(*): log when Clerk writes agent rules in development Each dev-time trigger now prints the files it created or updated and how to turn the write off, so a write to an unexpected folder is visible on the first run. Nothing is logged when the files are already current. Co-Authored-By: Claude Opus 5.5 --- packages/astro/src/integration/agent-rules.ts | 4 +-- packages/expo/app.plugin.js | 4 +-- .../nextjs/src/server/agent-rules-node.ts | 16 +++++---- packages/nuxt/src/agent-rules.ts | 4 +-- .../shared/src/__tests__/agentRules.spec.ts | 33 +++++++++++++++++-- .../src/__tests__/agentRulesWriter.spec.ts | 4 ++- packages/shared/src/__tests__/vite.spec.ts | 4 ++- packages/shared/src/agentRules/index.ts | 15 ++++++++- packages/shared/src/agentRulesWriter.ts | 10 ++++-- packages/shared/src/vite.ts | 4 +-- 10 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/astro/src/integration/agent-rules.ts b/packages/astro/src/integration/agent-rules.ts index 252caef47c5..96e18c49fa6 100644 --- a/packages/astro/src/integration/agent-rules.ts +++ b/packages/astro/src/integration/agent-rules.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; let didWrite = false; @@ -15,7 +15,7 @@ export function writeAgentRulesOnce(root: URL) { didWrite = true; try { - writeClerkAgentRules({ cwd: fileURLToPath(root), fs, path, sdks: ['astro'] }); + logClerkAgentRulesResult(writeClerkAgentRules({ cwd: fileURLToPath(root), fs, path, sdks: ['astro'] })); } catch { // Writing agent hints must never break a dev server. } diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 8d848e6de70..c5afcb4178c 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -7,7 +7,7 @@ * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs */ const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins'); -const { writeClerkAgentRules } = require('@clerk/shared/agentRules'); +const { logClerkAgentRulesResult, writeClerkAgentRules } = require('@clerk/shared/agentRules'); const { isClerkAgentRulesWriteAllowed } = require('@clerk/shared/utils'); const fs = require('fs'); @@ -26,7 +26,7 @@ const maybeWriteAgentRules = config => { try { const projectRoot = config?._internal?.projectRoot || process.cwd(); - writeClerkAgentRules({ cwd: projectRoot, fs, path, sdks: ['expo'] }); + logClerkAgentRulesResult(writeClerkAgentRules({ cwd: projectRoot, fs, path, sdks: ['expo'] })); } catch { // Writing agent hints must never break Expo config evaluation. } diff --git a/packages/nextjs/src/server/agent-rules-node.ts b/packages/nextjs/src/server/agent-rules-node.ts index fdbf0a94407..2e4aa748326 100644 --- a/packages/nextjs/src/server/agent-rules-node.ts +++ b/packages/nextjs/src/server/agent-rules-node.ts @@ -1,4 +1,4 @@ -import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; @@ -13,12 +13,14 @@ export function writeAgentRulesOnce() { didWrite = true; try { - writeClerkAgentRules({ - cwd: nodeCwdOrThrow()(), - fs: nodeFsOrThrow(), - path: nodePathOrThrow(), - sdks: ['nextjs'], - }); + logClerkAgentRulesResult( + writeClerkAgentRules({ + cwd: nodeCwdOrThrow()(), + fs: nodeFsOrThrow(), + path: nodePathOrThrow(), + sdks: ['nextjs'], + }), + ); } catch { // Writing agent hints must never break a dev server. } diff --git a/packages/nuxt/src/agent-rules.ts b/packages/nuxt/src/agent-rules.ts index 4eb6a9c92f2..19d0ae5b1b9 100644 --- a/packages/nuxt/src/agent-rules.ts +++ b/packages/nuxt/src/agent-rules.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; let didWrite = false; @@ -14,7 +14,7 @@ export function writeAgentRulesOnce(rootDir: string) { didWrite = true; try { - writeClerkAgentRules({ cwd: rootDir, fs, path, sdks: ['nuxt'] }); + logClerkAgentRulesResult(writeClerkAgentRules({ cwd: rootDir, fs, path, sdks: ['nuxt'] })); } catch { // Writing agent hints must never break a dev server. } diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 165c09d2f7d..aaa84d39af3 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -1,9 +1,10 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CLERK_SDK_PLUGIN_END, CLERK_SDK_PLUGIN_START, type FileSystemAdapter, + logClerkAgentRulesResult, renderClerkAgentRulesBlock, renderClerkSdkPluginBlock, writeClerkAgentRules, @@ -68,6 +69,7 @@ describe('writeClerkAgentRules', () => { expect(result.claudeMd).toBe('created'); expect(memory.read('/repo/app/AGENTS.md')).toContain(''); expect(memory.read('/repo/app/CLAUDE.md')).toBe('@AGENTS.md\n'); + expect(result.files).toEqual(['/repo/app/AGENTS.md', '/repo/app/CLAUDE.md']); }); it('writes nothing when no package.json is at or above the starting folder', () => { @@ -75,7 +77,7 @@ describe('writeClerkAgentRules', () => { const result = writeClerkAgentRules({ cwd: '/home/user', fs: memory.fs, path: pathAdapter, sdks: ['express'] }); - expect(result).toEqual({ targetDir: '/home/user', agentsMd: 'skipped', claudeMd: 'skipped' }); + expect(result).toEqual({ targetDir: '/home/user', agentsMd: 'skipped', claudeMd: 'skipped', files: [] }); expect(memory.read('/home/user/AGENTS.md')).toBeUndefined(); expect(memory.read('/home/user/CLAUDE.md')).toBeUndefined(); }); @@ -262,6 +264,33 @@ describe('writeClerkAgentRules with an existing CLAUDE.md', () => { }); }); +describe('logClerkAgentRulesResult', () => { + it('logs the files that changed', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + logClerkAgentRulesResult({ + targetDir: '/repo', + agentsMd: 'created', + claudeMd: 'created', + files: ['/repo/AGENTS.md', '/repo/CLAUDE.md'], + }); + + expect(log).toHaveBeenCalledWith( + '[Clerk]: Updated /repo/AGENTS.md and /repo/CLAUDE.md for AI coding agents. Set CLERK_AGENTS_MD=false to turn this off.', + ); + log.mockRestore(); + }); + + it('stays quiet when nothing changed', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + logClerkAgentRulesResult({ targetDir: '/repo', agentsMd: 'unchanged', claudeMd: 'unchanged', files: [] }); + + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); +}); + describe('Clerk plugin rules', () => { it('recommends the Clerk plugin and keeps the ask-before-changes rule in the root block', () => { const block = renderClerkAgentRulesBlock(); diff --git a/packages/shared/src/__tests__/agentRulesWriter.spec.ts b/packages/shared/src/__tests__/agentRulesWriter.spec.ts index 2e4146653f7..0511c893630 100644 --- a/packages/shared/src/__tests__/agentRulesWriter.spec.ts +++ b/packages/shared/src/__tests__/agentRulesWriter.spec.ts @@ -12,7 +12,9 @@ vi.mock('../agentRules', async importOriginal => ({ describe('createClerkAgentRulesWriter', () => { beforeEach(() => { - vi.mocked(writeClerkAgentRules).mockReset(); + vi.mocked(writeClerkAgentRules) + .mockReset() + .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', claudeMd: 'unchanged', files: [] }); for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } diff --git a/packages/shared/src/__tests__/vite.spec.ts b/packages/shared/src/__tests__/vite.spec.ts index 396650506e2..b1a939a0c0f 100644 --- a/packages/shared/src/__tests__/vite.spec.ts +++ b/packages/shared/src/__tests__/vite.spec.ts @@ -12,7 +12,9 @@ vi.mock('../agentRules', async importOriginal => ({ describe('clerkAgentRulesVitePlugin', () => { beforeEach(() => { - vi.mocked(writeClerkAgentRules).mockReset(); + vi.mocked(writeClerkAgentRules) + .mockReset() + .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', claudeMd: 'unchanged', files: [] }); for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 3c5bc8d2bb7..3a37824089e 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -41,6 +41,7 @@ export type WriteClerkAgentRulesResult = { targetDir: string; agentsMd: 'created' | 'updated' | 'unchanged' | 'skipped'; claudeMd: 'created' | 'updated' | 'unchanged' | 'skipped'; + files: string[]; }; const GENERIC_START = ''; @@ -235,7 +236,7 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ const { dryRun = false, fs, path, sdks } = options; const targetDir = findTargetDir(fs, path, options.cwd); if (!targetDir) { - return { targetDir: path.resolve(options.cwd), agentsMd: 'skipped', claudeMd: 'skipped' }; + return { targetDir: path.resolve(options.cwd), agentsMd: 'skipped', claudeMd: 'skipped', files: [] }; } const targetFile = pickTargetFile(fs, path, targetDir); @@ -291,5 +292,17 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ : claudeExists ? 'unchanged' : 'skipped', + files: [ + ...(targetStatus === 'unchanged' ? [] : [targetFile]), + ...(shouldCreateClaude || shouldImportAgentsInClaude ? [claudePath] : []), + ], }; } + +export function logClerkAgentRulesResult({ files }: WriteClerkAgentRulesResult) { + if (files.length > 0) { + console.log( + `[Clerk]: Updated ${files.join(' and ')} for AI coding agents. Set CLERK_AGENTS_MD=false to turn this off.`, + ); + } +} diff --git a/packages/shared/src/agentRulesWriter.ts b/packages/shared/src/agentRulesWriter.ts index 2fb32e28aeb..8afc83457b5 100644 --- a/packages/shared/src/agentRulesWriter.ts +++ b/packages/shared/src/agentRulesWriter.ts @@ -1,4 +1,10 @@ -import { type ClerkAgentRulesSdk, type FileSystemAdapter, type PathAdapter, writeClerkAgentRules } from './agentRules'; +import { + type ClerkAgentRulesSdk, + type FileSystemAdapter, + logClerkAgentRulesResult, + type PathAdapter, + writeClerkAgentRules, +} from './agentRules'; import { isClerkAgentRulesWriteAllowed } from './utils'; type NodeProcess = { @@ -24,7 +30,7 @@ export function createClerkAgentRulesWriter(sdk: ClerkAgentRulesSdk) { } try { - writeClerkAgentRules({ cwd: cwd ?? nodeProcess.cwd(), fs, path, sdks: [sdk] }); + logClerkAgentRulesResult(writeClerkAgentRules({ cwd: cwd ?? nodeProcess.cwd(), fs, path, sdks: [sdk] })); } catch { return; } diff --git a/packages/shared/src/vite.ts b/packages/shared/src/vite.ts index 1d26a7f1fd2..85c9112ca89 100644 --- a/packages/shared/src/vite.ts +++ b/packages/shared/src/vite.ts @@ -1,4 +1,4 @@ -import { type ClerkAgentRulesSdk, writeClerkAgentRules } from './agentRules'; +import { type ClerkAgentRulesSdk, logClerkAgentRulesResult, writeClerkAgentRules } from './agentRules'; import { isClerkAgentRulesWriteAllowed } from './utils'; type ViteResolvedConfig = { @@ -21,7 +21,7 @@ export function clerkAgentRulesVitePlugin(sdk: Extract Date: Thu, 22 Oct 2026 16:32:39 -0400 Subject: [PATCH 16/31] test(repo): update typedoc snapshot for InstanceType union order Co-Authored-By: Claude Opus 5.5 --- .typedoc/__tests__/__snapshots__/clerk-properties.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.typedoc/__tests__/__snapshots__/clerk-properties.mdx b/.typedoc/__tests__/__snapshots__/clerk-properties.mdx index 2a9ba670c16..160e1ad3aa6 100644 --- a/.typedoc/__tests__/__snapshots__/clerk-properties.mdx +++ b/.typedoc/__tests__/__snapshots__/clerk-properties.mdx @@ -4,7 +4,7 @@ | `billing` | [`BillingNamespace`](/docs/reference/objects/billing) | The `Billing` object used for managing billing. | | `client` | undefined \| [ClientResource](/docs/reference/objects/client) | The `Client` object for the current window. | | `domain` | `string` | The current Clerk app's domain. Prefixed with `clerk.` on production if not already prefixed. Returns `""` when ran on the server. | -| `instanceType` | undefined \| "production" \| "development" | Indicates whether the Clerk instance is running in a production or development environment. | +| `instanceType` | undefined \| "development" \| "production" | Indicates whether the Clerk instance is running in a production or development environment. | | `isSatellite` | `boolean` | Indicates whether the instance is a satellite app. | | `isSignedIn` | `boolean` | Indicates whether the current user has a valid signed-in client session. | | `isStandardBrowser` | undefined \| boolean | Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser. | From c64a9986ca11255a61ab0322538617853ceb61ac Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 22 Oct 2026 17:38:08 -0400 Subject: [PATCH 17/31] fix(shared): keep SDK agent rules blocks in sorted order Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/__tests__/agentRules.spec.ts | 16 ++++++++++++++++ packages/shared/src/agentRules/index.ts | 9 +++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index aaa84d39af3..31e243af363 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -125,6 +125,22 @@ describe('writeClerkAgentRules', () => { expect(content).not.toContain('\nold\n'); }); + it('writes SDK blocks in sorted order', () => { + const memory = createMemoryFs(); + + writeClerkAgentRules({ + cwd: '/repo', + fs: memory.fs, + path: pathAdapter, + sdks: ['vue', 'astro', 'react'], + }); + + const content = memory.read('/repo/AGENTS.md') ?? ''; + const positions = ['astro', 'react', 'vue'].map(sdk => content.indexOf(``)); + expect(positions.every(position => position !== -1)).toBe(true); + expect(positions).toEqual([...positions].sort((a, b) => a - b)); + }); + it('does not write files in dry-run mode', () => { const memory = createMemoryFs(); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 3a37824089e..80d5538df24 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -247,15 +247,20 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ let next = upsertMarkedBlock(existing, renderClerkAgentRulesBlock(eol), GENERIC_START, GENERIC_END, eol); + let insertAfter = GENERIC_END; for (const sdk of uniqueSdks(sdks)) { + const sdkEnd = ``; next = upsertMarkedBlock( next, renderClerkSdkAgentRulesBlock(sdk, eol), ``, - ``, + sdkEnd, eol, - GENERIC_END, + insertAfter, ); + if (next.includes(sdkEnd)) { + insertAfter = sdkEnd; + } } const targetStatus = fs.existsSync(targetFile) ? (next === existing ? 'unchanged' : 'updated') : 'created'; From 5b83411d99e7507eb5cb0ba12e8f9fd38a1d8709 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 26 Oct 2026 12:55:07 -0400 Subject: [PATCH 18/31] ci(repo): build the Expo native fixture against the local @clerk/shared Co-Authored-By: Claude Opus 5.5 --- .github/workflows/expo-native-build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index d4c38bbdfca..f974c7272ed 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -115,6 +115,7 @@ jobs: pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR" pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR" pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR" + pnpm --filter @clerk/shared pack --pack-destination "$SDK_PACK_DIR" - name: Install fixture dependencies if: steps.native-build-cache.outputs.cache-hit != 'true' @@ -123,6 +124,8 @@ jobs: EXPO_SDK: ${{ matrix.expo-sdk }} run: | cp "package.sdk-$EXPO_SDK.json" package.json + SHARED_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-shared-*.tgz)" + printf 'overrides:\n "@clerk/shared": "file:%s"\n' "$SHARED_TARBALL" >> pnpm-workspace.yaml pnpm install --no-frozen-lockfile # [0-9] keeps this glob off the other clerk-expo-* tarballs. SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)" From 5655e2e5e8ba074bdd274954d8117ab066d3178c Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 26 Oct 2026 17:26:05 -0400 Subject: [PATCH 19/31] fix(shared): reject inherited keys in isClerkAgentRulesSdk and clarify when agent rules are skipped Co-Authored-By: Claude Opus 5.5 --- .changeset/shared-agent-rules.md | 2 +- .changeset/write-agents-md-in-dev.md | 2 +- packages/shared/src/__tests__/agentRules.spec.ts | 12 ++++++++++++ packages/shared/src/agentRules/index.ts | 3 ++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.changeset/shared-agent-rules.md b/.changeset/shared-agent-rules.md index 9e5e5db0e69..ff9983c86a0 100644 --- a/.changeset/shared-agent-rules.md +++ b/.changeset/shared-agent-rules.md @@ -2,4 +2,4 @@ '@clerk/shared': minor --- -Add `@clerk/shared/agentRules`, `@clerk/shared/agentRulesWriter`, and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` in production, in tests, in CI, or when `CLERK_AGENTS_MD=false`. +Add `@clerk/shared/agentRules`, `@clerk/shared/agentRulesWriter`, and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` when `NODE_ENV=production`, in tests, in CI, or when `CLERK_AGENTS_MD=false`. Deployments that don't set `NODE_ENV=production` should set `CLERK_AGENTS_MD=false` to turn writes off. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index dda80b4218d..d16d192b89e 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development, Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written in production, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development, Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 31e243af363..0eded3e381b 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -4,6 +4,7 @@ import { CLERK_SDK_PLUGIN_END, CLERK_SDK_PLUGIN_START, type FileSystemAdapter, + isClerkAgentRulesSdk, logClerkAgentRulesResult, renderClerkAgentRulesBlock, renderClerkSdkPluginBlock, @@ -280,6 +281,17 @@ describe('writeClerkAgentRules with an existing CLAUDE.md', () => { }); }); +describe('isClerkAgentRulesSdk', () => { + it('accepts supported SDKs', () => { + expect(isClerkAgentRulesSdk('nextjs')).toBe(true); + }); + + it('rejects inherited object keys', () => { + expect(isClerkAgentRulesSdk('constructor')).toBe(false); + expect(isClerkAgentRulesSdk('toString')).toBe(false); + }); +}); + describe('logClerkAgentRulesResult', () => { it('logs the files that changed', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 80d5538df24..cb7596e5d8d 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -81,7 +81,8 @@ const appendAgentsImport = (content: string) => { return content.trim() ? `${content.trimEnd()}${eol}${eol}@AGENTS.md${eol}` : `@AGENTS.md${eol}`; }; -export const isClerkAgentRulesSdk = (value: string): value is ClerkAgentRulesSdk => value in SDK_LABELS; +export const isClerkAgentRulesSdk = (value: string): value is ClerkAgentRulesSdk => + Object.prototype.hasOwnProperty.call(SDK_LABELS, value); export const clerkAgentRulesSdks = Object.keys(SDK_LABELS) as ClerkAgentRulesSdk[]; From c8b67adfe20cc06e7864a4b32951a1ba257b29e8 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 27 Oct 2026 15:24:37 -0400 Subject: [PATCH 20/31] fix(*): warn when agent rules cannot be written Log a warning with the failure reason instead of swallowing it, and skip loading the agent-rules module in production Next.js builds. Co-Authored-By: Claude Opus 5.5 --- packages/astro/src/integration/agent-rules.ts | 6 +++--- packages/electron/src/main/agent-rules.ts | 5 +++-- packages/expo/app.plugin.js | 6 +++--- packages/nextjs/src/app-router/server/auth.ts | 4 +++- packages/nextjs/src/server/agent-rules-node.ts | 6 +++--- packages/nextjs/src/server/clerkMiddleware.ts | 4 +++- packages/nuxt/src/agent-rules.ts | 6 +++--- .../shared/src/__tests__/agentRulesWriter.spec.ts | 6 +++++- packages/shared/src/agentRules/index.ts | 7 +++++++ packages/shared/src/agentRulesWriter.ts | 5 +++-- packages/shared/src/vite.ts | 11 ++++++++--- 11 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/astro/src/integration/agent-rules.ts b/packages/astro/src/integration/agent-rules.ts index 96e18c49fa6..70bcebcbd2b 100644 --- a/packages/astro/src/integration/agent-rules.ts +++ b/packages/astro/src/integration/agent-rules.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { logClerkAgentRulesError, logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; let didWrite = false; @@ -16,7 +16,7 @@ export function writeAgentRulesOnce(root: URL) { try { logClerkAgentRulesResult(writeClerkAgentRules({ cwd: fileURLToPath(root), fs, path, sdks: ['astro'] })); - } catch { - // Writing agent hints must never break a dev server. + } catch (error) { + logClerkAgentRulesError(error); } } diff --git a/packages/electron/src/main/agent-rules.ts b/packages/electron/src/main/agent-rules.ts index 75cb2c02e1b..5648d00679c 100644 --- a/packages/electron/src/main/agent-rules.ts +++ b/packages/electron/src/main/agent-rules.ts @@ -1,3 +1,4 @@ +import { logClerkAgentRulesError } from '@clerk/shared/agentRules'; import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; import { app } from 'electron'; @@ -10,7 +11,7 @@ export function writeAgentRulesOnce() { try { writeAgentRules(app.getAppPath()); - } catch { - return; + } catch (error) { + logClerkAgentRulesError(error); } } diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index c5afcb4178c..aee06f677b4 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -7,7 +7,7 @@ * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs */ const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins'); -const { logClerkAgentRulesResult, writeClerkAgentRules } = require('@clerk/shared/agentRules'); +const { logClerkAgentRulesError, logClerkAgentRulesResult, writeClerkAgentRules } = require('@clerk/shared/agentRules'); const { isClerkAgentRulesWriteAllowed } = require('@clerk/shared/utils'); const fs = require('fs'); @@ -27,8 +27,8 @@ const maybeWriteAgentRules = config => { try { const projectRoot = config?._internal?.projectRoot || process.cwd(); logClerkAgentRulesResult(writeClerkAgentRules({ cwd: projectRoot, fs, path, sdks: ['expo'] })); - } catch { - // Writing agent hints must never break Expo config evaluation. + } catch (error) { + logClerkAgentRulesError(error); } }; diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 459466d72ae..5aae55b889c 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -83,7 +83,9 @@ export const auth: AuthFn = (async (options?: AuthOptions) => { require('server-only'); try { - await import('../../server/agent-rules-node.js').then(m => m.writeAgentRulesOnce()).catch(() => undefined); + if (process.env.NODE_ENV !== 'production') { + await import('../../server/agent-rules-node.js').then(m => m.writeAgentRulesOnce()).catch(() => undefined); + } const request = await buildRequestLike(); diff --git a/packages/nextjs/src/server/agent-rules-node.ts b/packages/nextjs/src/server/agent-rules-node.ts index 2e4aa748326..92116164870 100644 --- a/packages/nextjs/src/server/agent-rules-node.ts +++ b/packages/nextjs/src/server/agent-rules-node.ts @@ -1,4 +1,4 @@ -import { logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { logClerkAgentRulesError, logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; @@ -21,7 +21,7 @@ export function writeAgentRulesOnce() { sdks: ['nextjs'], }), ); - } catch { - // Writing agent hints must never break a dev server. + } catch (error) { + logClerkAgentRulesError(error); } } diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 3089d2d7f8b..003fb404f8a 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -147,7 +147,9 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const [handler, params] = parseHandlerAndOptions(args); const baseNextMiddleware: NextMiddleware = withLogger('clerkMiddleware', logger => async (request, event) => { - await import('./agent-rules-node.js').then(m => m.writeAgentRulesOnce()).catch(() => undefined); + if (process.env.NODE_ENV !== 'production') { + await import('./agent-rules-node.js').then(m => m.writeAgentRulesOnce()).catch(() => undefined); + } // Handles the case where `options` is a callback function to dynamically access `NextRequest` const resolvedParams = typeof params === 'function' ? await params(request) : params; diff --git a/packages/nuxt/src/agent-rules.ts b/packages/nuxt/src/agent-rules.ts index 19d0ae5b1b9..12f250dca3f 100644 --- a/packages/nuxt/src/agent-rules.ts +++ b/packages/nuxt/src/agent-rules.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; +import { logClerkAgentRulesError, logClerkAgentRulesResult, writeClerkAgentRules } from '@clerk/shared/agentRules'; import { isClerkAgentRulesWriteAllowed } from '@clerk/shared/utils'; let didWrite = false; @@ -15,7 +15,7 @@ export function writeAgentRulesOnce(rootDir: string) { try { logClerkAgentRulesResult(writeClerkAgentRules({ cwd: rootDir, fs, path, sdks: ['nuxt'] })); - } catch { - // Writing agent hints must never break a dev server. + } catch (error) { + logClerkAgentRulesError(error); } } diff --git a/packages/shared/src/__tests__/agentRulesWriter.spec.ts b/packages/shared/src/__tests__/agentRulesWriter.spec.ts index 0511c893630..0a985ecca2d 100644 --- a/packages/shared/src/__tests__/agentRulesWriter.spec.ts +++ b/packages/shared/src/__tests__/agentRulesWriter.spec.ts @@ -77,11 +77,15 @@ describe('createClerkAgentRulesWriter', () => { expect(writeClerkAgentRules).not.toHaveBeenCalled(); }); - it('does not throw when writing fails', () => { + it('warns instead of throwing when writing fails', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); vi.mocked(writeClerkAgentRules).mockImplementation(() => { throw new Error('EACCES'); }); expect(() => createClerkAgentRulesWriter('react-router')()).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Could not update AGENTS.md for AI coding agents: EACCES'), + ); }); }); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index cb7596e5d8d..1e6c9a6b477 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -312,3 +312,10 @@ export function logClerkAgentRulesResult({ files }: WriteClerkAgentRulesResult) ); } } + +export function logClerkAgentRulesError(error: unknown) { + const reason = error instanceof Error ? error.message : String(error); + console.warn( + `[Clerk]: Could not update AGENTS.md for AI coding agents: ${reason}. Set CLERK_AGENTS_MD=false to turn this off.`, + ); +} diff --git a/packages/shared/src/agentRulesWriter.ts b/packages/shared/src/agentRulesWriter.ts index 8afc83457b5..86b6309d930 100644 --- a/packages/shared/src/agentRulesWriter.ts +++ b/packages/shared/src/agentRulesWriter.ts @@ -1,6 +1,7 @@ import { type ClerkAgentRulesSdk, type FileSystemAdapter, + logClerkAgentRulesError, logClerkAgentRulesResult, type PathAdapter, writeClerkAgentRules, @@ -31,8 +32,8 @@ export function createClerkAgentRulesWriter(sdk: ClerkAgentRulesSdk) { try { logClerkAgentRulesResult(writeClerkAgentRules({ cwd: cwd ?? nodeProcess.cwd(), fs, path, sdks: [sdk] })); - } catch { - return; + } catch (error) { + logClerkAgentRulesError(error); } }; } diff --git a/packages/shared/src/vite.ts b/packages/shared/src/vite.ts index 85c9112ca89..f4792ee20c9 100644 --- a/packages/shared/src/vite.ts +++ b/packages/shared/src/vite.ts @@ -1,4 +1,9 @@ -import { type ClerkAgentRulesSdk, logClerkAgentRulesResult, writeClerkAgentRules } from './agentRules'; +import { + type ClerkAgentRulesSdk, + logClerkAgentRulesError, + logClerkAgentRulesResult, + writeClerkAgentRules, +} from './agentRules'; import { isClerkAgentRulesWriteAllowed } from './utils'; type ViteResolvedConfig = { @@ -22,8 +27,8 @@ export function clerkAgentRulesVitePlugin(sdk: Extract Date: Tue, 27 Oct 2026 15:43:35 -0400 Subject: [PATCH 21/31] fix(electron): keep the app path lookup silent when agent rules are skipped Co-Authored-By: Claude Opus 5.5 --- packages/electron/src/main/agent-rules.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/electron/src/main/agent-rules.ts b/packages/electron/src/main/agent-rules.ts index 5648d00679c..75cb2c02e1b 100644 --- a/packages/electron/src/main/agent-rules.ts +++ b/packages/electron/src/main/agent-rules.ts @@ -1,4 +1,3 @@ -import { logClerkAgentRulesError } from '@clerk/shared/agentRules'; import { createClerkAgentRulesWriter } from '@clerk/shared/agentRulesWriter'; import { app } from 'electron'; @@ -11,7 +10,7 @@ export function writeAgentRulesOnce() { try { writeAgentRules(app.getAppPath()); - } catch (error) { - logClerkAgentRulesError(error); + } catch { + return; } } From bb7198a2505d950ecc7d421975785b3a6e6546f6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 14:15:36 -0400 Subject: [PATCH 22/31] fix(shared): trim CLERK_AGENTS_MD before checking the opt-out Co-Authored-By: Claude Opus 5.5 --- .../shared/src/__tests__/runtimeEnvironment.spec.ts | 13 ++++++++----- packages/shared/src/utils/runtimeEnvironment.ts | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index 9d9dc5b03c0..77b4e9d9901 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -100,11 +100,14 @@ describe('isClerkAgentRulesWriteAllowed', () => { expect(isClerkAgentRulesWriteAllowed()).toBe(false); }); - it.each(['false', 'FALSE', '0', 'off', 'no'])('blocks writes when CLERK_AGENTS_MD is %s', value => { - vi.stubEnv('CLERK_AGENTS_MD', value); - - expect(isClerkAgentRulesWriteAllowed()).toBe(false); - }); + it.each(['false', 'FALSE', '0', 'off', 'no', ' false', 'false '])( + 'blocks writes when CLERK_AGENTS_MD is %s', + value => { + vi.stubEnv('CLERK_AGENTS_MD', value); + + expect(isClerkAgentRulesWriteAllowed()).toBe(false); + }, + ); it('allows writes when CLERK_AGENTS_MD is unset', () => { vi.stubEnv('CLERK_AGENTS_MD', undefined); diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index cd96dd16dae..235a1eb9e09 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -63,7 +63,7 @@ export const isAutomatedEnvironment = (): boolean => { }; export const isClerkAgentRulesWriteAllowed = (): boolean => { - const optOut = (getEnvVariable('CLERK_AGENTS_MD') || '').toLowerCase(); + const optOut = (getEnvVariable('CLERK_AGENTS_MD') || '').trim().toLowerCase(); return ( !['0', 'false', 'off', 'no'].includes(optOut) && !isProductionEnvironment() && From 0c4b54292140aa647d72989a896d36d97ddbd948 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 14:15:48 -0400 Subject: [PATCH 23/31] fix(nextjs): skip agent rules silently on the Edge runtime Co-Authored-By: Claude Opus 5.5 --- .../server/__tests__/agent-rules-node.test.ts | 22 +++++++++++++++++-- .../nextjs/src/server/agent-rules-node.ts | 16 +++++++------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts index ead72cbbe4a..4624650e662 100644 --- a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts +++ b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts @@ -6,17 +6,19 @@ vi.mock('@clerk/shared/agentRules', async importOriginal => ({ ...(await importOriginal()), writeClerkAgentRules: vi.fn(), })); -vi.mock('../fs/utils', () => ({ + +const nodeApis = { nodeCwdOrThrow: () => () => '/app', nodeFsOrThrow: () => ({}), nodePathOrThrow: () => ({}), -})); +}; const loadWriteAgentRulesOnce = async () => (await import('../agent-rules-node.js')).writeAgentRulesOnce; describe('writeAgentRulesOnce', () => { beforeEach(() => { vi.resetModules(); + vi.doMock('../fs/utils', () => nodeApis); vi.mocked(writeClerkAgentRules).mockReset(); for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); @@ -27,6 +29,7 @@ describe('writeAgentRulesOnce', () => { afterEach(() => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it('writes the rules once per process in development, with or without an AI agent', async () => { @@ -63,6 +66,21 @@ describe('writeAgentRulesOnce', () => { expect(writeClerkAgentRules).not.toHaveBeenCalled(); }); + it('skips without a warning on the Edge runtime, where Node APIs are missing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.doMock('../fs/utils', () => ({ + ...nodeApis, + nodeCwdOrThrow: () => { + throw new Error("Clerk: cwd is missing. This is an internal error. Please contact Clerk's support."); + }, + })); + + (await loadWriteAgentRulesOnce())(); + + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + it('does not throw when writing fails', async () => { vi.mocked(writeClerkAgentRules).mockImplementation(() => { throw new Error('EACCES'); diff --git a/packages/nextjs/src/server/agent-rules-node.ts b/packages/nextjs/src/server/agent-rules-node.ts index 92116164870..3a49ab9da6f 100644 --- a/packages/nextjs/src/server/agent-rules-node.ts +++ b/packages/nextjs/src/server/agent-rules-node.ts @@ -12,15 +12,15 @@ export function writeAgentRulesOnce() { didWrite = true; + let node; try { - logClerkAgentRulesResult( - writeClerkAgentRules({ - cwd: nodeCwdOrThrow()(), - fs: nodeFsOrThrow(), - path: nodePathOrThrow(), - sdks: ['nextjs'], - }), - ); + node = { cwd: nodeCwdOrThrow()(), fs: nodeFsOrThrow(), path: nodePathOrThrow() }; + } catch { + return; + } + + try { + logClerkAgentRulesResult(writeClerkAgentRules({ ...node, sdks: ['nextjs'] })); } catch (error) { logClerkAgentRulesError(error); } From f4188ef1e395e1ed48cb673fc159f93a08503f70 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 14:16:20 -0400 Subject: [PATCH 24/31] fix(shared): write AGENTS.md next to the app's package.json, not the workspace root Co-Authored-By: Claude Opus 5.5 --- .changeset/write-agents-md-in-dev.md | 2 +- .../shared/src/__tests__/agentRules.spec.ts | 29 +++++++++++++++++-- packages/shared/src/agentRules/index.ts | 18 ++---------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index d16d192b89e..7a31386f250 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development, Clerk now adds a Clerk section to the project's root `AGENTS.md`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 0eded3e381b..5331671601f 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -92,9 +92,10 @@ describe('writeClerkAgentRules', () => { expect(memory.read('/repo/src/AGENTS.md')).toBeUndefined(); }); - it('targets the workspace root when one exists', () => { + it('targets the app package instead of the workspace root', () => { const memory = createMemoryFs({ '/repo/pnpm-workspace.yaml': 'packages:\n - apps/*\n', + '/repo/apps/web/package.json': '{}', }); writeClerkAgentRules({ @@ -104,8 +105,30 @@ describe('writeClerkAgentRules', () => { sdks: ['react'], }); - expect(memory.read('/repo/AGENTS.md')).toContain(''); - expect(memory.read('/repo/apps/web/AGENTS.md')).toBeUndefined(); + expect(memory.read('/repo/apps/web/AGENTS.md')).toContain(''); + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + }); + + it('points to an SDK AGENTS.md that exists when the SDK is installed in a pnpm workspace app', () => { + const memory = createMemoryFs({ + '/repo/pnpm-workspace.yaml': 'packages:\n - apps/*\n', + '/repo/package.json': '{"name":"root","private":true}', + '/repo/apps/web/package.json': '{"name":"web","dependencies":{"@clerk/nextjs":"*"}}', + '/repo/apps/web/node_modules/@clerk/nextjs/AGENTS.md': '# Clerk Next.js', + }); + + const { files } = writeClerkAgentRules({ + cwd: '/repo/apps/web', + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + const agentsMdPath = files.find(file => file.endsWith('/AGENTS.md')) ?? ''; + const pointer = memory.read(agentsMdPath)?.match(/`(node_modules\/@clerk\/nextjs\/AGENTS\.md)`/)?.[1] ?? ''; + + expect(pointer).toBe('node_modules/@clerk/nextjs/AGENTS.md'); + expect(memory.fs.existsSync(pathAdapter.join(pathAdapter.dirname(agentsMdPath), pointer))).toBe(true); }); it('replaces only Clerk blocks and preserves surrounding content', () => { diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 1e6c9a6b477..3597c0c46f2 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -186,30 +186,16 @@ function upsertMarkedBlock( function findTargetDir(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { let current = path.resolve(cwd); const root = path.parse(current).root; - let nearestPackageDir: string | null = null; while (current !== root) { - if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + if (fs.existsSync(path.join(current, 'package.json'))) { return current; } - const packageJson = path.join(current, 'package.json'); - if (fs.existsSync(packageJson)) { - nearestPackageDir ??= current; - try { - const pkg = JSON.parse(fs.readFileSync(packageJson, { encoding: 'utf8' })); - if (pkg.workspaces) { - return current; - } - } catch { - // Ignore invalid package.json while walking upward. - } - } - current = path.dirname(current); } - return nearestPackageDir; + return null; } function pickTargetFile(fs: FileSystemAdapter, path: PathAdapter, targetDir: string) { From 81fc3bbedd221efe7ac5a2597f738ad422a96593 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 14:17:00 -0400 Subject: [PATCH 25/31] refactor(shared,upgrade): stop creating and editing CLAUDE.md Claude Code reads AGENTS.md directly, so the writer only updates AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- .changeset/write-agents-md-in-dev.md | 2 +- .../shared/src/__tests__/agentRules.spec.ts | 119 +++--------------- .../src/__tests__/agentRulesWriter.spec.ts | 2 +- packages/shared/src/__tests__/vite.spec.ts | 2 +- packages/shared/src/agentRules/index.ts | 74 ++--------- .../src/__tests__/integration/cli.test.js | 4 +- packages/upgrade/src/cli.js | 2 +- 7 files changed, 30 insertions(+), 175 deletions(-) diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index 7a31386f250..2dabb3265ea 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. It also creates a `CLAUDE.md` that imports `AGENTS.md`, or adds an `@AGENTS.md` import to an existing `CLAUDE.md` that doesn't load it. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 5331671601f..b98bb89f6ef 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -56,7 +56,7 @@ describe('writeClerkAgentRules', () => { process.env.NODE_ENV = 'test'; }); - it('creates AGENTS.md and CLAUDE.md when neither exists', () => { + it('creates AGENTS.md when it does not exist', () => { const memory = createMemoryFs({ '/repo/app/package.json': '{}' }); const result = writeClerkAgentRules({ @@ -67,10 +67,18 @@ describe('writeClerkAgentRules', () => { }); expect(result.agentsMd).toBe('created'); - expect(result.claudeMd).toBe('created'); expect(memory.read('/repo/app/AGENTS.md')).toContain(''); - expect(memory.read('/repo/app/CLAUDE.md')).toBe('@AGENTS.md\n'); - expect(result.files).toEqual(['/repo/app/AGENTS.md', '/repo/app/CLAUDE.md']); + expect(memory.read('/repo/app/CLAUDE.md')).toBeUndefined(); + expect(result.files).toEqual(['/repo/app/AGENTS.md']); + }); + + it('leaves CLAUDE.md alone', () => { + const memory = createMemoryFs({ '/repo/CLAUDE.md': 'Claude rules\n' }); + + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\n'); + expect(memory.read('/repo/AGENTS.md')).toContain(''); }); it('writes nothing when no package.json is at or above the starting folder', () => { @@ -78,9 +86,8 @@ describe('writeClerkAgentRules', () => { const result = writeClerkAgentRules({ cwd: '/home/user', fs: memory.fs, path: pathAdapter, sdks: ['express'] }); - expect(result).toEqual({ targetDir: '/home/user', agentsMd: 'skipped', claudeMd: 'skipped', files: [] }); + expect(result).toEqual({ targetDir: '/home/user', agentsMd: 'skipped', files: [] }); expect(memory.read('/home/user/AGENTS.md')).toBeUndefined(); - expect(memory.read('/home/user/CLAUDE.md')).toBeUndefined(); }); it('targets the nearest folder with a package.json when started in a subfolder', () => { @@ -211,99 +218,6 @@ describe('writeClerkAgentRules with an unmatched BEGIN marker', () => { }); }); -describe('writeClerkAgentRules with an existing CLAUDE.md', () => { - it('adds an @AGENTS.md import when CLAUDE.md does not load AGENTS.md', () => { - const memory = createMemoryFs({ - '/repo/AGENTS.md': 'Agent rules\n', - '/repo/CLAUDE.md': 'Claude rules\n', - }); - - const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); - - expect(result.claudeMd).toBe('updated'); - expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\n\n@AGENTS.md\n'); - expect(memory.read('/repo/AGENTS.md')).toContain(''); - }); - - it('adds the import only once across runs', () => { - const memory = createMemoryFs({ - '/repo/AGENTS.md': 'Agent rules\n', - '/repo/CLAUDE.md': 'Claude rules\n', - }); - - writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); - const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); - - expect(result.claudeMd).toBe('unchanged'); - expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\n\n@AGENTS.md\n'); - }); - - it.each(['@AGENTS.md', '@./AGENTS.md'])('leaves CLAUDE.md alone when it already imports %s', importLine => { - const claude = `Claude rules\n\n${importLine}\n`; - const memory = createMemoryFs({ - '/repo/AGENTS.md': 'Agent rules\n', - '/repo/CLAUDE.md': claude, - }); - - const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); - - expect(result.claudeMd).toBe('unchanged'); - expect(memory.read('/repo/CLAUDE.md')).toBe(claude); - }); - - it('leaves CLAUDE.md alone when it mirrors AGENTS.md, as a symlink does', () => { - const memory = createMemoryFs({ - '/repo/AGENTS.md': 'Shared rules\n', - '/repo/CLAUDE.md': 'Shared rules\n', - }); - - const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); - - expect(result.claudeMd).toBe('unchanged'); - expect(memory.read('/repo/CLAUDE.md')).toBe('Shared rules\n'); - }); - - it('writes the Clerk block into CLAUDE.md and skips the import when only CLAUDE.md exists', () => { - const memory = createMemoryFs({ '/repo/CLAUDE.md': 'Claude rules\n' }); - - const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); - - expect(result.agentsMd).toBe('updated'); - expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); - expect(memory.read('/repo/CLAUDE.md')).toContain(''); - expect(memory.read('/repo/CLAUDE.md')).not.toContain('@AGENTS.md'); - }); - - it('keeps CRLF line endings in CLAUDE.md', () => { - const memory = createMemoryFs({ - '/repo/AGENTS.md': 'Agent rules\r\n', - '/repo/CLAUDE.md': 'Claude rules\r\n', - }); - - writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); - - expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\r\n\r\n@AGENTS.md\r\n'); - }); - - it('reports the import but does not write it in dry-run mode', () => { - const memory = createMemoryFs({ - '/repo/AGENTS.md': 'Agent rules\n', - '/repo/CLAUDE.md': 'Claude rules\n', - }); - - const result = writeClerkAgentRules({ - cwd: '/repo', - dryRun: true, - fs: memory.fs, - path: pathAdapter, - sdks: ['nextjs'], - }); - - expect(result.claudeMd).toBe('updated'); - expect(memory.read('/repo/CLAUDE.md')).toBe('Claude rules\n'); - }); -}); - describe('isClerkAgentRulesSdk', () => { it('accepts supported SDKs', () => { expect(isClerkAgentRulesSdk('nextjs')).toBe(true); @@ -322,12 +236,11 @@ describe('logClerkAgentRulesResult', () => { logClerkAgentRulesResult({ targetDir: '/repo', agentsMd: 'created', - claudeMd: 'created', - files: ['/repo/AGENTS.md', '/repo/CLAUDE.md'], + files: ['/repo/AGENTS.md'], }); expect(log).toHaveBeenCalledWith( - '[Clerk]: Updated /repo/AGENTS.md and /repo/CLAUDE.md for AI coding agents. Set CLERK_AGENTS_MD=false to turn this off.', + '[Clerk]: Updated /repo/AGENTS.md for AI coding agents. Set CLERK_AGENTS_MD=false to turn this off.', ); log.mockRestore(); }); @@ -335,7 +248,7 @@ describe('logClerkAgentRulesResult', () => { it('stays quiet when nothing changed', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - logClerkAgentRulesResult({ targetDir: '/repo', agentsMd: 'unchanged', claudeMd: 'unchanged', files: [] }); + logClerkAgentRulesResult({ targetDir: '/repo', agentsMd: 'unchanged', files: [] }); expect(log).not.toHaveBeenCalled(); log.mockRestore(); diff --git a/packages/shared/src/__tests__/agentRulesWriter.spec.ts b/packages/shared/src/__tests__/agentRulesWriter.spec.ts index 0a985ecca2d..c93e5d61d68 100644 --- a/packages/shared/src/__tests__/agentRulesWriter.spec.ts +++ b/packages/shared/src/__tests__/agentRulesWriter.spec.ts @@ -14,7 +14,7 @@ describe('createClerkAgentRulesWriter', () => { beforeEach(() => { vi.mocked(writeClerkAgentRules) .mockReset() - .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', claudeMd: 'unchanged', files: [] }); + .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', files: [] }); for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } diff --git a/packages/shared/src/__tests__/vite.spec.ts b/packages/shared/src/__tests__/vite.spec.ts index b1a939a0c0f..962f3d2b735 100644 --- a/packages/shared/src/__tests__/vite.spec.ts +++ b/packages/shared/src/__tests__/vite.spec.ts @@ -14,7 +14,7 @@ describe('clerkAgentRulesVitePlugin', () => { beforeEach(() => { vi.mocked(writeClerkAgentRules) .mockReset() - .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', claudeMd: 'unchanged', files: [] }); + .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', files: [] }); for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 3597c0c46f2..e3a0f29ada7 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -40,7 +40,6 @@ export interface WriteClerkAgentRulesOptions { export type WriteClerkAgentRulesResult = { targetDir: string; agentsMd: 'created' | 'updated' | 'unchanged' | 'skipped'; - claudeMd: 'created' | 'updated' | 'unchanged' | 'skipped'; files: string[]; }; @@ -74,13 +73,6 @@ const uniqueSdks = (sdks: ClerkAgentRulesSdk[]) => [...new Set(sdks)].sort(); const detectEol = (content: string) => (content.includes('\r\n') ? '\r\n' : '\n'); const normalizeEol = (content: string, eol: string) => content.replace(/\r?\n/g, eol); -const CLAUDE_AGENTS_IMPORT = /^\s*@(?:\.\/)?AGENTS\.md\s*$/m; - -const appendAgentsImport = (content: string) => { - const eol = detectEol(content); - return content.trim() ? `${content.trimEnd()}${eol}${eol}@AGENTS.md${eol}` : `@AGENTS.md${eol}`; -}; - export const isClerkAgentRulesSdk = (value: string): value is ClerkAgentRulesSdk => Object.prototype.hasOwnProperty.call(SDK_LABELS, value); @@ -198,38 +190,17 @@ function findTargetDir(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { return null; } -function pickTargetFile(fs: FileSystemAdapter, path: PathAdapter, targetDir: string) { - const agentsPath = path.join(targetDir, 'AGENTS.md'); - const claudePath = path.join(targetDir, 'CLAUDE.md'); - - for (const filePath of [agentsPath, claudePath]) { - if (fs.existsSync(filePath) && fs.readFileSync(filePath, { encoding: 'utf8' }).includes(GENERIC_START)) { - return filePath; - } - } - - if (fs.existsSync(agentsPath)) { - return agentsPath; - } - - if (fs.existsSync(claudePath)) { - return claudePath; - } - - return agentsPath; -} - export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): WriteClerkAgentRulesResult { const { dryRun = false, fs, path, sdks } = options; const targetDir = findTargetDir(fs, path, options.cwd); if (!targetDir) { - return { targetDir: path.resolve(options.cwd), agentsMd: 'skipped', claudeMd: 'skipped', files: [] }; + return { targetDir: path.resolve(options.cwd), agentsMd: 'skipped', files: [] }; } - const targetFile = pickTargetFile(fs, path, targetDir); const agentsPath = path.join(targetDir, 'AGENTS.md'); - const claudePath = path.join(targetDir, 'CLAUDE.md'); - const existing = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, { encoding: 'utf8' }) : ''; + const agentsExists = fs.existsSync(agentsPath); + const existing = agentsExists ? fs.readFileSync(agentsPath, { encoding: 'utf8' }) : ''; + const eol = detectEol(existing); let next = upsertMarkedBlock(existing, renderClerkAgentRulesBlock(eol), GENERIC_START, GENERIC_END, eol); @@ -250,45 +221,16 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ } } - const targetStatus = fs.existsSync(targetFile) ? (next === existing ? 'unchanged' : 'updated') : 'created'; - const claudeExists = fs.existsSync(claudePath); - const shouldCreateClaude = targetFile === agentsPath && !claudeExists; - const claudeContent = - targetFile === agentsPath && claudeExists ? fs.readFileSync(claudePath, { encoding: 'utf8' }) : null; - const shouldImportAgentsInClaude = - claudeContent !== null && - claudeContent !== existing && - !claudeContent.includes(GENERIC_START) && - !CLAUDE_AGENTS_IMPORT.test(claudeContent); + const agentsMd = agentsExists ? (next === existing ? 'unchanged' : 'updated') : 'created'; if (!dryRun) { fs.mkdirSync?.(targetDir, { recursive: true }); - if (targetStatus !== 'unchanged') { - fs.writeFileSync(targetFile, next, { encoding: 'utf8', mode: 0o644 }); - } - if (shouldCreateClaude) { - fs.writeFileSync(claudePath, '@AGENTS.md\n', { encoding: 'utf8', mode: 0o644 }); - } - if (shouldImportAgentsInClaude && claudeContent !== null) { - fs.writeFileSync(claudePath, appendAgentsImport(claudeContent), { encoding: 'utf8', mode: 0o644 }); + if (agentsMd !== 'unchanged') { + fs.writeFileSync(agentsPath, next, { encoding: 'utf8', mode: 0o644 }); } } - return { - targetDir, - agentsMd: targetStatus, - claudeMd: shouldCreateClaude - ? 'created' - : shouldImportAgentsInClaude - ? 'updated' - : claudeExists - ? 'unchanged' - : 'skipped', - files: [ - ...(targetStatus === 'unchanged' ? [] : [targetFile]), - ...(shouldCreateClaude || shouldImportAgentsInClaude ? [claudePath] : []), - ], - }; + return { targetDir, agentsMd, files: agentsMd === 'unchanged' ? [] : [agentsPath] }; } export function logClerkAgentRulesResult({ files }: WriteClerkAgentRulesResult) { diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index 11c714def5a..91efa28c584 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -186,7 +186,7 @@ describe('CLI Integration', () => { fixture?.cleanup(); }); - it('writes AGENTS.md and CLAUDE.md without running SDK upgrade detection', async () => { + it('writes AGENTS.md without running SDK upgrade detection', async () => { const fs = await import('node:fs'); const result = await runCli(['--dir', fixture.path, '--agents-md'], { timeout: 15000 }); @@ -195,7 +195,7 @@ describe('CLI Integration', () => { expect(fs.readFileSync(path.join(fixture.path, 'AGENTS.md'), 'utf8')).toContain( '', ); - expect(fs.readFileSync(path.join(fixture.path, 'CLAUDE.md'), 'utf8')).toBe('@AGENTS.md\n'); + expect(fs.existsSync(path.join(fixture.path, 'CLAUDE.md'))).toBe(false); }); it('does not write files in dry-run mode', async () => { diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 00d76fea1c3..14999cb6f4c 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -301,7 +301,7 @@ function writeAgentsMd(options, detectedSdks) { renderText(`${prefix} Clerk AGENTS.md sections for ${sdks.map(s => `@clerk/${s}`).join(', ')}`, 'blue'); renderText(`Target: ${result.targetDir}`); - renderText(`AGENTS.md: ${result.agentsMd}; CLAUDE.md: ${result.claudeMd}`); + renderText(`AGENTS.md: ${result.agentsMd}`); renderNewline(); } From 49881b1234ff31be4038dce5a28fb4f3b7e609b0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 14:17:27 -0400 Subject: [PATCH 26/31] feat(shared,upgrade): keep the Clerk section out of AGENTS.md once the user removes it The writer records its first write in node_modules/.cache/clerk and skips later runs when the section is gone. clerk-upgrade --agents-md still forces a write. Co-Authored-By: Claude Opus 5.5 --- .changeset/write-agents-md-in-dev.md | 2 +- .../shared/src/__tests__/agentRules.spec.ts | 60 ++++++++++++++++++- packages/shared/src/agentRules/index.ts | 15 ++++- packages/upgrade/src/util/agent-rules.js | 2 +- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index 2dabb3265ea..2375d578ce4 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. If you remove the section, Clerk doesn't add it back on that machine. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index b98bb89f6ef..94372e63d6b 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -218,6 +218,64 @@ describe('writeClerkAgentRules with an unmatched BEGIN marker', () => { }); }); +describe('writeClerkAgentRules after the user removes the Clerk section', () => { + const statePath = '/repo/node_modules/.cache/clerk/agent-rules'; + + it('records that it wrote the section', () => { + const memory = createMemoryFs(); + + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(memory.read(statePath)).toBe(''); + }); + + it('does not add the section back once it was removed', () => { + const memory = createMemoryFs(); + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + memory.fs.writeFileSync('/repo/AGENTS.md', 'Project rules\n', { encoding: 'utf8' }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result).toEqual({ targetDir: '/repo', agentsMd: 'skipped', files: [] }); + expect(memory.read('/repo/AGENTS.md')).toBe('Project rules\n'); + }); + + it('keeps updating the section while it is still there', () => { + const memory = createMemoryFs({ + '/repo/AGENTS.md': '\nold\n\n', + [statePath]: '', + }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.agentsMd).toBe('updated'); + expect(memory.read('/repo/AGENTS.md')).toContain(''); + }); + + it('adds the section back when forced', () => { + const memory = createMemoryFs({ '/repo/AGENTS.md': 'Project rules\n', [statePath]: '' }); + + const result = writeClerkAgentRules({ + cwd: '/repo', + force: true, + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + expect(result.agentsMd).toBe('updated'); + expect(memory.read('/repo/AGENTS.md')).toContain(''); + }); + + it('records nothing in dry-run mode', () => { + const memory = createMemoryFs(); + + writeClerkAgentRules({ cwd: '/repo', dryRun: true, fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(memory.read(statePath)).toBeUndefined(); + }); +}); + describe('isClerkAgentRulesSdk', () => { it('accepts supported SDKs', () => { expect(isClerkAgentRulesSdk('nextjs')).toBe(true); @@ -240,7 +298,7 @@ describe('logClerkAgentRulesResult', () => { }); expect(log).toHaveBeenCalledWith( - '[Clerk]: Updated /repo/AGENTS.md for AI coding agents. Set CLERK_AGENTS_MD=false to turn this off.', + '[Clerk]: Updated /repo/AGENTS.md for AI coding agents. Remove the Clerk section from AGENTS.md or set CLERK_AGENTS_MD=false to turn this off.', ); log.mockRestore(); }); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index e3a0f29ada7..5970ce9e71d 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -32,6 +32,7 @@ export interface PathAdapter { export interface WriteClerkAgentRulesOptions { cwd: string; dryRun?: boolean; + force?: boolean; fs: FileSystemAdapter; path: PathAdapter; sdks: ClerkAgentRulesSdk[]; @@ -191,16 +192,22 @@ function findTargetDir(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { } export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): WriteClerkAgentRulesResult { - const { dryRun = false, fs, path, sdks } = options; + const { dryRun = false, force = false, fs, path, sdks } = options; const targetDir = findTargetDir(fs, path, options.cwd); if (!targetDir) { return { targetDir: path.resolve(options.cwd), agentsMd: 'skipped', files: [] }; } const agentsPath = path.join(targetDir, 'AGENTS.md'); + const stateDir = path.join(targetDir, 'node_modules', '.cache', 'clerk'); + const statePath = path.join(stateDir, 'agent-rules'); const agentsExists = fs.existsSync(agentsPath); const existing = agentsExists ? fs.readFileSync(agentsPath, { encoding: 'utf8' }) : ''; + if (!force && !existing.includes(GENERIC_START) && fs.existsSync(statePath)) { + return { targetDir, agentsMd: 'skipped', files: [] }; + } + const eol = detectEol(existing); let next = upsertMarkedBlock(existing, renderClerkAgentRulesBlock(eol), GENERIC_START, GENERIC_END, eol); @@ -228,6 +235,10 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ if (agentsMd !== 'unchanged') { fs.writeFileSync(agentsPath, next, { encoding: 'utf8', mode: 0o644 }); } + if (!fs.existsSync(statePath)) { + fs.mkdirSync?.(stateDir, { recursive: true }); + fs.writeFileSync(statePath, '', { encoding: 'utf8', mode: 0o644 }); + } } return { targetDir, agentsMd, files: agentsMd === 'unchanged' ? [] : [agentsPath] }; @@ -236,7 +247,7 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ export function logClerkAgentRulesResult({ files }: WriteClerkAgentRulesResult) { if (files.length > 0) { console.log( - `[Clerk]: Updated ${files.join(' and ')} for AI coding agents. Set CLERK_AGENTS_MD=false to turn this off.`, + `[Clerk]: Updated ${files.join(' and ')} for AI coding agents. Remove the Clerk section from AGENTS.md or set CLERK_AGENTS_MD=false to turn this off.`, ); } } diff --git a/packages/upgrade/src/util/agent-rules.js b/packages/upgrade/src/util/agent-rules.js index f515080530b..b7a1e31ce92 100644 --- a/packages/upgrade/src/util/agent-rules.js +++ b/packages/upgrade/src/util/agent-rules.js @@ -23,5 +23,5 @@ export function detectClerkAgentRuleSdks(dir) { } export function writeClerkAgentRules({ dir, dryRun = false, sdks = detectClerkAgentRuleSdks(dir) }) { - return { ...writeRules({ cwd: dir, dryRun, fs, path, sdks }), sdks }; + return { ...writeRules({ cwd: dir, dryRun, force: true, fs, path, sdks }), sdks }; } From a6989230714d4fb14da7990de62dfab0244bd5e4 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 14:18:00 -0400 Subject: [PATCH 27/31] feat(*): write Clerk agent rules only when an AI coding agent is detected Restores isAgentEnvironment() and extends it to match @vercel/detect-agent's env signals (Codex, Gemini CLI, OpenCode, Augment, Antigravity, Replit, Copilot CLI). A dev run without an agent records nothing, so the first later run under an agent still writes AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- .../chrome-extension-agent-rules-vite.md | 2 +- .changeset/react-vue-agent-rules-vite.md | 2 +- .changeset/shared-agent-rules.md | 2 +- .changeset/write-agents-md-in-dev.md | 2 +- .../integration/__tests__/agent-rules.test.ts | 5 +- .../__tests__/appPlugin.agentRules.test.js | 5 +- .../server/__tests__/agent-rules-node.test.ts | 19 +++++-- .../nuxt/src/__tests__/agent-rules.test.ts | 5 +- .../src/__tests__/agentRulesWriter.spec.ts | 5 +- .../src/__tests__/runtimeEnvironment.spec.ts | 49 ++++++++++++++++++- packages/shared/src/__tests__/vite.spec.ts | 5 +- .../shared/src/utils/runtimeEnvironment.ts | 36 +++++++++++++- 12 files changed, 117 insertions(+), 20 deletions(-) diff --git a/.changeset/chrome-extension-agent-rules-vite.md b/.changeset/chrome-extension-agent-rules-vite.md index c0519885406..9756256eae1 100644 --- a/.changeset/chrome-extension-agent-rules-vite.md +++ b/.changeset/chrome-extension-agent-rules-vite.md @@ -2,7 +2,7 @@ '@clerk/chrome-extension': minor --- -Add a Vite plugin that writes a Clerk section to the project's `AGENTS.md` when the Vite dev server starts, for extensions built with WXT, CRXJS, or a custom Vite setup. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. +Add a Vite plugin that writes a Clerk section to the project's `AGENTS.md` when the Vite dev server starts under an AI coding agent, for extensions built with WXT, CRXJS, or a custom Vite setup. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. ```ts // wxt.config.ts diff --git a/.changeset/react-vue-agent-rules-vite.md b/.changeset/react-vue-agent-rules-vite.md index fe94fdb6157..7392e49c6ba 100644 --- a/.changeset/react-vue-agent-rules-vite.md +++ b/.changeset/react-vue-agent-rules-vite.md @@ -3,7 +3,7 @@ '@clerk/vue': minor --- -Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server starts. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. +Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server starts under an AI coding agent. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. ```ts import { clerkReactAgentRules } from '@clerk/react/vite'; diff --git a/.changeset/shared-agent-rules.md b/.changeset/shared-agent-rules.md index ff9983c86a0..c8b64d845b5 100644 --- a/.changeset/shared-agent-rules.md +++ b/.changeset/shared-agent-rules.md @@ -2,4 +2,4 @@ '@clerk/shared': minor --- -Add `@clerk/shared/agentRules`, `@clerk/shared/agentRulesWriter`, and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`, which returns `false` when `NODE_ENV=production`, in tests, in CI, or when `CLERK_AGENTS_MD=false`. Deployments that don't set `NODE_ENV=production` should set `CLERK_AGENTS_MD=false` to turn writes off. +Add `@clerk/shared/agentRules`, `@clerk/shared/agentRulesWriter`, and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isAgentEnvironment()` and `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`. `isClerkAgentRulesWriteAllowed()` returns `false` when no AI coding agent is detected, when `NODE_ENV=production`, in tests, in CI, or when `CLERK_AGENTS_MD=false`. Deployments that don't set `NODE_ENV=production` should set `CLERK_AGENTS_MD=false` to turn writes off. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index 2375d578ce4..12e56a5a45b 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. If you remove the section, Clerk doesn't add it back on that machine. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development and an AI coding agent such as Claude Code, Codex, Cursor, Gemini CLI, or GitHub Copilot is detected, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. If you remove the section, Clerk doesn't add it back on that machine. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/astro/src/integration/__tests__/agent-rules.test.ts b/packages/astro/src/integration/__tests__/agent-rules.test.ts index 0ff0d61427c..df0fc1920f1 100644 --- a/packages/astro/src/integration/__tests__/agent-rules.test.ts +++ b/packages/astro/src/integration/__tests__/agent-rules.test.ts @@ -1,6 +1,6 @@ import type * as AgentRules from '@clerk/shared/agentRules'; import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@clerk/shared/agentRules', async importOriginal => ({ @@ -14,11 +14,12 @@ describe('writeAgentRulesOnce', () => { beforeEach(() => { vi.resetModules(); vi.mocked(writeClerkAgentRules).mockReset(); - for (const name of automatedEnvironmentVariables) { + for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/expo/src/__tests__/appPlugin.agentRules.test.js b/packages/expo/src/__tests__/appPlugin.agentRules.test.js index 90ee8c0cfaa..2f5401661dd 100644 --- a/packages/expo/src/__tests__/appPlugin.agentRules.test.js +++ b/packages/expo/src/__tests__/appPlugin.agentRules.test.js @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; -import { automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; const nodeRequire = createRequire(import.meta.url); @@ -20,11 +20,12 @@ describe('maybeWriteAgentRules', () => { beforeEach(() => { projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-agent-rules-')); fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); - for (const name of automatedEnvironmentVariables) { + for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts index 4624650e662..99885ef2730 100644 --- a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts +++ b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts @@ -1,5 +1,5 @@ import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@clerk/shared/agentRules', async importOriginal => ({ @@ -20,11 +20,12 @@ describe('writeAgentRulesOnce', () => { vi.resetModules(); vi.doMock('../fs/utils', () => nodeApis); vi.mocked(writeClerkAgentRules).mockReset(); - for (const name of automatedEnvironmentVariables) { + for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { @@ -32,7 +33,7 @@ describe('writeAgentRulesOnce', () => { vi.restoreAllMocks(); }); - it('writes the rules once per process in development, with or without an AI agent', async () => { + it('writes the rules once per process in development when an AI agent is detected', async () => { const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); writeAgentRulesOnce(); @@ -42,6 +43,18 @@ describe('writeAgentRulesOnce', () => { expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app', sdks: ['nextjs'] })); }); + it('skips without an AI agent, then writes once one is detected', async () => { + vi.stubEnv('CLAUDECODE', ''); + const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); + + writeAgentRulesOnce(); + expect(writeClerkAgentRules).not.toHaveBeenCalled(); + + vi.stubEnv('CLAUDECODE', '1'); + writeAgentRulesOnce(); + expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); + }); + it('skips in CI', async () => { vi.stubEnv('CI', 'true'); diff --git a/packages/nuxt/src/__tests__/agent-rules.test.ts b/packages/nuxt/src/__tests__/agent-rules.test.ts index 90c11508dab..f55d4d1dad3 100644 --- a/packages/nuxt/src/__tests__/agent-rules.test.ts +++ b/packages/nuxt/src/__tests__/agent-rules.test.ts @@ -1,6 +1,6 @@ import type * as AgentRules from '@clerk/shared/agentRules'; import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@clerk/shared/agentRules', async importOriginal => ({ @@ -14,11 +14,12 @@ describe('writeAgentRulesOnce', () => { beforeEach(() => { vi.resetModules(); vi.mocked(writeClerkAgentRules).mockReset(); - for (const name of automatedEnvironmentVariables) { + for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/shared/src/__tests__/agentRulesWriter.spec.ts b/packages/shared/src/__tests__/agentRulesWriter.spec.ts index c93e5d61d68..c5ab45110be 100644 --- a/packages/shared/src/__tests__/agentRulesWriter.spec.ts +++ b/packages/shared/src/__tests__/agentRulesWriter.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as AgentRules from '../agentRules'; import { writeClerkAgentRules } from '../agentRules'; import { createClerkAgentRulesWriter } from '../agentRulesWriter'; -import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; +import { agentEnvironmentVariables, automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; vi.mock('../agentRules', async importOriginal => ({ ...(await importOriginal()), @@ -15,11 +15,12 @@ describe('createClerkAgentRulesWriter', () => { vi.mocked(writeClerkAgentRules) .mockReset() .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', files: [] }); - for (const name of automatedEnvironmentVariables) { + for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index 77b4e9d9901..df1f9479112 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + agentEnvironmentVariables, automatedEnvironmentVariables, + isAgentEnvironment, isAutomatedEnvironment, isClerkAgentRulesWriteAllowed, } from '../utils/runtimeEnvironment'; @@ -65,11 +67,48 @@ describe('isAutomatedEnvironment', () => { }); }); +describe('isAgentEnvironment', () => { + beforeEach(() => { + for (const name of agentEnvironmentVariables) { + vi.stubEnv(name, undefined); + } + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each(['CLAUDECODE', 'CODEX_THREAD_ID', 'GEMINI_CLI', 'OPENCODE', 'AI_AGENT'])( + 'returns true when %s is set', + name => { + vi.stubEnv(name, '1'); + + expect(isAgentEnvironment()).toBe(true); + }, + ); + + it('returns false when agent environment variables are unset or falsey', () => { + vi.stubEnv('CLAUDECODE', 'false'); + vi.stubEnv('CURSOR_AGENT', '0'); + + expect(isAgentEnvironment()).toBe(false); + }); + + it('returns true only when CURSOR_EXTENSION_HOST_ROLE is agent-exec', () => { + vi.stubEnv('CURSOR_EXTENSION_HOST_ROLE', 'user'); + expect(isAgentEnvironment()).toBe(false); + + vi.stubEnv('CURSOR_EXTENSION_HOST_ROLE', 'agent-exec'); + expect(isAgentEnvironment()).toBe(true); + }); +}); + describe('isClerkAgentRulesWriteAllowed', () => { beforeEach(() => { - for (const name of automatedEnvironmentVariables) { + for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } + vi.stubEnv('CLAUDECODE', '1'); vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); }); @@ -78,10 +117,16 @@ describe('isClerkAgentRulesWriteAllowed', () => { vi.unstubAllEnvs(); }); - it('allows writes in development without an AI agent', () => { + it('allows writes in development when an AI agent is detected', () => { expect(isClerkAgentRulesWriteAllowed()).toBe(true); }); + it('blocks writes when no AI agent is detected', () => { + vi.stubEnv('CLAUDECODE', ''); + + expect(isClerkAgentRulesWriteAllowed()).toBe(false); + }); + it('blocks writes in production', () => { vi.stubEnv('NODE_ENV', 'production'); diff --git a/packages/shared/src/__tests__/vite.spec.ts b/packages/shared/src/__tests__/vite.spec.ts index 962f3d2b735..25b7b44267f 100644 --- a/packages/shared/src/__tests__/vite.spec.ts +++ b/packages/shared/src/__tests__/vite.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as AgentRules from '../agentRules'; import { writeClerkAgentRules } from '../agentRules'; -import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; +import { agentEnvironmentVariables, automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; import { clerkAgentRulesVitePlugin } from '../vite'; vi.mock('../agentRules', async importOriginal => ({ @@ -15,11 +15,12 @@ describe('clerkAgentRulesVitePlugin', () => { vi.mocked(writeClerkAgentRules) .mockReset() .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', files: [] }); - for (const name of automatedEnvironmentVariables) { + for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index 235a1eb9e09..789f1d46fe3 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -19,6 +19,32 @@ export const automatedEnvironmentVariables = [ 'CF_PAGES', ] as const; +export const agentEnvironmentVariables = [ + 'AI_AGENT', + 'AGENT', + 'AGENTS', + 'ANTIGRAVITY_AGENT', + 'AUGMENT_AGENT', + 'CLAUDECODE', + 'CLAUDE_CODE', + 'CODEX_CI', + 'CODEX_SANDBOX', + 'CODEX_THREAD_ID', + 'COPILOT_ALLOW_ALL', + 'COPILOT_GITHUB_TOKEN', + 'COPILOT_MODEL', + 'CURSOR_AGENT', + 'CURSOR_CLI', + 'CURSOR_EXTENSION_HOST_ROLE', + 'CURSOR_TRACE_ID', + 'GEMINI_CLI', + 'GITHUB_COPILOT_AGENT', + 'GITHUB_COPILOT_CHAT', + 'OPENCODE', + 'OPENCODE_CLIENT', + 'REPL_ID', +] as const; + const isTruthyEnvValue = (value: unknown): boolean => { if (typeof value !== 'string' || !value) { return false; @@ -62,12 +88,20 @@ export const isAutomatedEnvironment = (): boolean => { return automatedEnvironmentVariables.some(name => isTruthyEnvValue(getEnvVariable(name))); }; +export const isAgentEnvironment = (): boolean => { + return agentEnvironmentVariables.some(name => { + const value = getEnvVariable(name); + return name === 'CURSOR_EXTENSION_HOST_ROLE' ? value === 'agent-exec' : isTruthyEnvValue(value); + }); +}; + export const isClerkAgentRulesWriteAllowed = (): boolean => { const optOut = (getEnvVariable('CLERK_AGENTS_MD') || '').trim().toLowerCase(); return ( !['0', 'false', 'off', 'no'].includes(optOut) && !isProductionEnvironment() && !isTestEnvironment() && - !isAutomatedEnvironment() + !isAutomatedEnvironment() && + isAgentEnvironment() ); }; From df460208a68051084e8f1d41f54421dfed7d2959 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 14:34:24 -0400 Subject: [PATCH 28/31] feat(shared,upgrade): let projects turn off Clerk agent rules in package.json "clerk": { "agentsMd": false } in the app's package.json stops all writes, including clerk-upgrade --agents-md, and removes existing Clerk sections from AGENTS.md. The dev log now names this team-wide opt-out first. Co-Authored-By: Claude Opus 5.5 --- .changeset/upgrade-agents-md.md | 2 +- .changeset/write-agents-md-in-dev.md | 2 +- .../shared/src/__tests__/agentRules.spec.ts | 92 ++++++++++++++++++- packages/shared/src/agentRules/index.ts | 63 ++++++++++++- .../src/__tests__/integration/cli.test.js | 12 +++ packages/upgrade/src/cli.js | 5 + 6 files changed, 169 insertions(+), 7 deletions(-) diff --git a/.changeset/upgrade-agents-md.md b/.changeset/upgrade-agents-md.md index ead62e02cca..0ee0c778eba 100644 --- a/.changeset/upgrade-agents-md.md +++ b/.changeset/upgrade-agents-md.md @@ -2,4 +2,4 @@ '@clerk/upgrade': minor --- -Add an `--agents-md` flag that writes Clerk sections to the project's `AGENTS.md` for each installed Clerk SDK, then exits. Interactive upgrades also offer to write these sections. +Add an `--agents-md` flag that writes Clerk sections to the project's `AGENTS.md` for each installed Clerk SDK, then exits. Interactive upgrades also offer to write these sections. Nothing is written when `package.json` sets `"clerk": { "agentsMd": false }`. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index 12e56a5a45b..a071c580369 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development and an AI coding agent such as Claude Code, Codex, Cursor, Gemini CLI, or GitHub Copilot is detected, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. If you remove the section, Clerk doesn't add it back on that machine. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development and an AI coding agent such as Claude Code, Codex, Cursor, Gemini CLI, or GitHub Copilot is detected, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. If you remove the section, Clerk doesn't add it back on that machine. To turn it off for everyone on a project, add `"clerk": { "agentsMd": false }` to the app's `package.json`. Clerk then removes its section on the next dev run under an agent. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 94372e63d6b..785b2fa1525 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -24,6 +24,9 @@ const createMemoryFs = (files: Record = {}, { withRepoPackageJso mkdirSync: path => { dirs.add(path); }, + unlinkSync: path => { + store.delete(path); + }, }; return { @@ -276,6 +279,83 @@ describe('writeClerkAgentRules after the user removes the Clerk section', () => }); }); +describe('writeClerkAgentRules when package.json sets clerk.agentsMd to false', () => { + const disabledPackageJson = '{"clerk":{"agentsMd":false}}'; + + it('does not create AGENTS.md', () => { + const memory = createMemoryFs({ '/repo/package.json': disabledPackageJson }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result).toEqual({ targetDir: '/repo', agentsMd: 'disabled', files: [] }); + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + }); + + it('removes existing Clerk sections and keeps the rest of the file', () => { + const memory = createMemoryFs(); + memory.fs.writeFileSync('/repo/AGENTS.md', 'Project rules\n', { encoding: 'utf8' }); + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs', 'react'] }); + memory.fs.writeFileSync('/repo/AGENTS.md', `${memory.read('/repo/AGENTS.md')}\nMore rules\n`, { encoding: 'utf8' }); + memory.fs.writeFileSync('/repo/package.json', disabledPackageJson, { encoding: 'utf8' }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result).toEqual({ targetDir: '/repo', agentsMd: 'disabled', files: ['/repo/AGENTS.md'] }); + expect(memory.read('/repo/AGENTS.md')).toBe('Project rules\n\nMore rules\n'); + }); + + it('deletes AGENTS.md when it held only Clerk sections', () => { + const memory = createMemoryFs(); + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + memory.fs.writeFileSync('/repo/package.json', disabledPackageJson, { encoding: 'utf8' }); + + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + }); + + it('wins over a forced write', () => { + const memory = createMemoryFs({ '/repo/package.json': disabledPackageJson }); + + const result = writeClerkAgentRules({ + cwd: '/repo', + force: true, + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + expect(result.agentsMd).toBe('disabled'); + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + }); + + it('reports the removal but changes nothing in dry-run mode', () => { + const memory = createMemoryFs(); + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + const before = memory.read('/repo/AGENTS.md'); + memory.fs.writeFileSync('/repo/package.json', disabledPackageJson, { encoding: 'utf8' }); + + const result = writeClerkAgentRules({ + cwd: '/repo', + dryRun: true, + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + expect(result.files).toEqual(['/repo/AGENTS.md']); + expect(memory.read('/repo/AGENTS.md')).toBe(before); + }); + + it('writes as usual when package.json is not valid JSON', () => { + const memory = createMemoryFs({ '/repo/package.json': '{' }); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.agentsMd).toBe('created'); + }); +}); + describe('isClerkAgentRulesSdk', () => { it('accepts supported SDKs', () => { expect(isClerkAgentRulesSdk('nextjs')).toBe(true); @@ -298,7 +378,17 @@ describe('logClerkAgentRulesResult', () => { }); expect(log).toHaveBeenCalledWith( - '[Clerk]: Updated /repo/AGENTS.md for AI coding agents. Remove the Clerk section from AGENTS.md or set CLERK_AGENTS_MD=false to turn this off.', + '[Clerk]: Updated /repo/AGENTS.md for AI coding agents. To turn this off for everyone, add "clerk": { "agentsMd": false } to package.json. To turn it off on this machine, remove the Clerk section from AGENTS.md or set CLERK_AGENTS_MD=false.', + ); + }); + + it('logs a removal when package.json turns the rules off', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + logClerkAgentRulesResult({ targetDir: '/repo', agentsMd: 'disabled', files: ['/repo/AGENTS.md'] }); + + expect(log).toHaveBeenCalledWith( + '[Clerk]: Removed the Clerk section from /repo/AGENTS.md because package.json sets "clerk": { "agentsMd": false }.', ); log.mockRestore(); }); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 5970ce9e71d..fb0dc126de3 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -20,6 +20,7 @@ export interface FileSystemAdapter { readFileSync: (path: string, options: { encoding: BufferEncoding }) => string; writeFileSync: (path: string, data: string, options: { encoding: BufferEncoding; mode?: number }) => void; mkdirSync?: (path: string, options: { recursive: boolean }) => void; + unlinkSync?: (path: string) => void; } export interface PathAdapter { @@ -40,7 +41,7 @@ export interface WriteClerkAgentRulesOptions { export type WriteClerkAgentRulesResult = { targetDir: string; - agentsMd: 'created' | 'updated' | 'unchanged' | 'skipped'; + agentsMd: 'created' | 'updated' | 'unchanged' | 'skipped' | 'disabled'; files: string[]; }; @@ -176,6 +177,27 @@ function upsertMarkedBlock( return `${content.trimEnd()}${eol}${eol}${block}${eol}`; } +function removeMarkedBlock(content: string, start: string, end: string, eol: string) { + const startIndex = content.indexOf(start); + const endIndex = content.indexOf(end, startIndex + start.length); + if (startIndex === -1 || endIndex === -1) { + return content; + } + + const before = content.slice(0, startIndex).trimEnd(); + const after = content.slice(endIndex + end.length).trimStart(); + return before && after ? `${before}${eol}${eol}${after}` : `${before}${after}${before || after ? eol : ''}`; +} + +function isDisabledInPackageJson(fs: FileSystemAdapter, path: PathAdapter, targetDir: string) { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), { encoding: 'utf8' })); + return pkg?.clerk?.agentsMd === false; + } catch { + return false; + } +} + function findTargetDir(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { let current = path.resolve(cwd); const root = path.parse(current).root; @@ -204,6 +226,30 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ const agentsExists = fs.existsSync(agentsPath); const existing = agentsExists ? fs.readFileSync(agentsPath, { encoding: 'utf8' }) : ''; + if (isDisabledInPackageJson(fs, path, targetDir)) { + const eol = detectEol(existing); + let next = removeMarkedBlock(existing, GENERIC_START, GENERIC_END, eol); + for (const sdk of clerkAgentRulesSdks) { + next = removeMarkedBlock( + next, + ``, + ``, + eol, + ); + } + if (next === existing) { + return { targetDir, agentsMd: 'disabled', files: [] }; + } + if (!dryRun) { + if (next.trim() || !fs.unlinkSync) { + fs.writeFileSync(agentsPath, next, { encoding: 'utf8', mode: 0o644 }); + } else { + fs.unlinkSync(agentsPath); + } + } + return { targetDir, agentsMd: 'disabled', files: [agentsPath] }; + } + if (!force && !existing.includes(GENERIC_START) && fs.existsSync(statePath)) { return { targetDir, agentsMd: 'skipped', files: [] }; } @@ -244,12 +290,21 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ return { targetDir, agentsMd, files: agentsMd === 'unchanged' ? [] : [agentsPath] }; } -export function logClerkAgentRulesResult({ files }: WriteClerkAgentRulesResult) { - if (files.length > 0) { +export function logClerkAgentRulesResult({ agentsMd, files }: WriteClerkAgentRulesResult) { + if (files.length === 0) { + return; + } + + if (agentsMd === 'disabled') { console.log( - `[Clerk]: Updated ${files.join(' and ')} for AI coding agents. Remove the Clerk section from AGENTS.md or set CLERK_AGENTS_MD=false to turn this off.`, + `[Clerk]: Removed the Clerk section from ${files.join(' and ')} because package.json sets "clerk": { "agentsMd": false }.`, ); + return; } + + console.log( + `[Clerk]: Updated ${files.join(' and ')} for AI coding agents. To turn this off for everyone, add "clerk": { "agentsMd": false } to package.json. To turn it off on this machine, remove the Clerk section from AGENTS.md or set CLERK_AGENTS_MD=false.`, + ); } export function logClerkAgentRulesError(error: unknown) { diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index 91efa28c584..bee816dc628 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -198,6 +198,18 @@ describe('CLI Integration', () => { expect(fs.existsSync(path.join(fixture.path, 'CLAUDE.md'))).toBe(false); }); + it('writes nothing when package.json sets clerk.agentsMd to false', async () => { + const fs = await import('node:fs'); + const packageJsonPath = path.join(fixture.path, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + fs.writeFileSync(packageJsonPath, JSON.stringify({ ...pkg, clerk: { agentsMd: false } })); + + const result = await runCli(['--dir', fixture.path, '--agents-md'], { timeout: 15000 }); + + expect(result.stdout).toContain('"clerk": { "agentsMd": false }'); + expect(fs.existsSync(path.join(fixture.path, 'AGENTS.md'))).toBe(false); + }); + it('does not write files in dry-run mode', async () => { const fs = await import('node:fs'); const result = await runCli(['--dir', fixture.path, '--agents-md', '--dry-run'], { timeout: 15000 }); diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 14999cb6f4c..c188a2fc0ea 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -297,6 +297,11 @@ function writeAgentsMd(options, detectedSdks) { } const result = writeClerkAgentRules({ dir: options.dir, dryRun: options.dryRun, sdks }); + if (result.agentsMd === 'disabled') { + renderWarning('package.json sets "clerk": { "agentsMd": false }, so Clerk AGENTS.md sections were not written'); + return; + } + const prefix = options.dryRun ? '[dry run] Would write' : 'Wrote'; renderText(`${prefix} Clerk AGENTS.md sections for ${sdks.map(s => `@clerk/${s}`).join(', ')}`, 'blue'); From 45499315ac8d43454d0e8897c7d641e3a103f9bf Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 15:14:38 -0400 Subject: [PATCH 29/31] feat(shared,upgrade): write AGENTS.md at the monorepo root with one section per app In a workspace, every app writes to the root AGENTS.md under its own marker (clerk-agent-rules::), and each section points at the SDK copy that app installed. "clerk": { "agentsMd": false } in an app's package.json turns off and removes only that app's section; in the root package.json it removes them all. Removed sections are remembered one by one, and clerk-upgrade --agents-md writes a section for each workspace app. Co-Authored-By: Claude Opus 5.5 --- .changeset/upgrade-agents-md.md | 2 +- .changeset/write-agents-md-in-dev.md | 2 +- .../shared/src/__tests__/agentRules.spec.ts | 179 ++++++++++++++---- packages/shared/src/agentRules/index.ts | 149 ++++++++++----- .../__tests__/integration/agent-rules.test.js | 36 +++- packages/upgrade/src/util/agent-rules.js | 48 +++-- 6 files changed, 305 insertions(+), 111 deletions(-) diff --git a/.changeset/upgrade-agents-md.md b/.changeset/upgrade-agents-md.md index 0ee0c778eba..0c5108e4c9f 100644 --- a/.changeset/upgrade-agents-md.md +++ b/.changeset/upgrade-agents-md.md @@ -2,4 +2,4 @@ '@clerk/upgrade': minor --- -Add an `--agents-md` flag that writes Clerk sections to the project's `AGENTS.md` for each installed Clerk SDK, then exits. Interactive upgrades also offer to write these sections. Nothing is written when `package.json` sets `"clerk": { "agentsMd": false }`. +Add an `--agents-md` flag that writes Clerk sections to the project's `AGENTS.md` for each installed Clerk SDK, then exits. In a monorepo, it writes one section per app to the `AGENTS.md` at the repo root. Interactive upgrades also offer to write these sections. Nothing is written when `package.json` sets `"clerk": { "agentsMd": false }`. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index a071c580369..3db5b679ace 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development and an AI coding agent such as Claude Code, Codex, Cursor, Gemini CLI, or GitHub Copilot is detected, Clerk now adds a Clerk section to the `AGENTS.md` next to the app's `package.json`, creating the file if needed. If you remove the section, Clerk doesn't add it back on that machine. To turn it off for everyone on a project, add `"clerk": { "agentsMd": false }` to the app's `package.json`. Clerk then removes its section on the next dev run under an agent. The section tells the agent to read `node_modules/@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development and an AI coding agent such as Claude Code, Codex, Cursor, Gemini CLI, or GitHub Copilot is detected, Clerk now adds a Clerk section to the project's `AGENTS.md`, creating the file if needed. In a monorepo, the file is the one at the repo root, with one section per app. If you remove a section, Clerk doesn't add it back on that machine. To turn it off for one app, add `"clerk": { "agentsMd": false }` to that app's `package.json`. To turn it off for the whole repo, add it to the root `package.json`. Clerk then removes its sections on the next dev run under an agent. Each section tells the agent to read the installed `@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 785b2fa1525..6af54a7a49a 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CLERK_SDK_PLUGIN_END, @@ -10,6 +10,18 @@ import { renderClerkSdkPluginBlock, writeClerkAgentRules, } from '../agentRules'; +import { agentEnvironmentVariables } from '../utils/runtimeEnvironment'; + +beforeEach(() => { + for (const name of agentEnvironmentVariables) { + vi.stubEnv(name, ''); + } + vi.stubEnv('CLAUDECODE', '1'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); const createMemoryFs = (files: Record = {}, { withRepoPackageJson = true } = {}) => { const store = new Map(Object.entries(withRepoPackageJson ? { '/repo/package.json': '{}', ...files } : files)); @@ -102,45 +114,6 @@ describe('writeClerkAgentRules', () => { expect(memory.read('/repo/src/AGENTS.md')).toBeUndefined(); }); - it('targets the app package instead of the workspace root', () => { - const memory = createMemoryFs({ - '/repo/pnpm-workspace.yaml': 'packages:\n - apps/*\n', - '/repo/apps/web/package.json': '{}', - }); - - writeClerkAgentRules({ - cwd: '/repo/apps/web', - fs: memory.fs, - path: pathAdapter, - sdks: ['react'], - }); - - expect(memory.read('/repo/apps/web/AGENTS.md')).toContain(''); - expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); - }); - - it('points to an SDK AGENTS.md that exists when the SDK is installed in a pnpm workspace app', () => { - const memory = createMemoryFs({ - '/repo/pnpm-workspace.yaml': 'packages:\n - apps/*\n', - '/repo/package.json': '{"name":"root","private":true}', - '/repo/apps/web/package.json': '{"name":"web","dependencies":{"@clerk/nextjs":"*"}}', - '/repo/apps/web/node_modules/@clerk/nextjs/AGENTS.md': '# Clerk Next.js', - }); - - const { files } = writeClerkAgentRules({ - cwd: '/repo/apps/web', - fs: memory.fs, - path: pathAdapter, - sdks: ['nextjs'], - }); - - const agentsMdPath = files.find(file => file.endsWith('/AGENTS.md')) ?? ''; - const pointer = memory.read(agentsMdPath)?.match(/`(node_modules\/@clerk\/nextjs\/AGENTS\.md)`/)?.[1] ?? ''; - - expect(pointer).toBe('node_modules/@clerk/nextjs/AGENTS.md'); - expect(memory.fs.existsSync(pathAdapter.join(pathAdapter.dirname(agentsMdPath), pointer))).toBe(true); - }); - it('replaces only Clerk blocks and preserves surrounding content', () => { const memory = createMemoryFs({ '/repo/AGENTS.md': 'Project rules\n\n\nold\n\n', @@ -191,6 +164,130 @@ describe('writeClerkAgentRules', () => { }); }); +describe('writeClerkAgentRules in a monorepo', () => { + const createMonorepo = (files: Record = {}) => + createMemoryFs({ + '/repo/package.json': '{"name":"root","private":true}', + '/repo/pnpm-workspace.yaml': 'packages:\n - apps/*\n', + '/repo/apps/web/package.json': '{"name":"web"}', + '/repo/apps/web/node_modules/@clerk/react/package.json': '{}', + '/repo/apps/ext/package.json': '{"name":"ext"}', + '/repo/apps/ext/node_modules/@clerk/chrome-extension/package.json': '{}', + ...files, + }); + + const writeWeb = (memory: ReturnType) => + writeClerkAgentRules({ cwd: '/repo/apps/web', fs: memory.fs, path: pathAdapter, sdks: ['react'] }); + const writeExt = (memory: ReturnType) => + writeClerkAgentRules({ cwd: '/repo/apps/ext', fs: memory.fs, path: pathAdapter, sdks: ['chrome-extension'] }); + + it('writes one section per app to the AGENTS.md at the repo root', () => { + const memory = createMonorepo(); + + writeWeb(memory); + const result = writeExt(memory); + + const content = memory.read('/repo/AGENTS.md'); + expect(result.targetDir).toBe('/repo'); + expect(content?.match(//g)).toHaveLength(1); + expect(content).toContain('### Clerk + React in `apps/web`'); + expect(content).toContain(''); + expect(content).toContain(''); + expect(content?.indexOf('react:apps/web')).toBeLessThan(content?.indexOf('chrome-extension:apps/ext') ?? 0); + expect(memory.read('/repo/apps/web/AGENTS.md')).toBeUndefined(); + }); + + it('points each section at the copy of the SDK that app installed', () => { + const memory = createMonorepo({ '/repo/node_modules/@clerk/chrome-extension/package.json': '{}' }); + + writeWeb(memory); + writeClerkAgentRules({ cwd: '/repo/apps/web', fs: memory.fs, path: pathAdapter, sdks: ['react'] }); + writeClerkAgentRules({ + cwd: '/repo/apps/ext', + fs: { + ...memory.fs, + existsSync: path => + path !== '/repo/apps/ext/node_modules/@clerk/chrome-extension/package.json' && memory.fs.existsSync(path), + }, + path: pathAdapter, + sdks: ['chrome-extension'], + }); + + const content = memory.read('/repo/AGENTS.md'); + expect(content).toContain('Read `apps/web/node_modules/@clerk/react/AGENTS.md` before writing auth code.'); + expect(content).toContain('Read `node_modules/@clerk/chrome-extension/AGENTS.md` before writing auth code.'); + }); + + it('finds the repo root from package.json workspaces', () => { + const memory = createMemoryFs( + { + '/repo/package.json': '{"workspaces":["apps/*"]}', + '/repo/apps/web/package.json': '{}', + }, + { withRepoPackageJson: false }, + ); + + writeClerkAgentRules({ cwd: '/repo/apps/web/src', fs: memory.fs, path: pathAdapter, sdks: ['react'] }); + + expect(memory.read('/repo/AGENTS.md')).toContain(''); + }); + + it('turns off one app from its own package.json and keeps writing for the others', () => { + const memory = createMonorepo(); + writeWeb(memory); + writeExt(memory); + memory.fs.writeFileSync('/repo/apps/web/package.json', '{"clerk":{"agentsMd":false}}', { encoding: 'utf8' }); + + expect(writeWeb(memory)).toEqual({ targetDir: '/repo', agentsMd: 'disabled', files: ['/repo/AGENTS.md'] }); + writeExt(memory); + + const content = memory.read('/repo/AGENTS.md'); + expect(content).not.toContain('react:apps/web'); + expect(content).toContain(''); + expect(content).toContain(''); + }); + + it('removes the intro section and the file when the last app turns off', () => { + const memory = createMonorepo(); + writeWeb(memory); + memory.fs.writeFileSync('/repo/apps/web/package.json', '{"clerk":{"agentsMd":false}}', { encoding: 'utf8' }); + + writeWeb(memory); + + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + }); + + it('turns off every app from the repo root package.json', () => { + const memory = createMonorepo(); + memory.fs.writeFileSync('/repo/AGENTS.md', 'Project rules\n', { encoding: 'utf8' }); + writeWeb(memory); + writeExt(memory); + memory.fs.writeFileSync('/repo/package.json', '{"clerk":{"agentsMd":false}}', { encoding: 'utf8' }); + + writeExt(memory); + writeWeb(memory); + + expect(memory.read('/repo/AGENTS.md')).toBe('Project rules\n'); + }); + + it('keeps a removed app section out without blocking the other apps', () => { + const memory = createMonorepo(); + writeWeb(memory); + writeExt(memory); + const content = memory.read('/repo/AGENTS.md') ?? ''; + const start = content.indexOf(''); + const endMarker = ''; + const end = content.indexOf(endMarker) + endMarker.length; + memory.fs.writeFileSync('/repo/AGENTS.md', content.slice(0, start) + content.slice(end), { encoding: 'utf8' }); + + writeWeb(memory); + writeExt(memory); + + expect(memory.read('/repo/AGENTS.md')).not.toContain('react:apps/web'); + expect(memory.read('/repo/AGENTS.md')).toContain('chrome-extension:apps/ext'); + }); +}); + describe('writeClerkAgentRules with an unmatched BEGIN marker', () => { it('keeps text after an orphan shared BEGIN marker across runs', () => { const memory = createMemoryFs({ @@ -222,7 +319,7 @@ describe('writeClerkAgentRules with an unmatched BEGIN marker', () => { }); describe('writeClerkAgentRules after the user removes the Clerk section', () => { - const statePath = '/repo/node_modules/.cache/clerk/agent-rules'; + const statePath = '/repo/node_modules/.cache/clerk/agent-rules/clerk-agent-rules'; it('records that it wrote the section', () => { const memory = createMemoryFs(); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index fb0dc126de3..0cc3a4335b9 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -45,6 +45,7 @@ export type WriteClerkAgentRulesResult = { files: string[]; }; +const GENERIC_ID = 'clerk-agent-rules'; const GENERIC_START = ''; const GENERIC_END = ''; @@ -128,21 +129,31 @@ ${GENERIC_END}`, ); } -export function renderClerkSdkAgentRulesBlock(sdk: ClerkAgentRulesSdk, eol = '\n') { +const sdkBlockId = (sdk: ClerkAgentRulesSdk, appDir = '') => `${GENERIC_ID}:${sdk}${appDir ? `:${appDir}` : ''}`; + +export function renderClerkSdkAgentRulesBlock( + sdk: ClerkAgentRulesSdk, + eol = '\n', + { + appDir = '', + agentsMdPath = `node_modules/@clerk/${sdk}/AGENTS.md`, + }: { appDir?: string; agentsMdPath?: string } = {}, +) { + const id = sdkBlockId(sdk, appDir); const notes = [ - `Read \`node_modules/@clerk/${sdk}/AGENTS.md\` before writing auth code.`, + `Read \`${agentsMdPath}\` before writing auth code.`, 'Clerk Elements is removed. Never use it.', ...(SDK_NOTES[sdk] ?? []), ]; return normalizeEol( - ` + ` -### Clerk + ${SDK_LABELS[sdk]} +### Clerk + ${SDK_LABELS[sdk]}${appDir ? ` in \`${appDir}\`` : ''} ${notes.map(note => `- ${note}`).join('\n')} -`, +`, eol, ); } @@ -177,68 +188,100 @@ function upsertMarkedBlock( return `${content.trimEnd()}${eol}${eol}${block}${eol}`; } -function removeMarkedBlock(content: string, start: string, end: string, eol: string) { - const startIndex = content.indexOf(start); - const endIndex = content.indexOf(end, startIndex + start.length); +const blockStart = (id: string) => ``; +const blockEnd = (id: string) => ``; + +const listBlockIds = (content: string) => + [...content.matchAll(//g)].map(match => match[1]); + +function removeBlock(content: string, id: string, eol: string) { + const startIndex = content.indexOf(blockStart(id)); + const endIndex = content.indexOf(blockEnd(id), startIndex); if (startIndex === -1 || endIndex === -1) { return content; } const before = content.slice(0, startIndex).trimEnd(); - const after = content.slice(endIndex + end.length).trimStart(); + const after = content.slice(endIndex + blockEnd(id).length).trimStart(); return before && after ? `${before}${eol}${eol}${after}` : `${before}${after}${before || after ? eol : ''}`; } -function isDisabledInPackageJson(fs: FileSystemAdapter, path: PathAdapter, targetDir: string) { +function isDisabledInPackageJson(fs: FileSystemAdapter, path: PathAdapter, dir: string) { try { - const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), { encoding: 'utf8' })); + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), { encoding: 'utf8' })); return pkg?.clerk?.agentsMd === false; } catch { return false; } } -function findTargetDir(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { +function isWorkspaceRoot(fs: FileSystemAdapter, path: PathAdapter, dir: string) { + if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { + return true; + } + try { + return !!JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), { encoding: 'utf8' })).workspaces; + } catch { + return false; + } +} + +function findAppDirs(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { let current = path.resolve(cwd); const root = path.parse(current).root; + const dirs: string[] = []; while (current !== root) { if (fs.existsSync(path.join(current, 'package.json'))) { - return current; + dirs.push(current); + if (isWorkspaceRoot(fs, path, current)) { + return dirs; + } + } else if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return [...dirs, current]; } current = path.dirname(current); } - return null; + return dirs.slice(0, 1); +} + +const relativeDir = (rootDir: string, dir: string) => + dir === rootDir ? '' : dir.slice(rootDir.length + 1).replace(/\\/g, '/'); + +function findSdkAgentsMd(fs: FileSystemAdapter, path: PathAdapter, sdk: ClerkAgentRulesSdk, dirs: string[]) { + const rootDir = dirs[dirs.length - 1]; + const installDir = dirs.find(dir => fs.existsSync(path.join(dir, 'node_modules', '@clerk', sdk, 'package.json'))); + const prefix = relativeDir(rootDir, installDir ?? dirs[0]); + return `${prefix ? `${prefix}/` : ''}node_modules/@clerk/${sdk}/AGENTS.md`; } export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): WriteClerkAgentRulesResult { const { dryRun = false, force = false, fs, path, sdks } = options; - const targetDir = findTargetDir(fs, path, options.cwd); - if (!targetDir) { + const dirs = findAppDirs(fs, path, options.cwd); + if (dirs.length === 0) { return { targetDir: path.resolve(options.cwd), agentsMd: 'skipped', files: [] }; } - const agentsPath = path.join(targetDir, 'AGENTS.md'); - const stateDir = path.join(targetDir, 'node_modules', '.cache', 'clerk'); - const statePath = path.join(stateDir, 'agent-rules'); + const appDir = dirs[0]; + const rootDir = dirs[dirs.length - 1]; + const appKey = relativeDir(rootDir, appDir); + const agentsPath = path.join(rootDir, 'AGENTS.md'); const agentsExists = fs.existsSync(agentsPath); const existing = agentsExists ? fs.readFileSync(agentsPath, { encoding: 'utf8' }) : ''; + const eol = detectEol(existing); - if (isDisabledInPackageJson(fs, path, targetDir)) { - const eol = detectEol(existing); - let next = removeMarkedBlock(existing, GENERIC_START, GENERIC_END, eol); - for (const sdk of clerkAgentRulesSdks) { - next = removeMarkedBlock( - next, - ``, - ``, - eol, - ); + const disabledDir = dirs.find(dir => isDisabledInPackageJson(fs, path, dir)); + if (disabledDir) { + const ids = + disabledDir === rootDir ? listBlockIds(existing) : clerkAgentRulesSdks.map(sdk => sdkBlockId(sdk, appKey)); + let next = ids.reduce((content, id) => removeBlock(content, id, eol), existing); + if (listBlockIds(next).every(id => id === GENERIC_ID)) { + next = removeBlock(next, GENERIC_ID, eol); } if (next === existing) { - return { targetDir, agentsMd: 'disabled', files: [] }; + return { targetDir: rootDir, agentsMd: 'disabled', files: [] }; } if (!dryRun) { if (next.trim() || !fs.unlinkSync) { @@ -247,47 +290,49 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ fs.unlinkSync(agentsPath); } } - return { targetDir, agentsMd: 'disabled', files: [agentsPath] }; + return { targetDir: rootDir, agentsMd: 'disabled', files: [agentsPath] }; } - if (!force && !existing.includes(GENERIC_START) && fs.existsSync(statePath)) { - return { targetDir, agentsMd: 'skipped', files: [] }; - } + const stateDir = path.join(rootDir, 'node_modules', '.cache', 'clerk', 'agent-rules'); + const statePath = (id: string) => path.join(stateDir, encodeURIComponent(id)); + const wasRemoved = (id: string) => !force && !existing.includes(blockStart(id)) && fs.existsSync(statePath(id)); - const eol = detectEol(existing); + if (wasRemoved(GENERIC_ID)) { + return { targetDir: rootDir, agentsMd: 'skipped', files: [] }; + } let next = upsertMarkedBlock(existing, renderClerkAgentRulesBlock(eol), GENERIC_START, GENERIC_END, eol); + const written = [GENERIC_ID]; - let insertAfter = GENERIC_END; for (const sdk of uniqueSdks(sdks)) { - const sdkEnd = ``; - next = upsertMarkedBlock( - next, - renderClerkSdkAgentRulesBlock(sdk, eol), - ``, - sdkEnd, - eol, - insertAfter, - ); - if (next.includes(sdkEnd)) { - insertAfter = sdkEnd; + const id = sdkBlockId(sdk, appKey); + if (wasRemoved(id)) { + continue; } + const lastEnd = [...next.matchAll(//g)].pop()?.[0]; + const block = renderClerkSdkAgentRulesBlock(sdk, eol, { + appDir: appKey, + agentsMdPath: findSdkAgentsMd(fs, path, sdk, dirs), + }); + next = upsertMarkedBlock(next, block, blockStart(id), blockEnd(id), eol, lastEnd); + written.push(id); } const agentsMd = agentsExists ? (next === existing ? 'unchanged' : 'updated') : 'created'; if (!dryRun) { - fs.mkdirSync?.(targetDir, { recursive: true }); if (agentsMd !== 'unchanged') { fs.writeFileSync(agentsPath, next, { encoding: 'utf8', mode: 0o644 }); } - if (!fs.existsSync(statePath)) { - fs.mkdirSync?.(stateDir, { recursive: true }); - fs.writeFileSync(statePath, '', { encoding: 'utf8', mode: 0o644 }); + for (const id of written) { + if (!fs.existsSync(statePath(id))) { + fs.mkdirSync?.(stateDir, { recursive: true }); + fs.writeFileSync(statePath(id), '', { encoding: 'utf8', mode: 0o644 }); + } } } - return { targetDir, agentsMd, files: agentsMd === 'unchanged' ? [] : [agentsPath] }; + return { targetDir: rootDir, agentsMd, files: agentsMd === 'unchanged' ? [] : [agentsPath] }; } export function logClerkAgentRulesResult({ agentsMd, files }: WriteClerkAgentRulesResult) { diff --git a/packages/upgrade/src/__tests__/integration/agent-rules.test.js b/packages/upgrade/src/__tests__/integration/agent-rules.test.js index fb91dc9e927..bd3272d0be5 100644 --- a/packages/upgrade/src/__tests__/integration/agent-rules.test.js +++ b/packages/upgrade/src/__tests__/integration/agent-rules.test.js @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { detectClerkAgentRuleSdks } from '../../util/agent-rules.js'; +import { detectClerkAgentRuleSdks, writeClerkAgentRules } from '../../util/agent-rules.js'; describe('detectClerkAgentRuleSdks', () => { let root; @@ -36,3 +36,37 @@ describe('detectClerkAgentRuleSdks', () => { expect(detectClerkAgentRuleSdks(path.join(root, 'packages', 'api'))).toEqual(['express', 'nextjs']); }); }); + +describe('writeClerkAgentRules', () => { + let root; + + const writePackage = (dir, pkg) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-upgrade-agent-rules-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('writes one section per workspace app to the AGENTS.md at the root', () => { + writePackage(root, { name: 'root', workspaces: ['apps/*'] }); + writePackage(path.join(root, 'apps', 'web'), { name: 'web', dependencies: { '@clerk/react': '^6.0.0' } }); + writePackage(path.join(root, 'apps', 'ext'), { + name: 'ext', + dependencies: { '@clerk/chrome-extension': '^3.0.0' }, + }); + + const result = writeClerkAgentRules({ dir: root }); + + const content = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'); + expect(result).toMatchObject({ targetDir: root, agentsMd: 'created', sdks: ['chrome-extension', 'react'] }); + expect(content).toContain(''); + expect(content).toContain(''); + expect(fs.existsSync(path.join(root, 'apps', 'web', 'AGENTS.md'))).toBe(false); + }); +}); diff --git a/packages/upgrade/src/util/agent-rules.js b/packages/upgrade/src/util/agent-rules.js index b7a1e31ce92..03d1901231d 100644 --- a/packages/upgrade/src/util/agent-rules.js +++ b/packages/upgrade/src/util/agent-rules.js @@ -5,23 +5,41 @@ import { isClerkAgentRulesSdk, writeClerkAgentRules as writeRules } from '@clerk import { findWorkspaceRoot, getInstalledClerkPackages, getWorkspacePackageDirs } from './detect-sdk.js'; -export function detectClerkAgentRuleSdks(dir) { +function getAgentRuleSdks(packageDir) { + return Object.keys(getInstalledClerkPackages(packageDir)) + .map(packageName => packageName.replace(/^@clerk\//, '')) + .filter(isClerkAgentRulesSdk); +} + +function getPackageDirs(dir) { const workspaceRoot = findWorkspaceRoot(dir); - const packageDirs = workspaceRoot ? [workspaceRoot, ...getWorkspacePackageDirs(workspaceRoot)] : [dir]; - const sdks = new Set(); - - for (const packageDir of packageDirs) { - for (const packageName of Object.keys(getInstalledClerkPackages(packageDir))) { - const sdk = packageName.replace(/^@clerk\//, ''); - if (isClerkAgentRulesSdk(sdk)) { - sdks.add(sdk); - } - } - } - - return [...sdks].sort(); + return workspaceRoot ? [workspaceRoot, ...getWorkspacePackageDirs(workspaceRoot)] : [dir]; +} + +export function detectClerkAgentRuleSdks(dir) { + return [...new Set(getPackageDirs(dir).flatMap(getAgentRuleSdks))].sort(); } export function writeClerkAgentRules({ dir, dryRun = false, sdks = detectClerkAgentRuleSdks(dir) }) { - return { ...writeRules({ cwd: dir, dryRun, force: true, fs, path, sdks }), sdks }; + const packageDirs = getPackageDirs(dir); + const writes = packageDirs + .map(packageDir => ({ + packageDir, + sdks: packageDirs.length === 1 ? sdks : getAgentRuleSdks(packageDir).filter(sdk => sdks.includes(sdk)), + })) + .filter(write => write.sdks.length > 0); + + const results = writes.map(write => + writeRules({ cwd: write.packageDir, dryRun, force: true, fs, path, sdks: write.sdks }), + ); + const statuses = results.map(result => result.agentsMd); + const agentsMd = statuses.includes('created') + ? 'created' + : statuses.includes('updated') + ? 'updated' + : statuses.length > 0 && statuses.every(status => status === 'disabled') + ? 'disabled' + : 'unchanged'; + + return { targetDir: results[0]?.targetDir ?? path.resolve(dir), agentsMd, sdks }; } From fb14098a3750e8b02796b470f18bbd6b3df5bfa1 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 15:15:30 -0400 Subject: [PATCH 30/31] feat(*): also detect AI agents from agent files in the repo Turborepo's default strict env mode strips agent env vars from dev tasks, so env-only detection never fired under turbo dev. The writer now also writes when the repo (up to its .git folder) has agent files such as AGENTS.md, CLAUDE.md, .claude/, or .cursor/. The check moves from isClerkAgentRulesWriteAllowed() into writeClerkAgentRules(), so package.json opt-outs also clean up on any dev run. Co-Authored-By: Claude Opus 5.5 --- .../chrome-extension-agent-rules-vite.md | 2 +- .changeset/react-vue-agent-rules-vite.md | 2 +- .changeset/shared-agent-rules.md | 2 +- .changeset/write-agents-md-in-dev.md | 2 +- .../integration/__tests__/agent-rules.test.ts | 5 +- .../__tests__/appPlugin.agentRules.test.js | 8 +-- .../server/__tests__/agent-rules-node.test.ts | 19 +----- .../nuxt/src/__tests__/agent-rules.test.ts | 5 +- .../shared/src/__tests__/agentRules.spec.ts | 67 +++++++++++++++++++ .../src/__tests__/agentRulesWriter.spec.ts | 5 +- .../src/__tests__/runtimeEnvironment.spec.ts | 9 +-- packages/shared/src/__tests__/vite.spec.ts | 5 +- packages/shared/src/agentRules/index.ts | 40 +++++++++++ .../shared/src/utils/runtimeEnvironment.ts | 3 +- 14 files changed, 128 insertions(+), 46 deletions(-) diff --git a/.changeset/chrome-extension-agent-rules-vite.md b/.changeset/chrome-extension-agent-rules-vite.md index 9756256eae1..7a2f062cdbe 100644 --- a/.changeset/chrome-extension-agent-rules-vite.md +++ b/.changeset/chrome-extension-agent-rules-vite.md @@ -2,7 +2,7 @@ '@clerk/chrome-extension': minor --- -Add a Vite plugin that writes a Clerk section to the project's `AGENTS.md` when the Vite dev server starts under an AI coding agent, for extensions built with WXT, CRXJS, or a custom Vite setup. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. +Add a Vite plugin that writes a Clerk section to the project's `AGENTS.md` when the Vite dev server starts under an AI coding agent, or in a repo that already has agent files such as `AGENTS.md` or `CLAUDE.md`, for extensions built with WXT, CRXJS, or a custom Vite setup. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. ```ts // wxt.config.ts diff --git a/.changeset/react-vue-agent-rules-vite.md b/.changeset/react-vue-agent-rules-vite.md index 7392e49c6ba..b68974673c3 100644 --- a/.changeset/react-vue-agent-rules-vite.md +++ b/.changeset/react-vue-agent-rules-vite.md @@ -3,7 +3,7 @@ '@clerk/vue': minor --- -Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server starts under an AI coding agent. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. +Add Vite plugins that write a Clerk section to the project's `AGENTS.md` when the Vite dev server starts under an AI coding agent, or in a repo that already has agent files such as `AGENTS.md` or `CLAUDE.md`. Nothing is written during builds, tests, or CI, and setting `CLERK_AGENTS_MD=false` turns it off. ```ts import { clerkReactAgentRules } from '@clerk/react/vite'; diff --git a/.changeset/shared-agent-rules.md b/.changeset/shared-agent-rules.md index c8b64d845b5..e752feb95f6 100644 --- a/.changeset/shared-agent-rules.md +++ b/.changeset/shared-agent-rules.md @@ -2,4 +2,4 @@ '@clerk/shared': minor --- -Add `@clerk/shared/agentRules`, `@clerk/shared/agentRulesWriter`, and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isAgentEnvironment()` and `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`. `isClerkAgentRulesWriteAllowed()` returns `false` when no AI coding agent is detected, when `NODE_ENV=production`, in tests, in CI, or when `CLERK_AGENTS_MD=false`. Deployments that don't set `NODE_ENV=production` should set `CLERK_AGENTS_MD=false` to turn writes off. +Add `@clerk/shared/agentRules`, `@clerk/shared/agentRulesWriter`, and `@clerk/shared/vite` for writing Clerk sections to a project's `AGENTS.md`. Add `isAgentEnvironment()` and `isClerkAgentRulesWriteAllowed()` to `@clerk/shared/utils`. `isClerkAgentRulesWriteAllowed()` returns `false` when `NODE_ENV=production`, in tests, in CI, or when `CLERK_AGENTS_MD=false`. Deployments that don't set `NODE_ENV=production` should set `CLERK_AGENTS_MD=false` to turn writes off. diff --git a/.changeset/write-agents-md-in-dev.md b/.changeset/write-agents-md-in-dev.md index 3db5b679ace..1bbaa61bc39 100644 --- a/.changeset/write-agents-md-in-dev.md +++ b/.changeset/write-agents-md-in-dev.md @@ -11,4 +11,4 @@ '@clerk/tanstack-react-start': minor --- -When your app runs in development and an AI coding agent such as Claude Code, Codex, Cursor, Gemini CLI, or GitHub Copilot is detected, Clerk now adds a Clerk section to the project's `AGENTS.md`, creating the file if needed. In a monorepo, the file is the one at the repo root, with one section per app. If you remove a section, Clerk doesn't add it back on that machine. To turn it off for one app, add `"clerk": { "agentsMd": false }` to that app's `package.json`. To turn it off for the whole repo, add it to the root `package.json`. Clerk then removes its sections on the next dev run under an agent. Each section tells the agent to read the installed `@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. +When your app runs in development and an AI coding agent such as Claude Code, Codex, Cursor, Gemini CLI, or GitHub Copilot is detected, or the repo already has agent files such as `AGENTS.md`, `CLAUDE.md`, `.claude/`, or `.cursor/`, Clerk now adds a Clerk section to the project's `AGENTS.md`, creating the file if needed. In a monorepo, the file is the one at the repo root, with one section per app. If you remove a section, Clerk doesn't add it back on that machine. To turn it off for one app, add `"clerk": { "agentsMd": false }` to that app's `package.json`. To turn it off for the whole repo, add it to the root `package.json`. Clerk then removes its sections on the next dev run. Each section tells the agent to read the installed `@clerk//AGENTS.md` before writing auth code and recommends the Clerk plugin (`npx plugins add clerk/skills`). Nothing is written when `NODE_ENV=production`, in tests, or in CI, and setting `CLERK_AGENTS_MD=false` turns it off. If your deployment doesn't set `NODE_ENV=production`, set `CLERK_AGENTS_MD=false` there. The section is written by the Astro and Nuxt dev servers, the Expo config plugin, the first `auth()` call or `clerkMiddleware()` request in Next.js, TanStack React Start's `clerkMiddleware()`, `clerkMiddleware()` in Express, Hono, and React Router, Fastify's `clerkPlugin`, and `createClerkBridge()` in an unpackaged Electron app. diff --git a/packages/astro/src/integration/__tests__/agent-rules.test.ts b/packages/astro/src/integration/__tests__/agent-rules.test.ts index df0fc1920f1..0ff0d61427c 100644 --- a/packages/astro/src/integration/__tests__/agent-rules.test.ts +++ b/packages/astro/src/integration/__tests__/agent-rules.test.ts @@ -1,6 +1,6 @@ import type * as AgentRules from '@clerk/shared/agentRules'; import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@clerk/shared/agentRules', async importOriginal => ({ @@ -14,12 +14,11 @@ describe('writeAgentRulesOnce', () => { beforeEach(() => { vi.resetModules(); vi.mocked(writeClerkAgentRules).mockReset(); - for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { + for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); - vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/expo/src/__tests__/appPlugin.agentRules.test.js b/packages/expo/src/__tests__/appPlugin.agentRules.test.js index 2f5401661dd..287e0368f85 100644 --- a/packages/expo/src/__tests__/appPlugin.agentRules.test.js +++ b/packages/expo/src/__tests__/appPlugin.agentRules.test.js @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; -import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; const nodeRequire = createRequire(import.meta.url); @@ -20,12 +20,11 @@ describe('maybeWriteAgentRules', () => { beforeEach(() => { projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-agent-rules-')); fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); - for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { + for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); - vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { @@ -36,7 +35,8 @@ describe('maybeWriteAgentRules', () => { const agentsMdPath = () => path.join(projectRoot, 'AGENTS.md'); const config = () => ({ _internal: { projectRoot } }); - test('writes the Expo rules once per process in development, with or without an AI agent', () => { + test('writes the Expo rules once per process in development under an AI agent', () => { + vi.stubEnv('CLAUDECODE', '1'); const maybeWriteAgentRules = loadMaybeWriteAgentRules(); maybeWriteAgentRules(config()); diff --git a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts index 99885ef2730..4624650e662 100644 --- a/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts +++ b/packages/nextjs/src/server/__tests__/agent-rules-node.test.ts @@ -1,5 +1,5 @@ import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@clerk/shared/agentRules', async importOriginal => ({ @@ -20,12 +20,11 @@ describe('writeAgentRulesOnce', () => { vi.resetModules(); vi.doMock('../fs/utils', () => nodeApis); vi.mocked(writeClerkAgentRules).mockReset(); - for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { + for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); - vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { @@ -33,7 +32,7 @@ describe('writeAgentRulesOnce', () => { vi.restoreAllMocks(); }); - it('writes the rules once per process in development when an AI agent is detected', async () => { + it('writes the rules once per process in development, with or without an AI agent', async () => { const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); writeAgentRulesOnce(); @@ -43,18 +42,6 @@ describe('writeAgentRulesOnce', () => { expect(writeClerkAgentRules).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/app', sdks: ['nextjs'] })); }); - it('skips without an AI agent, then writes once one is detected', async () => { - vi.stubEnv('CLAUDECODE', ''); - const writeAgentRulesOnce = await loadWriteAgentRulesOnce(); - - writeAgentRulesOnce(); - expect(writeClerkAgentRules).not.toHaveBeenCalled(); - - vi.stubEnv('CLAUDECODE', '1'); - writeAgentRulesOnce(); - expect(writeClerkAgentRules).toHaveBeenCalledTimes(1); - }); - it('skips in CI', async () => { vi.stubEnv('CI', 'true'); diff --git a/packages/nuxt/src/__tests__/agent-rules.test.ts b/packages/nuxt/src/__tests__/agent-rules.test.ts index f55d4d1dad3..90c11508dab 100644 --- a/packages/nuxt/src/__tests__/agent-rules.test.ts +++ b/packages/nuxt/src/__tests__/agent-rules.test.ts @@ -1,6 +1,6 @@ import type * as AgentRules from '@clerk/shared/agentRules'; import { writeClerkAgentRules } from '@clerk/shared/agentRules'; -import { agentEnvironmentVariables, automatedEnvironmentVariables } from '@clerk/shared/utils'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@clerk/shared/agentRules', async importOriginal => ({ @@ -14,12 +14,11 @@ describe('writeAgentRulesOnce', () => { beforeEach(() => { vi.resetModules(); vi.mocked(writeClerkAgentRules).mockReset(); - for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { + for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); - vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 6af54a7a49a..0cbb7b275c4 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -288,6 +288,73 @@ describe('writeClerkAgentRules in a monorepo', () => { }); }); +describe('writeClerkAgentRules without a detected AI agent', () => { + beforeEach(() => { + vi.stubEnv('CLAUDECODE', ''); + }); + + it('writes nothing and records nothing when the repo has no agent files', () => { + const memory = createMemoryFs(); + + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result).toEqual({ targetDir: '/repo', agentsMd: 'skipped', files: [] }); + expect(memory.read('/repo/AGENTS.md')).toBeUndefined(); + expect(memory.read('/repo/node_modules/.cache/clerk/agent-rules/clerk-agent-rules')).toBeUndefined(); + }); + + it.each(['/repo/CLAUDE.md', '/repo/.github/copilot-instructions.md', '/repo/apps/web/GEMINI.md'])( + 'writes when the repo has agent files such as %s', + file => { + const memory = createMemoryFs({ '/repo/.git': '', '/repo/apps/web/package.json': '{}', [file]: '' }); + + const result = writeClerkAgentRules({ + cwd: '/repo/apps/web', + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + expect(result.agentsMd).toBe('created'); + }, + ); + + it('ignores agent files above the repo, such as a home folder .claude', () => { + const memory = createMemoryFs( + { '/home/user/.claude': '', '/home/user/app/.git': '', '/home/user/app/package.json': '{}' }, + { withRepoPackageJson: false }, + ); + + const result = writeClerkAgentRules({ cwd: '/home/user/app', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.agentsMd).toBe('skipped'); + }); + + it('writes on a later run once an agent is detected', () => { + const memory = createMemoryFs(); + writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + vi.stubEnv('CLAUDECODE', '1'); + const result = writeClerkAgentRules({ cwd: '/repo', fs: memory.fs, path: pathAdapter, sdks: ['nextjs'] }); + + expect(result.agentsMd).toBe('created'); + }); + + it('writes when forced', () => { + const memory = createMemoryFs(); + + const result = writeClerkAgentRules({ + cwd: '/repo', + force: true, + fs: memory.fs, + path: pathAdapter, + sdks: ['nextjs'], + }); + + expect(result.agentsMd).toBe('created'); + }); +}); + describe('writeClerkAgentRules with an unmatched BEGIN marker', () => { it('keeps text after an orphan shared BEGIN marker across runs', () => { const memory = createMemoryFs({ diff --git a/packages/shared/src/__tests__/agentRulesWriter.spec.ts b/packages/shared/src/__tests__/agentRulesWriter.spec.ts index c5ab45110be..c93e5d61d68 100644 --- a/packages/shared/src/__tests__/agentRulesWriter.spec.ts +++ b/packages/shared/src/__tests__/agentRulesWriter.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as AgentRules from '../agentRules'; import { writeClerkAgentRules } from '../agentRules'; import { createClerkAgentRulesWriter } from '../agentRulesWriter'; -import { agentEnvironmentVariables, automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; +import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; vi.mock('../agentRules', async importOriginal => ({ ...(await importOriginal()), @@ -15,12 +15,11 @@ describe('createClerkAgentRulesWriter', () => { vi.mocked(writeClerkAgentRules) .mockReset() .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', files: [] }); - for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { + for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); - vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts index df1f9479112..82bae5798f7 100644 --- a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -108,7 +108,6 @@ describe('isClerkAgentRulesWriteAllowed', () => { for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { vi.stubEnv(name, ''); } - vi.stubEnv('CLAUDECODE', '1'); vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); }); @@ -117,16 +116,10 @@ describe('isClerkAgentRulesWriteAllowed', () => { vi.unstubAllEnvs(); }); - it('allows writes in development when an AI agent is detected', () => { + it('allows writes in development without an AI agent, so the writer can look for agent files', () => { expect(isClerkAgentRulesWriteAllowed()).toBe(true); }); - it('blocks writes when no AI agent is detected', () => { - vi.stubEnv('CLAUDECODE', ''); - - expect(isClerkAgentRulesWriteAllowed()).toBe(false); - }); - it('blocks writes in production', () => { vi.stubEnv('NODE_ENV', 'production'); diff --git a/packages/shared/src/__tests__/vite.spec.ts b/packages/shared/src/__tests__/vite.spec.ts index 25b7b44267f..962f3d2b735 100644 --- a/packages/shared/src/__tests__/vite.spec.ts +++ b/packages/shared/src/__tests__/vite.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as AgentRules from '../agentRules'; import { writeClerkAgentRules } from '../agentRules'; -import { agentEnvironmentVariables, automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; +import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment'; import { clerkAgentRulesVitePlugin } from '../vite'; vi.mock('../agentRules', async importOriginal => ({ @@ -15,12 +15,11 @@ describe('clerkAgentRulesVitePlugin', () => { vi.mocked(writeClerkAgentRules) .mockReset() .mockReturnValue({ targetDir: '/app', agentsMd: 'unchanged', files: [] }); - for (const name of [...automatedEnvironmentVariables, ...agentEnvironmentVariables]) { + for (const name of automatedEnvironmentVariables) { vi.stubEnv(name, ''); } vi.stubEnv('CLERK_AGENTS_MD', ''); vi.stubEnv('NODE_ENV', 'development'); - vi.stubEnv('CLAUDECODE', '1'); }); afterEach(() => { diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 0cc3a4335b9..6fbf9b120aa 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -1,3 +1,5 @@ +import { isAgentEnvironment } from '../utils/runtimeEnvironment'; + export type ClerkAgentRulesSdk = | 'astro' | 'backend' @@ -206,6 +208,21 @@ function removeBlock(content: string, id: string, eol: string) { return before && after ? `${before}${eol}${eol}${after}` : `${before}${after}${before || after ? eol : ''}`; } +const AGENT_FILES = [ + 'AGENTS.md', + 'CLAUDE.md', + 'GEMINI.md', + '.claude', + '.codex', + '.cursor', + '.cursorrules', + '.gemini', + '.github/copilot-instructions.md', + '.opencode', + '.windsurf', + '.windsurfrules', +]; + function isDisabledInPackageJson(fs: FileSystemAdapter, path: PathAdapter, dir: string) { try { const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), { encoding: 'utf8' })); @@ -247,6 +264,22 @@ function findAppDirs(fs: FileSystemAdapter, path: PathAdapter, cwd: string) { return dirs.slice(0, 1); } +function findAgentFileDirs(fs: FileSystemAdapter, path: PathAdapter, dirs: string[]) { + let current = dirs[0]; + const root = path.parse(current).root; + const repoDirs: string[] = []; + + while (current !== root) { + repoDirs.push(current); + if (fs.existsSync(path.join(current, '.git'))) { + return repoDirs; + } + current = path.dirname(current); + } + + return dirs; +} + const relativeDir = (rootDir: string, dir: string) => dir === rootDir ? '' : dir.slice(rootDir.length + 1).replace(/\\/g, '/'); @@ -293,6 +326,13 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ return { targetDir: rootDir, agentsMd: 'disabled', files: [agentsPath] }; } + const hasAgentFiles = findAgentFileDirs(fs, path, dirs).some(dir => + AGENT_FILES.some(file => fs.existsSync(path.join(dir, file))), + ); + if (!force && !isAgentEnvironment() && !hasAgentFiles) { + return { targetDir: rootDir, agentsMd: 'skipped', files: [] }; + } + const stateDir = path.join(rootDir, 'node_modules', '.cache', 'clerk', 'agent-rules'); const statePath = (id: string) => path.join(stateDir, encodeURIComponent(id)); const wasRemoved = (id: string) => !force && !existing.includes(blockStart(id)) && fs.existsSync(statePath(id)); diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index 789f1d46fe3..54764d38361 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -101,7 +101,6 @@ export const isClerkAgentRulesWriteAllowed = (): boolean => { !['0', 'false', 'off', 'no'].includes(optOut) && !isProductionEnvironment() && !isTestEnvironment() && - !isAutomatedEnvironment() && - isAgentEnvironment() + !isAutomatedEnvironment() ); }; From deede7d1e6407cb83620693e239857c3ff059182 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 29 Oct 2026 15:32:26 -0400 Subject: [PATCH 31/31] fix(shared): stop apps on different Clerk versions from rewriting each other's AGENTS.md intro The intro section now carries a clerk-agent-rules-version stamp, and the writer never replaces an intro with a higher version. A test pins the intro text to its version, so changing the text without bumping the version fails. Co-Authored-By: Claude Opus 5.5 --- .../shared/src/__tests__/agentRules.spec.ts | 39 +++++++++++++++++++ packages/shared/src/agentRules/index.ts | 17 +++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/__tests__/agentRules.spec.ts b/packages/shared/src/__tests__/agentRules.spec.ts index 0cbb7b275c4..29ba63fe556 100644 --- a/packages/shared/src/__tests__/agentRules.spec.ts +++ b/packages/shared/src/__tests__/agentRules.spec.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -270,6 +272,30 @@ describe('writeClerkAgentRules in a monorepo', () => { expect(memory.read('/repo/AGENTS.md')).toBe('Project rules\n'); }); + it('keeps a newer intro written by an app on a newer Clerk version', () => { + const newerIntro = + '\n\n\nNewer intro\n\n\n'; + const memory = createMonorepo({ '/repo/AGENTS.md': newerIntro }); + + writeExt(memory); + + const content = memory.read('/repo/AGENTS.md'); + expect(content?.startsWith(newerIntro.trimEnd())).toBe(true); + expect(content).toContain(''); + }); + + it('replaces an older intro', () => { + const memory = createMonorepo({ + '/repo/AGENTS.md': + '\n\n\nOlder intro\n\n\n', + }); + + writeExt(memory); + + expect(memory.read('/repo/AGENTS.md')).not.toContain('Older intro'); + expect(memory.read('/repo/AGENTS.md')).toContain('## Clerk: your training data is out of date'); + }); + it('keeps a removed app section out without blocking the other apps', () => { const memory = createMonorepo(); writeWeb(memory); @@ -592,6 +618,19 @@ describe('Clerk plugin rules', () => { expect(renderClerkSdkPluginBlock('\r\n')).not.toMatch(/[^\r]\n/); }); + it('pairs the intro text with its version, so changing the text needs a version bump', () => { + const block = renderClerkAgentRulesBlock(); + const version = block.match(//)?.[1]; + const sha256 = createHash('sha256') + .update(block.replace(//, '')) + .digest('hex'); + + expect({ version, sha256 }).toEqual({ + version: '1', + sha256: '4a7270844bdc65fe7388d963470fc91f6cf047cc916db4a3dc423811996b206c', + }); + }); + it('links agents to the Markdown Clerk docs in the root block', () => { expect(renderClerkAgentRulesBlock()).toContain('Clerk docs: https://clerk.com/docs.md'); }); diff --git a/packages/shared/src/agentRules/index.ts b/packages/shared/src/agentRules/index.ts index 6fbf9b120aa..6c443f22d1d 100644 --- a/packages/shared/src/agentRules/index.ts +++ b/packages/shared/src/agentRules/index.ts @@ -50,6 +50,8 @@ export type WriteClerkAgentRulesResult = { const GENERIC_ID = 'clerk-agent-rules'; const GENERIC_START = ''; const GENERIC_END = ''; +const INTRO_VERSION = 1; +const INTRO_VERSION_PATTERN = //; const SDK_LABELS: Record = { astro: 'Astro', @@ -117,6 +119,7 @@ ${CLERK_SDK_PLUGIN_END}`, export function renderClerkAgentRulesBlock(eol = '\n') { return normalizeEol( `${GENERIC_START} + ## Clerk: your training data is out of date @@ -190,6 +193,15 @@ function upsertMarkedBlock( return `${content.trimEnd()}${eol}${eol}${block}${eol}`; } +function readIntroVersion(content: string) { + const startIndex = content.indexOf(GENERIC_START); + const endIndex = content.indexOf(GENERIC_END, startIndex); + if (startIndex === -1 || endIndex === -1) { + return -1; + } + return Number(content.slice(startIndex, endIndex).match(INTRO_VERSION_PATTERN)?.[1] ?? 0); +} + const blockStart = (id: string) => ``; const blockEnd = (id: string) => ``; @@ -341,7 +353,10 @@ export function writeClerkAgentRules(options: WriteClerkAgentRulesOptions): Writ return { targetDir: rootDir, agentsMd: 'skipped', files: [] }; } - let next = upsertMarkedBlock(existing, renderClerkAgentRulesBlock(eol), GENERIC_START, GENERIC_END, eol); + let next = + readIntroVersion(existing) > INTRO_VERSION + ? existing + : upsertMarkedBlock(existing, renderClerkAgentRulesBlock(eol), GENERIC_START, GENERIC_END, eol); const written = [GENERIC_ID]; for (const sdk of uniqueSdks(sdks)) {