Skip to content

Commit 614ed54

Browse files
committed
feat(@angular-devkit/schematics): add schematics to generate ai context files.
* `ng generate config ai` to prompt support tools. * `ng generate config ai --tool=gemini` to specify the tool. Supported ai tools: gemini, claude, copilot, windsurf, cursor.
1 parent 5eeebf9 commit 614ed54

File tree

9 files changed

+213
-5
lines changed

9 files changed

+213
-5
lines changed

goldens/public-api/angular_devkit/schematics/index.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,10 @@ export enum MergeStrategy {
637637
export function mergeWith(source: Source, strategy?: MergeStrategy): Rule;
638638

639639
// @public (undocumented)
640-
export function move(from: string, to?: string): Rule;
640+
export function move(from: string, to: string): Rule;
641+
642+
// @public (undocumented)
643+
export function move(to: string): Rule;
641644

642645
// @public (undocumented)
643646
export function noop(): Rule;

packages/angular_devkit/schematics/src/rules/move.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { join, normalize } from '@angular-devkit/core';
1010
import { Rule } from '../engine/interface';
1111
import { noop } from './base';
1212

13+
export function move(from: string, to: string): Rule;
14+
export function move(to: string): Rule;
1315
export function move(from: string, to?: string): Rule {
1416
if (to === undefined) {
1517
to = from;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<% if (frontmatter) { %><%= frontmatter %>
2+
3+
<% } %>You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices.
4+
5+
## TypeScript Best Practices
6+
7+
- Use strict type checking
8+
- Prefer type inference when the type is obvious
9+
- Avoid the `any` type; use `unknown` when type is uncertain
10+
11+
## Angular Best Practices
12+
13+
- Always use standalone components over NgModules
14+
- Must NOT set `standalone: true` inside Angular decorators. It's the default.
15+
- Use signals for state management
16+
- Implement lazy loading for feature routes
17+
- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
18+
- Use `NgOptimizedImage` for all static images.
19+
- `NgOptimizedImage` does not work for inline base64 images.
20+
21+
## Components
22+
23+
- Keep components small and focused on a single responsibility
24+
- Use `input()` and `output()` functions instead of decorators
25+
- Use `computed()` for derived state
26+
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
27+
- Prefer inline templates for small components
28+
- Prefer Reactive forms instead of Template-driven ones
29+
- Do NOT use `ngClass`, use `class` bindings instead
30+
- DO NOT use `ngStyle`, use `style` bindings instead
31+
32+
## State Management
33+
34+
- Use signals for local component state
35+
- Use `computed()` for derived state
36+
- Keep state transformations pure and predictable
37+
- Do NOT use `mutate` on signals, use `update` or `set` instead
38+
39+
## Templates
40+
41+
- Keep templates simple and avoid complex logic
42+
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
43+
- Use the async pipe to handle observables
44+
45+
## Services
46+
47+
- Design services around a single responsibility
48+
- Use the `providedIn: 'root'` option for singleton services
49+
- Use the `inject()` function instead of constructor injection
50+
51+
## Common pitfalls
52+
53+
- Control flow (`@if`):
54+
- You cannot use `as` expressions in `@else if (...)`. E.g. invalid code: `@else if (bla(); as x)`.

packages/schematics/angular/config/index.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SchematicsException,
1212
apply,
1313
applyTemplates,
14+
chain,
1415
filter,
1516
mergeWith,
1617
move,
@@ -24,12 +25,40 @@ import { getWorkspace as readWorkspace, updateWorkspace } from '../utility/works
2425
import { Builders as AngularBuilder } from '../utility/workspace-models';
2526
import { Schema as ConfigOptions, Type as ConfigType } from './schema';
2627

28+
const geminiFile: ContextFileInfo = { rulesName: 'GEMINI.md', directory: '.gemini' };
29+
const copilotFile: ContextFileInfo = {
30+
rulesName: 'copilot-instructions.md',
31+
directory: '.github',
32+
};
33+
const claudeFile: ContextFileInfo = { rulesName: 'CLAUDE.md', directory: '.claude' };
34+
const windsurfFile: ContextFileInfo = {
35+
rulesName: 'guidelines.md',
36+
directory: path.join('.windsurf', 'rules'),
37+
};
38+
39+
// Cursor file is a bit different, it has a front matter section.
40+
const cursorFile: ContextFileInfo = {
41+
rulesName: 'cursor.mdc',
42+
directory: path.join('.cursor', 'rules'),
43+
frontmatter: `---\ncontext: true\npriority: high\nscope: project\n---`,
44+
};
45+
46+
const AI_TOOLS = {
47+
'gemini': geminiFile,
48+
'claude': claudeFile,
49+
'copilot': copilotFile,
50+
'cursor': cursorFile,
51+
'windsurf': windsurfFile,
52+
};
53+
2754
export default function (options: ConfigOptions): Rule {
2855
switch (options.type) {
2956
case ConfigType.Karma:
3057
return addKarmaConfig(options);
3158
case ConfigType.Browserslist:
3259
return addBrowserslistConfig(options);
60+
case ConfigType.Ai:
61+
return addAiContextFile(options);
3362
default:
3463
throw new SchematicsException(`"${options.type}" is an unknown configuration file type.`);
3564
}
@@ -103,3 +132,40 @@ function addKarmaConfig(options: ConfigOptions): Rule {
103132
);
104133
});
105134
}
135+
136+
interface ContextFileInfo {
137+
rulesName: string;
138+
directory: string;
139+
frontmatter?: string;
140+
}
141+
142+
function addAiContextFile(options: ConfigOptions): Rule {
143+
const selectedTool = options.tool as keyof typeof AI_TOOLS;
144+
const files: ContextFileInfo[] =
145+
options.tool === 'all' ? Object.values(AI_TOOLS) : [AI_TOOLS[selectedTool]];
146+
147+
return async (host) => {
148+
const workspace = await readWorkspace(host);
149+
const project = workspace.projects.get(options.project);
150+
if (!project) {
151+
throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`);
152+
}
153+
154+
const rules = files.map(({ rulesName, directory, frontmatter }) =>
155+
mergeWith(
156+
apply(url('./files'), [
157+
// Keep only the single source template
158+
filter((p) => p.endsWith('__rulesName__.template')),
159+
applyTemplates({
160+
...strings,
161+
rulesName,
162+
frontmatter: frontmatter ?? '',
163+
}),
164+
move(directory),
165+
]),
166+
),
167+
);
168+
169+
return chain(rules);
170+
};
171+
}

packages/schematics/angular/config/index_spec.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
1010
import { Schema as ApplicationOptions } from '../application/schema';
1111
import { Schema as WorkspaceOptions } from '../workspace/schema';
12-
import { Schema as ConfigOptions, Type as ConfigType } from './schema';
12+
import { Schema as ConfigOptions, Tool as ConfigTool, Type as ConfigType } from './schema';
1313

1414
describe('Config Schematic', () => {
1515
const schematicRunner = new SchematicTestRunner(
@@ -32,12 +32,15 @@ describe('Config Schematic', () => {
3232
};
3333

3434
let applicationTree: UnitTestTree;
35-
function runConfigSchematic(type: ConfigType): Promise<UnitTestTree> {
35+
function runConfigSchematic(type: ConfigType, tool?: ConfigTool): Promise<UnitTestTree>;
36+
function runConfigSchematic(type: ConfigType.Ai, tool: ConfigTool): Promise<UnitTestTree>;
37+
function runConfigSchematic(type: ConfigType, tool?: ConfigTool): Promise<UnitTestTree> {
3638
return schematicRunner.runSchematic<ConfigOptions>(
3739
'config',
3840
{
3941
project: 'foo',
4042
type,
43+
tool,
4144
},
4245
applicationTree,
4346
);
@@ -97,4 +100,38 @@ describe('Config Schematic', () => {
97100
expect(tree.readContent('projects/foo/.browserslistrc')).toContain('Chrome >=');
98101
});
99102
});
103+
104+
describe(`when 'type' is 'ai'`, () => {
105+
it('should create a GEMINI.MD file', async () => {
106+
const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Gemini);
107+
expect(tree.readContent('.gemini/GEMINI.md')).toMatch(/^You are an expert in TypeScript/);
108+
});
109+
110+
it('should create a copilot-instructions.md file', async () => {
111+
const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Copilot);
112+
expect(tree.readContent('.github/copilot-instructions.md')).toContain(
113+
'You are an expert in TypeScript',
114+
);
115+
});
116+
117+
it('should create a cursor file', async () => {
118+
const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Cursor);
119+
const cursorFile = tree.readContent('.cursor/rules/cursor.mdc');
120+
expect(cursorFile).toContain('You are an expert in TypeScript');
121+
expect(cursorFile).toContain('context: true');
122+
expect(cursorFile).toContain('---\n\nYou are an expert in TypeScript');
123+
});
124+
125+
it('should create a windsurf file', async () => {
126+
const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Windsurf);
127+
expect(tree.readContent('.windsurf/rules/guidelines.md')).toContain(
128+
'You are an expert in TypeScript',
129+
);
130+
});
131+
132+
it('should create a claude file', async () => {
133+
const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Claude);
134+
expect(tree.readContent('.claude/CLAUDE.md')).toContain('You are an expert in TypeScript');
135+
});
136+
});
100137
});

packages/schematics/angular/config/schema.json

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,36 @@
1616
"type": {
1717
"type": "string",
1818
"description": "Specifies the type of configuration file to generate.",
19-
"enum": ["karma", "browserslist"],
19+
"enum": ["karma", "browserslist", "ai"],
2020
"x-prompt": "Which type of configuration file would you like to create?",
2121
"$default": {
2222
"$source": "argv",
2323
"index": 0
2424
}
25+
},
26+
"tool": {
27+
"type": "string",
28+
"description": "Specifies the AI tool to configure when type is 'ai'.",
29+
"enum": ["gemini", "copilot", "claude", "cursor", "windsurf", "all", "none"]
2530
}
2631
},
27-
"required": ["project", "type"]
32+
"required": ["project", "type"],
33+
"allOf": [
34+
{
35+
"if": {
36+
"properties": {
37+
"type": {
38+
"not": {
39+
"const": "ai"
40+
}
41+
}
42+
}
43+
},
44+
"then": {
45+
"not": {
46+
"required": ["tool"]
47+
}
48+
}
49+
}
50+
]
2851
}

packages/schematics/angular/ng-new/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
RepositoryInitializerTask,
2424
} from '@angular-devkit/schematics/tasks';
2525
import { Schema as ApplicationOptions } from '../application/schema';
26+
import { Tool as AiTool, Schema as ConfigOptions, Type as ConfigType } from '../config/schema';
2627
import { Schema as WorkspaceOptions } from '../workspace/schema';
2728
import { Schema as NgNewOptions } from './schema';
2829

@@ -60,11 +61,18 @@ export default function (options: NgNewOptions): Rule {
6061
zoneless: options.zoneless,
6162
};
6263

64+
const configOptions: ConfigOptions = {
65+
project: options.name,
66+
type: ConfigType.Ai,
67+
tool: options.aiConfig as unknown as AiTool,
68+
};
69+
6370
return chain([
6471
mergeWith(
6572
apply(empty(), [
6673
schematic('workspace', workspaceOptions),
6774
options.createApplication ? schematic('application', applicationOptions) : noop,
75+
options.aiConfig !== 'none' ? schematic('config', configOptions) : noop,
6876
move(options.directory),
6977
]),
7078
),

packages/schematics/angular/ng-new/index_spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,13 @@ describe('Ng New Schematic', () => {
103103
const { cli } = JSON.parse(tree.readContent('/bar/angular.json'));
104104
expect(cli.packageManager).toBe('npm');
105105
});
106+
107+
it('should add ai config file when aiConfig is set', async () => {
108+
const options = { ...defaultOptions, aiConfig: 'gemini' };
109+
110+
const tree = await schematicRunner.runSchematic('ng-new', options);
111+
const files = tree.files;
112+
expect(files).toContain('/bar/.gemini/GEMINI.md');
113+
expect(tree.readContent('/bar/.gemini/GEMINI.md')).toMatch(/^You are an expert in TypeScript/);
114+
});
106115
});

packages/schematics/angular/ng-new/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@
144144
"x-prompt": "Do you want to create a 'zoneless' application without zone.js (Developer Preview)?",
145145
"type": "boolean",
146146
"default": false
147+
},
148+
"aiConfig": {
149+
"description": "Create an AI configuration file for the project. This file is used to improve the outputs of AI tools by following the best practices.",
150+
"default": "none",
151+
"type": "string",
152+
"enum": ["gemini", "copilot", "claude", "cursor", "windsurf", "all", "none"]
147153
}
148154
},
149155
"required": ["name", "version"]

0 commit comments

Comments
 (0)