diff --git a/packages/schematics/angular/BUILD.bazel b/packages/schematics/angular/BUILD.bazel index 849c7aa4137b..604cc2476110 100644 --- a/packages/schematics/angular/BUILD.bazel +++ b/packages/schematics/angular/BUILD.bazel @@ -50,10 +50,9 @@ genrule( srcs = [ "//:node_modules/@angular/core/dir", ], - outs = ["ai-config/files/__rulesName__.template"], + outs = ["ai-config/files/__bestPracticesName__.template"], cmd = """ - echo -e "<% if (frontmatter) { %><%= frontmatter %>\\n<% } %>" > $@ - cat "$(location //:node_modules/@angular/core/dir)/resources/best-practices.md" >> $@ + cp "$(location //:node_modules/@angular/core/dir)/resources/best-practices.md" $@ """, ) diff --git a/packages/schematics/angular/ai-config/file_utils.ts b/packages/schematics/angular/ai-config/file_utils.ts new file mode 100644 index 000000000000..78fa685827d1 --- /dev/null +++ b/packages/schematics/angular/ai-config/file_utils.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + Rule, + apply, + applyTemplates, + filter, + forEach, + mergeWith, + move, + noop, + strings, + url, +} from '@angular-devkit/schematics'; +import { FileConfigurationHandlerOptions } from './types'; + +const TOML_MCP_SERVERS_PROP = '[mcp_servers.angular-cli]'; + +/** + * Create or update a JSON MCP configuration file to include the Angular MCP server. + */ +export function addJsonMcpConfig( + { tree, context, fileInfo, tool }: FileConfigurationHandlerOptions, + mcpServersProperty: string, +): Rule { + const { name, directory } = fileInfo; + + return mergeWith( + apply(url('./files'), [ + filter((path) => path.includes('__jsonConfigName__')), + applyTemplates({ + ...strings, + jsonConfigName: name, + mcpServersProperty, + }), + move(directory), + forEach((file) => { + if (!tree.exists(file.path)) { + return file; + } + + const existingFileBuffer = tree.read(file.path); + + // If we have an existing file, update the server property with + // Angular MCP server configuration. + if (existingFileBuffer) { + // The JSON config file should be record-like. + let existing: Record; + try { + existing = JSON.parse(existingFileBuffer.toString()); + } catch { + const path = `${directory}/${name}`; + const toolName = strings.classify(tool); + context.logger.warn( + `Skipping Angular MCP server configuration for '${toolName}'.\n` + + `Unable to modify '${path}'. ` + + 'Make sure that the file has a valid JSON syntax.\n', + ); + + return null; + } + const existingServersProp = existing[mcpServersProperty]; + const templateServersProp = JSON.parse(file.content.toString())[mcpServersProperty]; + + // Note: If the Angular MCP server config already exists, we'll overwrite it. + existing[mcpServersProperty] = existingServersProp + ? { + ...existingServersProp, + ...templateServersProp, + } + : templateServersProp; + + tree.overwrite(file.path, JSON.stringify(existing, null, 2)); + + return null; + } + + return file; + }), + ]), + ); +} + +/** + * Create or update a TOML MCP configuration file to include the Angular MCP server. + */ +export function addTomlMcpConfig({ + tree, + context, + fileInfo, + tool, +}: FileConfigurationHandlerOptions): Rule { + const { name, directory } = fileInfo; + + return mergeWith( + apply(url('./files'), [ + filter((path) => path.includes('__tomlConfigName__')), + applyTemplates({ + ...strings, + tomlConfigName: name, + }), + move(directory), + forEach((file) => { + if (!tree.exists(file.path)) { + return file; + } + + const existingFileBuffer = tree.read(file.path); + + if (existingFileBuffer) { + let existing = existingFileBuffer.toString(); + if (existing.includes(TOML_MCP_SERVERS_PROP)) { + const path = `${directory}/${name}`; + const toolName = strings.classify(tool); + context.logger.warn( + `Skipping Angular MCP server configuration for '${toolName}'.\n` + + `Configuration already exists in '${path}'.\n`, + ); + + return null; + } + + // Add the configuration at the end of the file. + const template = file.content.toString(); + existing = existing.length ? existing + '\n' + template : template; + + tree.overwrite(file.path, existing); + + return null; + } + + return file; + }), + ]), + ); +} + +/** + * Create an Angular best practices Markdown. + * If the file exists, the configuration is skipped. + */ +export function addBestPracticesMarkdown({ + tree, + context, + fileInfo, + tool, +}: FileConfigurationHandlerOptions): Rule { + const { name, directory } = fileInfo; + const path = `${directory}/${name}`; + + if (tree.exists(path)) { + const toolName = strings.classify(tool); + context.logger.warn( + `Skipping configuration file for '${toolName}' at '${path}' because it already exists.\n` + + 'This is to prevent overwriting a potentially customized file. ' + + 'If you want to regenerate it with Angular recommended defaults, please delete the existing file and re-run the command.\n' + + 'You can review the latest recommendations at https://angular.dev/ai/develop-with-ai.\n', + ); + + return noop(); + } + + return mergeWith( + apply(url('./files'), [ + filter((path) => path.includes('__bestPracticesName__')), + applyTemplates({ + ...strings, + bestPracticesName: name, + }), + move(directory), + ]), + ); +} diff --git a/packages/schematics/angular/ai-config/file_utils_spec.ts b/packages/schematics/angular/ai-config/file_utils_spec.ts new file mode 100644 index 000000000000..2584a7c2f190 --- /dev/null +++ b/packages/schematics/angular/ai-config/file_utils_spec.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// TBD diff --git a/packages/schematics/angular/ai-config/files/__jsonConfigName__.template b/packages/schematics/angular/ai-config/files/__jsonConfigName__.template new file mode 100644 index 000000000000..4da7c6e12f7b --- /dev/null +++ b/packages/schematics/angular/ai-config/files/__jsonConfigName__.template @@ -0,0 +1,8 @@ +{ + "<%= mcpServersProperty %>": { + "angular-cli": { + "command": "npx", + "args": ["-y", "@angular/cli", "mcp"] + } + } +} diff --git a/packages/schematics/angular/ai-config/files/__tomlConfigName__.template b/packages/schematics/angular/ai-config/files/__tomlConfigName__.template new file mode 100644 index 000000000000..74e6b49b22ae --- /dev/null +++ b/packages/schematics/angular/ai-config/files/__tomlConfigName__.template @@ -0,0 +1,3 @@ +[mcp_servers.angular-cli] +command = "npx" +args = ["-y", "@angular/cli", "mcp"] diff --git a/packages/schematics/angular/ai-config/index.ts b/packages/schematics/angular/ai-config/index.ts index f332fd8b7e39..7dab2fccbdef 100644 --- a/packages/schematics/angular/ai-config/index.ts +++ b/packages/schematics/angular/ai-config/index.ts @@ -6,57 +6,63 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Rule, - apply, - applyTemplates, - chain, - mergeWith, - move, - noop, - strings, - url, -} from '@angular-devkit/schematics'; +import { Rule, chain, noop, strings } from '@angular-devkit/schematics'; +import { addBestPracticesMarkdown, addJsonMcpConfig, addTomlMcpConfig } from './file_utils'; import { Schema as ConfigOptions, Tool } from './schema'; +import { ContextFileInfo, ContextFileType, FileConfigurationHandlerOptions } from './types'; -const AI_TOOLS: { [key in Exclude]: ContextFileInfo } = { - agents: { - rulesName: 'AGENTS.md', - directory: '.', - }, - gemini: { - rulesName: 'GEMINI.md', - directory: '.gemini', - }, - claude: { - rulesName: 'CLAUDE.md', - directory: '.claude', - }, - copilot: { - rulesName: 'copilot-instructions.md', - directory: '.github', - }, - windsurf: { - rulesName: 'guidelines.md', - directory: '.windsurf/rules', - }, - jetbrains: { - rulesName: 'guidelines.md', - directory: '.junie', - }, - // Cursor file has a front matter section. - cursor: { - rulesName: 'cursor.mdc', - directory: '.cursor/rules', - frontmatter: `---\ncontext: true\npriority: high\nscope: project\n---`, - }, +const AGENTS_MD_CFG: ContextFileInfo = { + type: ContextFileType.BestPracticesMd, + name: 'AGENTS.md', + directory: '.', }; -interface ContextFileInfo { - rulesName: string; - directory: string; - frontmatter?: string; -} +const AI_TOOLS: { [key in Exclude]: ContextFileInfo[] } = { + ['claude-code']: [ + AGENTS_MD_CFG, + { + type: ContextFileType.McpConfig, + name: '.mcp.json', + directory: '.', + }, + ], + cursor: [ + AGENTS_MD_CFG, + { + type: ContextFileType.McpConfig, + name: 'mcp.json', + directory: '.cursor', + }, + ], + ['gemini-cli']: [ + { + type: ContextFileType.BestPracticesMd, + name: 'GEMINI.md', + directory: '.gemini', + }, + { + type: ContextFileType.McpConfig, + name: 'settings.json', + directory: '.gemini', + }, + ], + ['open-ai-codex']: [ + AGENTS_MD_CFG, + { + type: ContextFileType.McpConfig, + name: 'config.toml', + directory: '.codex', + }, + ], + vscode: [ + AGENTS_MD_CFG, + { + type: ContextFileType.McpConfig, + name: 'mcp.json', + directory: '.vscode', + }, + ], +}; export default function ({ tool }: ConfigOptions): Rule { return (tree, context) => { @@ -66,33 +72,36 @@ export default function ({ tool }: ConfigOptions): Rule { const rules = tool .filter((tool) => tool !== Tool.None) - .map((selectedTool) => { - const { rulesName, directory, frontmatter } = AI_TOOLS[selectedTool]; - const path = `${directory}/${rulesName}`; - - if (tree.exists(path)) { - const toolName = strings.classify(selectedTool); - context.logger.warn( - `Skipping configuration file for '${toolName}' at '${path}' because it already exists.\n` + - 'This is to prevent overwriting a potentially customized file. ' + - 'If you want to regenerate it with Angular recommended defaults, please delete the existing file and re-run the command.\n' + - 'You can review the latest recommendations at https://angular.dev/ai/develop-with-ai.', - ); - - return noop(); - } + .flatMap((selectedTool) => + AI_TOOLS[selectedTool].map((fileInfo) => { + const fileCfgOpts: FileConfigurationHandlerOptions = { + tree, + context, + fileInfo, + tool: selectedTool, + }; - return mergeWith( - apply(url('./files'), [ - applyTemplates({ - ...strings, - rulesName, - frontmatter, - }), - move(directory), - ]), - ); - }); + switch (fileInfo.type) { + case ContextFileType.BestPracticesMd: + return addBestPracticesMarkdown(fileCfgOpts); + case ContextFileType.McpConfig: + switch (selectedTool) { + case Tool.ClaudeCode: + case Tool.Cursor: + case Tool.GeminiCli: + return addJsonMcpConfig(fileCfgOpts, 'mcpServers'); + case Tool.OpenAiCodex: + return addTomlMcpConfig(fileCfgOpts); + case Tool.Vscode: + return addJsonMcpConfig(fileCfgOpts, 'servers'); + default: + throw new Error( + `Unsupported '${strings.classify(selectedTool)}' MCP server configuraiton.`, + ); + } + } + }), + ); return chain(rules); }; diff --git a/packages/schematics/angular/ai-config/index_spec.ts b/packages/schematics/angular/ai-config/index_spec.ts index 63b1bb205963..bccbba880ae1 100644 --- a/packages/schematics/angular/ai-config/index_spec.ts +++ b/packages/schematics/angular/ai-config/index_spec.ts @@ -10,7 +10,7 @@ import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/te import { Schema as WorkspaceOptions } from '../workspace/schema'; import { Schema as ConfigOptions, Tool as ConfigTool } from './schema'; -describe('Ai Config Schematic', () => { +describe('AI Config Schematic', () => { const schematicRunner = new SchematicTestRunner( '@schematics/angular', require.resolve('../collection.json'), @@ -23,7 +23,7 @@ describe('Ai Config Schematic', () => { }; let workspaceTree: UnitTestTree; - function runConfigSchematic(tool: ConfigTool[]): Promise { + function runAiConfigSchematic(tool: ConfigTool[]): Promise { return schematicRunner.runSchematic('ai-config', { tool }, workspaceTree); } @@ -31,82 +31,58 @@ describe('Ai Config Schematic', () => { workspaceTree = await schematicRunner.runSchematic('workspace', workspaceOptions); }); - it('should create an AGENTS.md file', async () => { - const tree = await runConfigSchematic([ConfigTool.Agents]); + it('should create Angular MCP server config and AGENTS.md for Claude Code', async () => { + const tree = await runAiConfigSchematic([ConfigTool.ClaudeCode]); expect(tree.exists('AGENTS.md')).toBeTruthy(); + expect(tree.exists('.mcp.json')).toBeTruthy(); }); - it('should create a GEMINI.MD file', async () => { - const tree = await runConfigSchematic([ConfigTool.Gemini]); - expect(tree.exists('.gemini/GEMINI.md')).toBeTruthy(); - }); - - it('should create a copilot-instructions.md file', async () => { - const tree = await runConfigSchematic([ConfigTool.Copilot]); - expect(tree.exists('.github/copilot-instructions.md')).toBeTruthy(); - }); - - it('should create a cursor file', async () => { - const tree = await runConfigSchematic([ConfigTool.Cursor]); - expect(tree.exists('.cursor/rules/cursor.mdc')).toBeTruthy(); + it('should create Angular MCP server config and AGENTS.md for Cursor', async () => { + const tree = await runAiConfigSchematic([ConfigTool.Cursor]); + expect(tree.exists('AGENTS.md')).toBeTruthy(); + expect(tree.exists('.cursor/mcp.json')).toBeTruthy(); }); - it('should create a windsurf file', async () => { - const tree = await runConfigSchematic([ConfigTool.Windsurf]); - expect(tree.exists('.windsurf/rules/guidelines.md')).toBeTruthy(); + it('should create Angular MCP server config and GEMINI.md for Gemini CLI', async () => { + const tree = await runAiConfigSchematic([ConfigTool.GeminiCli]); + expect(tree.exists('.gemini/GEMINI.md')).toBeTruthy(); + expect(tree.exists('.gemini/settings.json')).toBeTruthy(); }); - it('should create a claude file', async () => { - const tree = await runConfigSchematic([ConfigTool.Claude]); - expect(tree.exists('.claude/CLAUDE.md')).toBeTruthy(); + it('should create Angular MCP server config and AGENTS.md for Open AI Codex', async () => { + const tree = await runAiConfigSchematic([ConfigTool.OpenAiCodex]); + expect(tree.exists('AGENTS.md')).toBeTruthy(); + expect(tree.exists('.codex/config.toml')).toBeTruthy(); }); - it('should create a jetbrains file', async () => { - const tree = await runConfigSchematic([ConfigTool.Jetbrains]); - expect(tree.exists('.junie/guidelines.md')).toBeTruthy(); + it('should create Angular MCP server config and AGENTS.md for VS Code', async () => { + const tree = await runAiConfigSchematic([ConfigTool.Vscode]); + expect(tree.exists('AGENTS.md')).toBeTruthy(); + expect(tree.exists('.vscode/mcp.json')).toBeTruthy(); }); it('should create multiple files when multiple tools are selected', async () => { - const tree = await runConfigSchematic([ - ConfigTool.Gemini, - ConfigTool.Copilot, + const tree = await runAiConfigSchematic([ + ConfigTool.GeminiCli, + ConfigTool.Vscode, ConfigTool.Cursor, ]); + expect(tree.exists('AGENTS.md')).toBeTruthy(); expect(tree.exists('.gemini/GEMINI.md')).toBeTruthy(); - expect(tree.exists('.github/copilot-instructions.md')).toBeTruthy(); - expect(tree.exists('.cursor/rules/cursor.mdc')).toBeTruthy(); + expect(tree.exists('.gemini/settings.json')).toBeTruthy(); + expect(tree.exists('.vscode/mcp.json')).toBeTruthy(); + expect(tree.exists('.cursor/mcp.json')).toBeTruthy(); }); it('should not create any files if None is selected', async () => { const filesCount = workspaceTree.files.length; - const tree = await runConfigSchematic([ConfigTool.None]); + const tree = await runAiConfigSchematic([ConfigTool.None]); expect(tree.files.length).toBe(filesCount); }); - it('should not overwrite an existing file', async () => { - const customContent = 'custom user content'; - workspaceTree.create('.gemini/GEMINI.md', customContent); - - const messages: string[] = []; - const loggerSubscription = schematicRunner.logger.subscribe((x) => messages.push(x.message)); - - try { - const tree = await runConfigSchematic([ConfigTool.Gemini]); - - expect(tree.readContent('.gemini/GEMINI.md')).toBe(customContent); - expect(messages).toContain( - `Skipping configuration file for 'Gemini' at '.gemini/GEMINI.md' because it already exists.\n` + - 'This is to prevent overwriting a potentially customized file. ' + - 'If you want to regenerate it with Angular recommended defaults, please delete the existing file and re-run the command.\n' + - 'You can review the latest recommendations at https://angular.dev/ai/develop-with-ai.', - ); - } finally { - loggerSubscription.unsubscribe(); - } - }); - - it('should create for tool if None and Gemini are selected', async () => { - const tree = await runConfigSchematic([ConfigTool.Gemini, ConfigTool.None]); + it('should create for tool if None and an AI host are selected', async () => { + const tree = await runAiConfigSchematic([ConfigTool.GeminiCli, ConfigTool.None]); expect(tree.exists('.gemini/GEMINI.md')).toBeTruthy(); + expect(tree.exists('.gemini/settings.json')).toBeTruthy(); }); }); diff --git a/packages/schematics/angular/ai-config/schema.json b/packages/schematics/angular/ai-config/schema.json index bbfc21028c9f..4cb63468ae53 100644 --- a/packages/schematics/angular/ai-config/schema.json +++ b/packages/schematics/angular/ai-config/schema.json @@ -4,14 +4,14 @@ "title": "Angular AI Config File Options Schema", "type": "object", "additionalProperties": false, - "description": "Generates AI configuration files for Angular projects. This schematic creates configuration files that help AI tools follow Angular best practices, improving the quality of AI-generated code and suggestions.", + "description": "Generates AI configuration files for Angular projects. This schematic creates AGENTS.md file and Angular MCP server configuration, improving the quality of AI-generated code and suggestions.", "properties": { "tool": { "type": "array", "uniqueItems": true, "default": ["none"], "x-prompt": { - "message": "Which AI tools do you want to configure with Angular best practices? https://angular.dev/ai/develop-with-ai", + "message": "Which AI tools should Angular integrate with? https://angular.dev/ai/develop-with-ai", "type": "list", "items": [ { @@ -19,39 +19,31 @@ "label": "None" }, { - "value": "agents", - "label": "Agents.md [ https://agents.md/ ]" - }, - { - "value": "claude", - "label": "Claude [ https://docs.anthropic.com/en/docs/claude-code/memory ]" + "value": "claude-code", + "label": "Claude Code [ `AGENTS.md` + Angular MCP server config ]" }, { "value": "cursor", - "label": "Cursor [ https://docs.cursor.com/en/context/rules ]" - }, - { - "value": "gemini", - "label": "Gemini [ https://ai.google.dev/gemini-api/docs ]" + "label": "Cursor [ `AGENTS.md` + Angular MCP server config ]" }, { - "value": "copilot", - "label": "GitHub Copilot [ https://code.visualstudio.com/docs/copilot/copilot-customization ]" + "value": "gemini-cli", + "label": "Gemini CLI [ `GEMINI.md` + Angular MCP server config ]" }, { - "value": "jetbrains", - "label": "JetBrains AI [ https://www.jetbrains.com/help/junie/customize-guidelines.html ]" + "value": "open-ai-codex", + "label": "Open AI Codex [ `AGENTS.md` + Angular MCP server config ]" }, { - "value": "windsurf", - "label": "Windsurf [ https://docs.windsurf.com/windsurf/cascade/memories#rules ]" + "value": "vscode", + "label": "VSCode [ `AGENTS.md` + Angular MCP server config ]" } ] }, - "description": "Specifies which AI tools to generate configuration files for. These file are used to improve the outputs of AI tools by following the best practices.", + "description": "Specifies which AI tools to generate configuration files (AGENTS.md, MCP server config) for.", "items": { "type": "string", - "enum": ["none", "gemini", "copilot", "claude", "cursor", "jetbrains", "windsurf", "agents"] + "enum": ["none", "claude-code", "cursor", "gemini-cli", "open-ai-codex", "vscode"] } } } diff --git a/packages/schematics/angular/ai-config/types.ts b/packages/schematics/angular/ai-config/types.ts new file mode 100644 index 000000000000..1d4fe78a7b2a --- /dev/null +++ b/packages/schematics/angular/ai-config/types.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SchematicContext, Tree } from '@angular-devkit/schematics'; +import { Tool } from './schema'; + +/** + * Types of supported AI configuration files. + */ +export enum ContextFileType { + /** Represents a Markdown AI instructions file (e.g. AGENTS.md). */ + BestPracticesMd = 0, + + /** Represents an MCP server configuration (e.g. Angular MCP). */ + McpConfig = 1, +} + +/** + * AI configuration file metadata. + */ +export interface ContextFileInfo { + type: ContextFileType; + name: string; + directory: string; +} + +/** + * Represents the file configuration handler options + * that are normally passed to the handler functions. + */ +export type FileConfigurationHandlerOptions = { + tree: Tree; + context: SchematicContext; + fileInfo: ContextFileInfo; + tool: Tool; +}; diff --git a/packages/schematics/angular/ng-new/index_spec.ts b/packages/schematics/angular/ng-new/index_spec.ts index ad97df398fba..a9a6b4a1b6b2 100644 --- a/packages/schematics/angular/ng-new/index_spec.ts +++ b/packages/schematics/angular/ng-new/index_spec.ts @@ -104,13 +104,15 @@ describe('Ng New Schematic', () => { expect(cli.packageManager).toBe('npm'); }); - it('should add ai config file when aiConfig is set', async () => { - const options = { ...defaultOptions, aiConfig: ['gemini', 'claude'] }; + it('should add AI config file when aiConfig is set', async () => { + const options = { ...defaultOptions, aiConfig: ['gemini-cli', 'claude-code'] }; const tree = await schematicRunner.runSchematic('ng-new', options); const files = tree.files; + expect(files).toContain('/bar/AGENTS.md'); + expect(files).toContain('/bar/.mcp.json'); expect(files).toContain('/bar/.gemini/GEMINI.md'); - expect(files).toContain('/bar/.claude/CLAUDE.md'); + expect(files).toContain('/bar/.gemini/settings.json'); }); it('should create a tailwind project when style is tailwind', async () => { diff --git a/packages/schematics/angular/ng-new/schema.json b/packages/schematics/angular/ng-new/schema.json index 30957b9342c1..257628fcda9e 100644 --- a/packages/schematics/angular/ng-new/schema.json +++ b/packages/schematics/angular/ng-new/schema.json @@ -155,7 +155,7 @@ "description": "Specifies which AI tools to generate configuration files for. These file are used to improve the outputs of AI tools by following the best practices.", "items": { "type": "string", - "enum": ["none", "gemini", "copilot", "claude", "cursor", "jetbrains", "windsurf", "agents"] + "enum": ["none", "claude-code", "cursor", "gemini-cli", "open-ai-codex", "vscode"] } }, "fileNameStyleGuide": {