Skip to content
Closed
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
10 changes: 9 additions & 1 deletion packages/schematics/angular/ai-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { Rule, chain, noop, strings } from '@angular-devkit/schematics';
import { addBestPracticesMarkdown, addJsonMcpConfig, addTomlMcpConfig } from './file_utils';
import { createAngularSkillsTask } from './install-skills';
import { Schema as ConfigOptions, Tool } from './schema';
import { ContextFileInfo, ContextFileType, FileConfigurationHandlerOptions } from './types';

Expand Down Expand Up @@ -64,7 +65,7 @@ const AI_TOOLS: { [key in Exclude<Tool, Tool.None>]: ContextFileInfo[] } = {
],
};

export default function ({ tool }: ConfigOptions): Rule {
export default function ({ tool, aiSkills }: ConfigOptions): Rule {
return (tree, context) => {
if (!tool) {
return noop();
Expand Down Expand Up @@ -103,6 +104,13 @@ export default function ({ tool }: ConfigOptions): Rule {
}),
);

if (aiSkills) {
const selectedTools = tool.filter((tool) => tool !== Tool.None);
if (selectedTools.length > 0) {
context.addTask(createAngularSkillsTask(selectedTools));
}
}

return chain(rules);
};
}
76 changes: 74 additions & 2 deletions packages/schematics/angular/ai-config/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { parse } from 'jsonc-parser';
import { Schema as WorkspaceOptions } from '../workspace/schema';
import { getAngularSkillsInstallArguments } from './install-skills';
import { Schema as ConfigOptions, Tool as ConfigTool } from './schema';

