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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/schematics/angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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" $@
""",
)

Expand Down
179 changes: 179 additions & 0 deletions packages/schematics/angular/ai-config/file_utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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),
]),
);
}
9 changes: 9 additions & 0 deletions packages/schematics/angular/ai-config/file_utils_spec.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"<%= mcpServersProperty %>": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[mcp_servers.angular-cli]
command = "npx"
args = ["-y", "@angular/cli", "mcp"]
155 changes: 82 additions & 73 deletions packages/schematics/angular/ai-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tool, Tool.None>]: 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<Tool, Tool.None>]: 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) => {
Expand All @@ -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);
};
Expand Down
Loading
Loading