describe('AI Config Schematic', () => {
Expand All @@ -24,14 +25,57 @@ describe('AI Config Schematic', () => {
};

let workspaceTree: UnitTestTree;
function runAiConfigSchematic(tool: ConfigTool[]): Promise<UnitTestTree> {
return schematicRunner.runSchematic<ConfigOptions>('ai-config', { tool }, workspaceTree);
function runAiConfigSchematic(
tool: ConfigTool[],
options: Partial<ConfigOptions> = {},
): Promise<UnitTestTree> {
return schematicRunner.runSchematic<ConfigOptions>(
'ai-config',
{ tool, ...options },
workspaceTree,
);
}

beforeEach(async () => {
workspaceTree = await schematicRunner.runSchematic('workspace', workspaceOptions);
});

it('should create a non-interactive skills command for each selected AI tool', () => {
expect(
getAngularSkillsInstallArguments(
[
ConfigTool.ClaudeCode,
ConfigTool.Cursor,
ConfigTool.GeminiCli,
ConfigTool.OpenAiCodex,
ConfigTool.Vscode,
],
'^22.1.0-next.0',
),
).toEqual([
'--yes',
'skills',
'add',
'https://github.com/angular/skills/tree/22.1.x',
'--skill',
'angular-developer',
'--skill',
'angular-new-app',
'--agent',
'claude-code',
'--agent',
'cursor',
'--agent',
'gemini-cli',
'--agent',
'codex',
'--agent',
'github-copilot',
'--copy',
'--yes',
]);
});

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();
Expand Down Expand Up @@ -109,6 +153,34 @@ describe('AI Config Schematic', () => {
}
});

it('should schedule Angular skills installation when enabled', async () => {
await runAiConfigSchematic([ConfigTool.ClaudeCode], { aiSkills: true });

expect(schematicRunner.tasks.length).toBe(1);
expect(schematicRunner.tasks[0]).toEqual({
name: 'run-schematic',
options: {
collection: null,
name: 'ai-config-install-skills',
options: {
tools: ['claude-code'],
},
},
});
});

it('should not install Angular skills when the user declines', async () => {
await runAiConfigSchematic([ConfigTool.ClaudeCode], { aiSkills: false });

expect(schematicRunner.tasks).toEqual([]);
});

it('should not install Angular skills when no AI tool is selected', async () => {
await runAiConfigSchematic([ConfigTool.None], { aiSkills: true });

expect(schematicRunner.tasks).toEqual([]);
});

it('should update JSON MCP server config, if the file exists', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const jsonConfig: Record<string, any> = {
Expand Down
98 changes: 98 additions & 0 deletions packages/schematics/angular/ai-config/install-skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @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 } from '@angular-devkit/schematics';
import { RunSchematicTask } from '@angular-devkit/schematics/tasks';
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { latestVersions } from '../utility/latest-versions';
import { Tool } from './schema';

type SkillsTool = Exclude<Tool, Tool.None>;

interface AngularSkillsInstallOptions {
tools: readonly string[];
workingDirectory?: string;
}

const SKILLS_AGENT: { [key in SkillsTool]: string } = {
['claude-code']: 'claude-code',
cursor: 'cursor',
['gemini-cli']: 'gemini-cli',
['open-ai-codex']: 'codex',
vscode: 'github-copilot',
};

const ANGULAR_SKILLS_REPOSITORY = 'https://github.com/angular/skills';

export function getAngularSkillsRepository(angularVersion = latestVersions.Angular): string {
const version = angularVersion.match(/(\d+)\.(\d+)/);
if (!version) {
throw new Error(`Unable to determine the Angular release line from '${angularVersion}'.`);
}

return `${ANGULAR_SKILLS_REPOSITORY}/tree/${version[1]}.${version[2]}.x`;
}

export function getAngularSkillsInstallArguments(
tools: readonly string[],
angularVersion = latestVersions.Angular,
): string[] {
const agents = tools.flatMap((tool) => {
const agent = SKILLS_AGENT[tool as SkillsTool];
if (!agent) {
throw new Error(`Unsupported AI tool '${tool}' for Angular Agent Skills.`);
}

return ['--agent', agent];
});

return [
'--yes',
'skills',
'add',
getAngularSkillsRepository(angularVersion),
'--skill',
'angular-developer',
'--skill',
'angular-new-app',
...agents,
'--copy',
'--yes',
];
}

export function createAngularSkillsTask(
tools: readonly string[],
workingDirectory?: string,
): RunSchematicTask<AngularSkillsInstallOptions> {
return new RunSchematicTask(
'ai-config-install-skills',
workingDirectory === undefined ? { tools } : { tools, workingDirectory },
);
}

export default function (options: AngularSkillsInstallOptions): Rule {
return (_tree, context) => {
const args = getAngularSkillsInstallArguments(options.tools);

try {
execFileSync('npx', args, {
cwd: resolve(options.workingDirectory ?? '.'),
env: { ...process.env, DISABLE_TELEMETRY: '1' },
stdio: 'inherit',
shell: process.platform === 'win32',
});
} catch {
context.logger.warn(
'Angular Agent Skills could not be installed.\n' +
`When you are online, install them manually with:\n npx ${args.join(' ')}`,
);
}
};
}
7 changes: 6 additions & 1 deletion packages/schematics/angular/ai-config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"title": "Angular AI Config File Options Schema",
"type": "object",
"additionalProperties": false,
"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.",
"description": "Generates AI configuration files for Angular projects and can install the official Angular Agent Skills.",
"properties": {
"tool": {
"type": "array",
Expand Down Expand Up @@ -45,6 +45,11 @@
"type": "string",
"enum": ["none", "claude-code", "cursor", "gemini-cli", "open-ai-codex", "vscode"]
}
},
"aiSkills": {
"type": "boolean",
"description": "Installs the official Angular Agent Skills locally for the selected AI tools.",
"x-prompt": "Install the official Angular Agent Skills for the selected AI tools? This downloads skills from github.com/angular/skills."
}
}
}
6 changes: 6 additions & 0 deletions packages/schematics/angular/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@
"schema": "./ai-config/schema.json",
"description": "Generates an AI tool configuration file."
},
"ai-config-install-skills": {
"factory": "./ai-config/install-skills",
"hidden": true,
"private": true,
"description": "[INTERNAL] Installs the official Angular Agent Skills."
},
"tailwind": {
"factory": "./tailwind",
"schema": "./tailwind/schema.json",
Expand Down
13 changes: 12 additions & 1 deletion packages/schematics/angular/ng-new/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
NodePackageInstallTask,
RepositoryInitializerTask,
} from '@angular-devkit/schematics/tasks';
import { createAngularSkillsTask } from '../ai-config/install-skills';
import { Schema as ApplicationOptions } from '../application/schema';
import { JSONFile } from '../utility/json-file';
import { Schema as WorkspaceOptions } from '../workspace/schema';
Expand Down Expand Up @@ -81,6 +82,7 @@ export default function (options: NgNewOptions): Rule {
options.createApplication ? schematic('application', applicationOptions) : noop,
schematic('ai-config', {
tool: options.aiConfig?.length ? options.aiConfig : undefined,
aiSkills: false,
}),
move(options.directory),
]),
Expand All @@ -95,13 +97,22 @@ export default function (options: NgNewOptions): Rule {
}),
);
}

let skillsTask;
const aiTools = (options.aiConfig ?? []).filter((tool) => tool !== 'none');
if (options.aiSkills && aiTools.length > 0) {
skillsTask = context.addTask(
createAngularSkillsTask(aiTools, options.directory),
packageTask ? [packageTask] : [],
);
}
if (!options.skipGit) {
const commit =
typeof options.commit == 'object' ? options.commit : options.commit ? {} : false;

context.addTask(
new RepositoryInitializerTask(options.directory, commit),
packageTask ? [packageTask] : [],
skillsTask ? [skillsTask] : packageTask ? [packageTask] : [],
);
}
},
Expand Down
25 changes: 25 additions & 0 deletions packages/schematics/angular/ng-new/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,31 @@ describe('Ng New Schematic', () => {
expect(files).toContain('/bar/.gemini/settings.json');
});

it('should install Angular skills before initializing the repository', async () => {
await schematicRunner.runSchematic('ng-new', {
...defaultOptions,
aiConfig: ['gemini-cli', 'claude-code'],
aiSkills: true,
});

const skillsTask = schematicRunner.tasks.find(
(task) => (task.options as { name?: string })?.name === 'ai-config-install-skills',
);
expect(skillsTask?.options).toEqual(
jasmine.objectContaining({
options: {
workingDirectory: 'bar',
tools: ['gemini-cli', 'claude-code'],
},
}),
);
expect(
schematicRunner.tasks.findIndex(
(task) => (task.options as { name?: string })?.name === 'ai-config-install-skills',
),
).toBeLessThan(schematicRunner.tasks.findIndex((task) => task.name === 'repo-init'));
});

it('should create a tailwind project when style is tailwind', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = { ...defaultOptions, style: 'tailwind' as any };
Expand Down
35 changes: 35 additions & 0 deletions packages/schematics/angular/ng-new/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,47 @@
"aiConfig": {
"type": "array",
"uniqueItems": true,
"x-prompt": {
"message": "Which AI tools should Angular integrate with? https://angular.dev/ai/develop-with-ai",
"type": "list",
"items": [
{
"value": "none",
"label": "None"
},
{
"value": "claude-code",
"label": "Claude Code [ `AGENTS.md` + Angular MCP server config ]"
},
{
"value": "cursor",
"label": "Cursor [ `AGENTS.md` + Angular MCP server config ]"
},
{
"value": "gemini-cli",
"label": "Gemini CLI [ `GEMINI.md` + Angular MCP server config ]"
},
{
"value": "open-ai-codex",
"label": "Open AI Codex [ `AGENTS.md` + Angular MCP server config ]"
},
{
"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.",
"items": {
"type": "string",
"enum": ["none", "claude-code", "cursor", "gemini-cli", "open-ai-codex", "vscode"]
}
},
"aiSkills": {
"type": "boolean",
"description": "Installs the official Angular Agent Skills locally for the selected AI tools. When omitted with non-interactive defaults, skills are not installed.",
"x-prompt": "Install the official Angular Agent Skills for the selected AI tools? This downloads skills from github.com/angular/skills."
},
"fileNameStyleGuide": {
"type": "string",
"enum": ["2016", "2025"],
Expand Down
Loading