From 52cd22ff1361f3aff04387cc3649645ed404041b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:35:02 +0000 Subject: [PATCH 01/15] Initial plan From 2c1847599eb3cdbc71dfc3751a8b21a5437652dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:42:29 +0000 Subject: [PATCH 02/15] feat: make ai-config agent-agnostic with --agent and --skills-dir options - Add AIAgentTarget type and AI_AGENT_SKILLS_DIRS mapping for claude, copilot, cursor, codex, windsurf, gemini, junie, and generic agents - Add getSkillsDir() utility to resolve agent name to skills directory - Accept optional skillsDir parameter in copyAISkillsToProject() - Add --agent (-a) and --skills-dir (-d) CLI options to ig ai-config - Default to .claude/skills for backward compatibility when no option given - Add 15 new unit tests for agent-aware destinations and getSkillsDir Agent-Logs-Url: https://github.com/IgniteUI/igniteui-cli/sessions/6ffa80e2-ee1c-4982-8606-c428fb045137 Co-authored-by: kdinev <1472513+kdinev@users.noreply.github.com> --- packages/cli/lib/commands/ai-config.ts | 33 +++-- packages/core/util/ai-skills.ts | 39 ++++- spec/unit/ai-skills-spec.ts | 190 ++++++++++++++++++++++++- 3 files changed, 245 insertions(+), 17 deletions(-) diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 8ce72b28a..d8f97c264 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -1,4 +1,4 @@ -import { addMcpServers, copyAISkillsToProject, GoogleAnalytics, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; +import { addMcpServers, AI_AGENT_SKILLS_DIRS, AIAgentTarget, copyAISkillsToProject, getSkillsDir, GoogleAnalytics, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; import { ArgumentsCamelCase, CommandModule } from "yargs"; export function configureMCP(): void { @@ -11,8 +11,8 @@ export function configureMCP(): void { Util.log(Util.greenCheck() + ` MCP servers configured in ${VS_CODE_MCP_PATH}`); } -export function configureSkills(): void { - const result = copyAISkillsToProject(); +export function configureSkills(skillsDir?: string): void { + const result = copyAISkillsToProject(skillsDir); if (result.found === 0) { Util.warn("No AI skill files found. Make sure packages are installed (npm install) " + "and your Ignite UI packages are up-to-date.", "yellow"); @@ -26,18 +26,32 @@ export function configureSkills(): void { } } -export function configure(skills = true): void { +export function configure(skills = true, skillsDir?: string): void { configureMCP(); if (skills) { - configureSkills(); + configureSkills(skillsDir); } } +const AI_AGENT_CHOICES = Object.keys(AI_AGENT_SKILLS_DIRS) as AIAgentTarget[]; + const command: CommandModule = { command: "ai-config", describe: "Configure Ignite UI AI tooling (MCP servers and AI coding skills)", - builder: (yargs) => yargs.usage(""), - async handler(_argv: ArgumentsCamelCase) { + builder: (yargs) => yargs + .usage("") + .option("agent", { + alias: "a", + describe: "AI agent to configure skills for (determines the target skills directory)", + choices: AI_AGENT_CHOICES, + type: "string" + }) + .option("skills-dir", { + alias: "d", + describe: "Custom skills directory path (overrides --agent)", + type: "string" + }), + async handler(argv: ArgumentsCamelCase) { GoogleAnalytics.post({ t: "screenview", cd: "MCP" @@ -46,10 +60,11 @@ const command: CommandModule = { GoogleAnalytics.post({ t: "event", ec: "$ig ai-config", - ea: "client: vscode" + ea: `client: vscode, agent: ${argv.agent ?? "default"}` }); - configure(); + const skillsDir = (argv.skillsDir as string) ?? (argv.agent ? getSkillsDir(argv.agent as AIAgentTarget) : undefined); + configure(true, skillsDir); } }; diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 70625f4f8..ff4296400 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -9,8 +9,29 @@ import { TEMPLATE_MANAGER } from "./GlobalConstants"; import { ProjectConfig } from "./ProjectConfig"; import { Util } from "./Util"; -const CLAUDE_SKILLS_DIR = ".claude/skills"; -const CLAUDE_SKILLS_DIR_TEMPLATE = "__dot__claude/skills"; +const DEFAULT_SKILLS_DIR = ".claude/skills"; +const SKILLS_DIR_TEMPLATE = "__dot__claude/skills"; + +export type AIAgentTarget = "claude" | "copilot" | "cursor" | "codex" | "windsurf" | "gemini" | "junie" | "generic"; + +export const AI_AGENT_SKILLS_DIRS: Record = { + claude: ".claude/skills", + copilot: ".github/skills", + cursor: ".cursor/skills", + codex: ".codex/skills", + windsurf: ".windsurf/skills", + gemini: ".gemini/skills", + junie: ".junie/skills", + generic: ".agents/skills" +}; + +/** + * Returns the project-level skills directory for the given AI agent target. + * Falls back to `.claude/skills` when no target is specified. + */ +export function getSkillsDir(target?: AIAgentTarget): string { + return AI_AGENT_SKILLS_DIRS[target ?? "claude"]; +} export interface AISkillsCopyResult { found: number; @@ -63,7 +84,7 @@ function resolveSkillsRoots(): string[] { const filePaths = projectLib?.getProject(projectLib.projectIds[0]).templatePaths ?? []; roots.push( ...filePaths - .map((p) => path.join(p, CLAUDE_SKILLS_DIR_TEMPLATE)) + .map((p) => path.join(p, SKILLS_DIR_TEMPLATE)) .slice(0, 1), ); } @@ -73,9 +94,13 @@ function resolveSkillsRoots(): string[] { } /** - * Copies skill files from the installed Ignite UI package(s) into .claude/skills/. + * Copies skill files from the installed Ignite UI package(s) into the + * specified skills directory. When no directory is given, defaults to + * `.claude/skills/` for backward compatibility. + * @param skillsDir – destination directory (e.g. `.agents/skills`, `.cursor/skills`, …) */ -export function copyAISkillsToProject(): AISkillsCopyResult { +export function copyAISkillsToProject(skillsDir?: string): AISkillsCopyResult { + const outputDir = skillsDir ?? DEFAULT_SKILLS_DIR; const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0 }; // Source reads (glob + readFile) always use physical FS - skill files can // come from sources outside the project virtual tree (external/global package): @@ -102,8 +127,8 @@ export function copyAISkillsToProject(): AISkillsCopyResult { const normRoot = skillsRoot.replace(/\\/g, "/").replace(/^\//, ""); const rel = path.posix.relative(normRoot, normP); const dest = multiRoot - ? `${CLAUDE_SKILLS_DIR}/${pkgDirName}/${rel}` - : `${CLAUDE_SKILLS_DIR}/${rel}`; + ? `${outputDir}/${pkgDirName}/${rel}` + : `${outputDir}/${rel}`; const newContent = srcFs.readFile(p); try { diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index 2d8fb0a89..5c3fb3e03 100644 --- a/spec/unit/ai-skills-spec.ts +++ b/spec/unit/ai-skills-spec.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { App, Config, copyAISkillsToProject, FS_TOKEN, FsFileSystem, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { AI_AGENT_SKILLS_DIRS, AIAgentTarget, App, Config, copyAISkillsToProject, FS_TOKEN, FsFileSystem, getSkillsDir, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; function skillsDir(pkgName: string) { return `node_modules/${pkgName}/skills`; @@ -737,4 +737,192 @@ describe("Unit - copyAISkillsToProject", () => { expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); }); }); + + describe("Agent-aware destination", () => { + it("should copy skills to .cursor/skills/ when skillsDir targets cursor", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const content = "# Angular skills"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + copyAISkillsToProject(getSkillsDir("cursor")); + + expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); + }); + + it("should copy skills to .agents/skills/ when skillsDir targets generic", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const content = "# Angular skills"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + copyAISkillsToProject(getSkillsDir("generic")); + + expect(destFs.writeFile).toHaveBeenCalledWith(".agents/skills/angular.md", content); + }); + + it("should copy skills to .github/skills/ when skillsDir targets copilot", () => { + const reactPkg = "igniteui-react"; + const dir = skillsDir(reactPkg); + const file = skillFile(reactPkg, "overview.md"); + const content = "# React overview"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === dir ? [file] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "react" } + } as unknown as Config); + + copyAISkillsToProject(getSkillsDir("copilot")); + + expect(destFs.writeFile).toHaveBeenCalledWith(".github/skills/overview.md", content); + }); + + it("should support a custom skills directory path", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const content = "# Angular skills"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + copyAISkillsToProject("my-custom/ai-skills"); + + expect(destFs.writeFile).toHaveBeenCalledWith("my-custom/ai-skills/angular.md", content); + }); + + it("should default to .claude/skills/ when no skillsDir is provided", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const content = "# Angular skills"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + copyAISkillsToProject(); + + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); + }); + }); +}); + +describe("Unit - getSkillsDir", () => { + it("should return .claude/skills for 'claude'", () => { + expect(getSkillsDir("claude")).toBe(".claude/skills"); + }); + + it("should return .github/skills for 'copilot'", () => { + expect(getSkillsDir("copilot")).toBe(".github/skills"); + }); + + it("should return .cursor/skills for 'cursor'", () => { + expect(getSkillsDir("cursor")).toBe(".cursor/skills"); + }); + + it("should return .codex/skills for 'codex'", () => { + expect(getSkillsDir("codex")).toBe(".codex/skills"); + }); + + it("should return .windsurf/skills for 'windsurf'", () => { + expect(getSkillsDir("windsurf")).toBe(".windsurf/skills"); + }); + + it("should return .gemini/skills for 'gemini'", () => { + expect(getSkillsDir("gemini")).toBe(".gemini/skills"); + }); + + it("should return .junie/skills for 'junie'", () => { + expect(getSkillsDir("junie")).toBe(".junie/skills"); + }); + + it("should return .agents/skills for 'generic'", () => { + expect(getSkillsDir("generic")).toBe(".agents/skills"); + }); + + it("should default to .claude/skills when no target is specified", () => { + expect(getSkillsDir()).toBe(".claude/skills"); + }); +}); + +describe("Unit - AI_AGENT_SKILLS_DIRS", () => { + it("should contain entries for all expected agents", () => { + const expected: AIAgentTarget[] = ["claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"]; + expect(Object.keys(AI_AGENT_SKILLS_DIRS).sort()).toEqual(expected.sort()); + }); }); From c67709e1e062132b21d2f7c9bbd535c22a873e9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:45:17 +0000 Subject: [PATCH 03/15] refactor: improve analytics event and clean up skillsDir resolution Agent-Logs-Url: https://github.com/IgniteUI/igniteui-cli/sessions/6ffa80e2-ee1c-4982-8606-c428fb045137 Co-authored-by: kdinev <1472513+kdinev@users.noreply.github.com> --- packages/cli/lib/commands/ai-config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index d8f97c264..3400c40fd 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -57,13 +57,15 @@ const command: CommandModule = { cd: "MCP" }); + const agent = (argv.agent as string) ?? "default"; GoogleAnalytics.post({ t: "event", ec: "$ig ai-config", - ea: `client: vscode, agent: ${argv.agent ?? "default"}` + ea: `agent: ${agent}` }); - const skillsDir = (argv.skillsDir as string) ?? (argv.agent ? getSkillsDir(argv.agent as AIAgentTarget) : undefined); + const skillsDir = (argv.skillsDir as string | undefined) + ?? (argv.agent ? getSkillsDir(argv.agent as AIAgentTarget) : undefined); configure(true, skillsDir); } }; From d4478a9c357d6be44a6d8965e14e9c3211b6bdd6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:05:04 +0000 Subject: [PATCH 04/15] feat: update angular schematics ai-config to accept --agent and --skills-dir options - Add ai-config-schema.json with agent and skillsDir properties - Update collection.json to reference the schema - Update aiConfig() to accept and pass through skillsDir - Update addAIConfig() to resolve skillsDir from agent/skillsDir options - Add 6 new schematic tests verifying agent-aware skill copy Agent-Logs-Url: https://github.com/IgniteUI/igniteui-cli/sessions/235a5b72-55c7-4dda-a33d-47abfb77073e Co-authored-by: Marina-L-Stoyanova <6087266+Marina-L-Stoyanova@users.noreply.github.com> --- .../src/cli-config/ai-config-schema.json | 20 ++++++++++ .../ng-schematics/src/cli-config/index.ts | 11 +++--- .../src/cli-config/index_spec.ts | 39 +++++++++++++++++++ packages/ng-schematics/src/collection.json | 3 +- 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 packages/ng-schematics/src/cli-config/ai-config-schema.json diff --git a/packages/ng-schematics/src/cli-config/ai-config-schema.json b/packages/ng-schematics/src/cli-config/ai-config-schema.json new file mode 100644 index 000000000..22537b658 --- /dev/null +++ b/packages/ng-schematics/src/cli-config/ai-config-schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "IgniteUIAIConfigSchema", + "title": "AI Config Options Schema", + "type": "object", + "description": "Configures AI tooling: MCP servers and AI coding skills.", + "properties": { + "agent": { + "type": "string", + "description": "AI agent to configure skills for (determines the target skills directory).", + "alias": "a", + "enum": ["claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"] + }, + "skillsDir": { + "type": "string", + "description": "Custom skills directory path (overrides --agent).", + "alias": "d" + } + } +} diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 35bdb322b..aef71b423 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -1,7 +1,7 @@ import * as ts from "typescript"; import { DependencyNotFoundException } from "@angular-devkit/core"; import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics"; -import { addClassToBody, addMcpServers, App, copyAISkillsToProject, FormatSettings, McpServerEntry, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; +import { addClassToBody, addMcpServers, AIAgentTarget, App, copyAISkillsToProject, FormatSettings, getSkillsDir, McpServerEntry, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates"; import { createCliConfig } from "../utils/cli-config"; import { setVirtual } from "../utils/NgFileSystem"; @@ -126,12 +126,12 @@ function appInit(tree: Tree) { setVirtual(tree); } -function aiConfig({ init } = { init: true }): Rule { +function aiConfig({ init, skillsDir }: { init: boolean; skillsDir?: string } = { init: true }): Rule { return (tree: Tree) => { if (init) { appInit(tree); } - copyAISkillsToProject(); + copyAISkillsToProject(skillsDir); const angularCliServer: Record = { "angular-cli": { @@ -145,8 +145,9 @@ function aiConfig({ init } = { init: true }): Rule { } /** Standalone `ai-config` schematic entry */ -export function addAIConfig(): Rule { - return aiConfig(); +export function addAIConfig(options: { agent?: AIAgentTarget; skillsDir?: string } = {}): Rule { + const resolvedSkillsDir = options.skillsDir ?? (options.agent ? getSkillsDir(options.agent) : undefined); + return aiConfig({ init: true, skillsDir: resolvedSkillsDir }); } export default function (): Rule { diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index fc42a5bd9..044aff1b5 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -402,4 +402,43 @@ export const appConfig: ApplicationConfig = { expect(content.servers["igniteui-theming"]).toBeDefined(); }); }); + + describe("ai-config schematic", () => { + it("should call copyAISkillsToProject with no skillsDir by default", async () => { + await runner.runSchematic("ai-config", {}, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(undefined); + }); + + it("should pass resolved skillsDir when agent option is provided", async () => { + await runner.runSchematic("ai-config", { agent: "cursor" }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".cursor/skills"); + }); + + it("should pass resolved skillsDir for copilot agent", async () => { + await runner.runSchematic("ai-config", { agent: "copilot" }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".github/skills"); + }); + + it("should pass resolved skillsDir for generic agent", async () => { + await runner.runSchematic("ai-config", { agent: "generic" }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".agents/skills"); + }); + + it("should pass custom skillsDir when skillsDir option is provided", async () => { + await runner.runSchematic("ai-config", { skillsDir: "my-custom/skills" }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith("my-custom/skills"); + }); + + it("should prefer skillsDir over agent when both are provided", async () => { + await runner.runSchematic("ai-config", { agent: "cursor", skillsDir: "override/path" }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith("override/path"); + }); + }); }); diff --git a/packages/ng-schematics/src/collection.json b/packages/ng-schematics/src/collection.json index 804b2eaf5..6dfaebdaf 100644 --- a/packages/ng-schematics/src/collection.json +++ b/packages/ng-schematics/src/collection.json @@ -35,7 +35,8 @@ }, "ai-config": { "description": "Configures AI tooling: MCP servers and AI coding skills.", - "factory": "./cli-config/index#addAIConfig" + "factory": "./cli-config/index#addAIConfig", + "schema": "./cli-config/ai-config-schema.json" }, "upgrade-packages": { "description": "Upgrades to the licensed Ignite UI for Angular packages", From 5784c4ae85ba30b0597e0d2e245547ec01429ec3 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Mon, 27 Apr 2026 12:24:03 +0300 Subject: [PATCH 05/15] feat: enhance ai-config to prompt for agent selection and improve skillsDir handling Co-authored-by: Copilot --- packages/cli/lib/commands/ai-config.ts | 30 +++++-- packages/core/util/ai-skills.ts | 3 +- .../src/cli-config/ai-config-schema.json | 16 +++- spec/unit/ai-config-spec.ts | 30 ++++++- spec/unit/ai-skills-spec.ts | 86 +++++++++++++++++++ 5 files changed, 149 insertions(+), 16 deletions(-) diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 3400c40fd..5f066e0d9 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -1,4 +1,4 @@ -import { addMcpServers, AI_AGENT_SKILLS_DIRS, AIAgentTarget, copyAISkillsToProject, getSkillsDir, GoogleAnalytics, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; +import { addMcpServers, AI_AGENT_SKILLS_DIRS, AIAgentTarget, copyAISkillsToProject, getSkillsDir, GoogleAnalytics, InquirerWrapper, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; import { ArgumentsCamelCase, CommandModule } from "yargs"; export function configureMCP(): void { @@ -57,15 +57,27 @@ const command: CommandModule = { cd: "MCP" }); - const agent = (argv.agent as string) ?? "default"; - GoogleAnalytics.post({ - t: "event", - ec: "$ig ai-config", - ea: `agent: ${agent}` - }); + let skillsDir = argv.skillsDir as string | undefined; + + if (!skillsDir) { + let agent = argv.agent as AIAgentTarget | undefined; + + if (!agent) { + agent = await InquirerWrapper.select({ + message: "Which AI agent are you using?", + choices: AI_AGENT_CHOICES + }) as AIAgentTarget; + } + + GoogleAnalytics.post({ + t: "event", + ec: "$ig ai-config", + ea: `agent: ${agent}` + }); + + skillsDir = getSkillsDir(agent); + } - const skillsDir = (argv.skillsDir as string | undefined) - ?? (argv.agent ? getSkillsDir(argv.agent as AIAgentTarget) : undefined); configure(true, skillsDir); } }; diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index ff4296400..0d8539c6c 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -9,7 +9,6 @@ import { TEMPLATE_MANAGER } from "./GlobalConstants"; import { ProjectConfig } from "./ProjectConfig"; import { Util } from "./Util"; -const DEFAULT_SKILLS_DIR = ".claude/skills"; const SKILLS_DIR_TEMPLATE = "__dot__claude/skills"; export type AIAgentTarget = "claude" | "copilot" | "cursor" | "codex" | "windsurf" | "gemini" | "junie" | "generic"; @@ -100,7 +99,7 @@ function resolveSkillsRoots(): string[] { * @param skillsDir – destination directory (e.g. `.agents/skills`, `.cursor/skills`, …) */ export function copyAISkillsToProject(skillsDir?: string): AISkillsCopyResult { - const outputDir = skillsDir ?? DEFAULT_SKILLS_DIR; + const outputDir = (skillsDir ?? getSkillsDir()).replace(/\\/g, "/").replace(/\/+$/, ""); const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0 }; // Source reads (glob + readFile) always use physical FS - skill files can // come from sources outside the project virtual tree (external/global package): diff --git a/packages/ng-schematics/src/cli-config/ai-config-schema.json b/packages/ng-schematics/src/cli-config/ai-config-schema.json index 22537b658..019776081 100644 --- a/packages/ng-schematics/src/cli-config/ai-config-schema.json +++ b/packages/ng-schematics/src/cli-config/ai-config-schema.json @@ -9,7 +9,21 @@ "type": "string", "description": "AI agent to configure skills for (determines the target skills directory).", "alias": "a", - "enum": ["claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"] + "enum": ["claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"], + "x-prompt": { + "message": "Which AI agent are you using?", + "type": "list", + "items": [ + { "value": "claude", "label": "Claude" }, + { "value": "copilot", "label": "GitHub Copilot" }, + { "value": "cursor", "label": "Cursor" }, + { "value": "codex", "label": "Codex" }, + { "value": "windsurf", "label": "Windsurf" }, + { "value": "gemini", "label": "Gemini" }, + { "value": "junie", "label": "Junie" }, + { "value": "generic", "label": "Generic / Other" } + ] + } }, "skillsDir": { "type": "string", diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index bb2eb84f0..c8672fb6e 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { App, Config, FS_TOKEN, FsFileSystem, GoogleAnalytics, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { App, Config, FS_TOKEN, FsFileSystem, GoogleAnalytics, IFileSystem, InquirerWrapper, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; import { configureMCP, configureSkills } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; @@ -257,14 +257,36 @@ describe("Unit - ai-config command", () => { }); describe("handler", () => { - it("posts analytics and calls configure", async () => { + it("prompts for agent when neither --agent nor --skills-dir is provided", async () => { App.container.set(FS_TOKEN, createMockFs()); + spyOn(InquirerWrapper, "select").and.returnValue(Promise.resolve("claude")); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); - expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("MCP servers configured")); + expect(InquirerWrapper.select).toHaveBeenCalledWith(jasmine.objectContaining({ + message: "Which AI agent are you using?" + })); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "MCP" })); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ec: "$ig ai-config" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: claude" })); + }); + + it("skips prompt when --agent is provided", async () => { + App.container.set(FS_TOKEN, createMockFs()); + spyOn(InquirerWrapper, "select"); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agent: "cursor" }); + + expect(InquirerWrapper.select).not.toHaveBeenCalled(); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor" })); + }); + + it("skips prompt when --skills-dir is provided", async () => { + App.container.set(FS_TOKEN, createMockFs()); + spyOn(InquirerWrapper, "select"); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", skillsDir: "custom/path" }); + + expect(InquirerWrapper.select).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index 5c3fb3e03..377c1625e 100644 --- a/spec/unit/ai-skills-spec.ts +++ b/spec/unit/ai-skills-spec.ts @@ -824,6 +824,92 @@ describe("Unit - copyAISkillsToProject", () => { expect(destFs.writeFile).toHaveBeenCalledWith(".github/skills/overview.md", content); }); + it("should strip a trailing slash from the provided skillsDir", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const content = "# Angular skills"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + // caller passes a trailing slash — dest path must not contain // + copyAISkillsToProject(".cursor/skills/"); + + expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); + }); + + it("should normalise Windows-style backslashes in the provided skillsDir", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const content = "# Angular skills"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + // Windows-style separators must be converted to posix + copyAISkillsToProject(".cursor\\skills"); + + expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); + }); + + it("should normalise backslashes and trailing slash together", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const content = "# Angular skills"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + copyAISkillsToProject(".cursor\\skills\\"); + + expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); + }); + it("should support a custom skills directory path", () => { const angularSkillsDir = skillsDir("igniteui-angular"); const skillFilePath = skillFile("igniteui-angular", "angular.md"); From 39eff3dc23ab551978946ab1de1fbda3bf49ee43 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Mon, 27 Apr 2026 20:58:41 +0300 Subject: [PATCH 06/15] feat: enhance ai-config command to handle agent selection and improve Google Analytics event tracking Co-authored-by: Copilot --- packages/cli/lib/commands/ai-config.ts | 16 ++++++++-------- packages/core/util/ai-skills.ts | 9 ++++++--- spec/unit/ai-config-spec.ts | 5 +++-- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 5f066e0d9..812613ab1 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -58,10 +58,9 @@ const command: CommandModule = { }); let skillsDir = argv.skillsDir as string | undefined; + let agent = argv.agent as AIAgentTarget | undefined; if (!skillsDir) { - let agent = argv.agent as AIAgentTarget | undefined; - if (!agent) { agent = await InquirerWrapper.select({ message: "Which AI agent are you using?", @@ -69,15 +68,16 @@ const command: CommandModule = { }) as AIAgentTarget; } - GoogleAnalytics.post({ - t: "event", - ec: "$ig ai-config", - ea: `agent: ${agent}` - }); - skillsDir = getSkillsDir(agent); } + GoogleAnalytics.post({ + t: "event", + ec: "$ig ai-config", + ea: `agent: ${agent ?? "custom"}`, + el: argv.skillsDir ? "customSkillsDir" : undefined + }); + configure(true, skillsDir); } }; diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 0d8539c6c..a6ae4ed5e 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -9,7 +9,7 @@ import { TEMPLATE_MANAGER } from "./GlobalConstants"; import { ProjectConfig } from "./ProjectConfig"; import { Util } from "./Util"; -const SKILLS_DIR_TEMPLATE = "__dot__claude/skills"; +const FALLBACK_SKILLS_TEMPLATE_PATH = "__dot__claude/skills"; export type AIAgentTarget = "claude" | "copilot" | "cursor" | "codex" | "windsurf" | "gemini" | "junie" | "generic"; @@ -83,7 +83,7 @@ function resolveSkillsRoots(): string[] { const filePaths = projectLib?.getProject(projectLib.projectIds[0]).templatePaths ?? []; roots.push( ...filePaths - .map((p) => path.join(p, SKILLS_DIR_TEMPLATE)) + .map((p) => path.join(p, FALLBACK_SKILLS_TEMPLATE_PATH)) .slice(0, 1), ); } @@ -99,7 +99,10 @@ function resolveSkillsRoots(): string[] { * @param skillsDir – destination directory (e.g. `.agents/skills`, `.cursor/skills`, …) */ export function copyAISkillsToProject(skillsDir?: string): AISkillsCopyResult { - const outputDir = (skillsDir ?? getSkillsDir()).replace(/\\/g, "/").replace(/\/+$/, ""); + let outputDir = (skillsDir ?? getSkillsDir()).replace(/\\/g, "/"); + while (outputDir.endsWith("/")) { + outputDir = outputDir.slice(0, -1); + } const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0 }; // Source reads (glob + readFile) always use physical FS - skill files can // come from sources outside the project virtual tree (external/global package): diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index c8672fb6e..983d4b3fd 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -267,7 +267,7 @@ describe("Unit - ai-config command", () => { message: "Which AI agent are you using?" })); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "MCP" })); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: claude" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: claude", el: undefined })); }); it("skips prompt when --agent is provided", async () => { @@ -277,7 +277,7 @@ describe("Unit - ai-config command", () => { await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agent: "cursor" }); expect(InquirerWrapper.select).not.toHaveBeenCalled(); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor", el: undefined })); }); it("skips prompt when --skills-dir is provided", async () => { @@ -287,6 +287,7 @@ describe("Unit - ai-config command", () => { await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", skillsDir: "custom/path" }); expect(InquirerWrapper.select).not.toHaveBeenCalled(); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: custom", el: "customSkillsDir" })); }); }); }); From 6c5d71710c1a59a8b6c18460c91be176e8f12e85 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Tue, 28 Apr 2026 12:53:28 +0300 Subject: [PATCH 07/15] feat: make ai-config agent-agnostic by supporting multiple agents and updating skills directory handling Co-authored-by: Copilot --- packages/cli/lib/commands/ai-config.ts | 48 +++++++++++------- packages/core/util/ai-skills.ts | 12 ++--- .../src/cli-config/ai-config-schema.json | 13 +++-- .../ng-schematics/src/cli-config/index.ts | 16 +++--- .../src/cli-config/index_spec.ts | 20 +++++--- spec/unit/ai-config-spec.ts | 42 ++++++++++------ spec/unit/ai-skills-spec.ts | 50 +++++++++---------- 7 files changed, 116 insertions(+), 85 deletions(-) diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 812613ab1..34b2a4319 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -11,7 +11,7 @@ export function configureMCP(): void { Util.log(Util.greenCheck() + ` MCP servers configured in ${VS_CODE_MCP_PATH}`); } -export function configureSkills(skillsDir?: string): void { +export function configureSkills(skillsDir: string): void { const result = copyAISkillsToProject(skillsDir); if (result.found === 0) { Util.warn("No AI skill files found. Make sure packages are installed (npm install) " + @@ -26,10 +26,12 @@ export function configureSkills(skillsDir?: string): void { } } -export function configure(skills = true, skillsDir?: string): void { +export function configure(skills = true, skillsDirs?: string[]): void { configureMCP(); - if (skills) { - configureSkills(skillsDir); + if (skills && skillsDirs) { + for (const dir of skillsDirs) { + configureSkills(dir); + } } } @@ -42,9 +44,9 @@ const command: CommandModule = { .usage("") .option("agent", { alias: "a", - describe: "AI agent to configure skills for (determines the target skills directory)", + describe: "AI agent(s) to configure skills for (determines the target skills directory)", choices: AI_AGENT_CHOICES, - type: "string" + type: "array" }) .option("skills-dir", { alias: "d", @@ -57,28 +59,36 @@ const command: CommandModule = { cd: "MCP" }); - let skillsDir = argv.skillsDir as string | undefined; - let agent = argv.agent as AIAgentTarget | undefined; + const skillsDir = argv.skillsDir as string | undefined; + + if (skillsDir) { + GoogleAnalytics.post({ + t: "event", + ec: "$ig ai-config", + ea: "agent: custom", + el: "customSkillsDir" + }); + + configure(true, [skillsDir]); + return; + } - if (!skillsDir) { - if (!agent) { - agent = await InquirerWrapper.select({ - message: "Which AI agent are you using?", - choices: AI_AGENT_CHOICES - }) as AIAgentTarget; - } + let agents = argv.agent as AIAgentTarget[] | undefined; - skillsDir = getSkillsDir(agent); + if (!agents?.length) { + agents = await InquirerWrapper.checkbox({ + message: "Which AI agent(s) are you using?", + choices: AI_AGENT_CHOICES + }) as AIAgentTarget[]; } GoogleAnalytics.post({ t: "event", ec: "$ig ai-config", - ea: `agent: ${agent ?? "custom"}`, - el: argv.skillsDir ? "customSkillsDir" : undefined + ea: `agent: ${agents.join(", ")}` }); - configure(true, skillsDir); + configure(true, agents.map(a => getSkillsDir(a))); } }; diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index a6ae4ed5e..0935121ab 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -26,10 +26,9 @@ export const AI_AGENT_SKILLS_DIRS: Record = { /** * Returns the project-level skills directory for the given AI agent target. - * Falls back to `.claude/skills` when no target is specified. */ -export function getSkillsDir(target?: AIAgentTarget): string { - return AI_AGENT_SKILLS_DIRS[target ?? "claude"]; +export function getSkillsDir(target: AIAgentTarget): string { + return AI_AGENT_SKILLS_DIRS[target]; } export interface AISkillsCopyResult { @@ -94,12 +93,11 @@ function resolveSkillsRoots(): string[] { /** * Copies skill files from the installed Ignite UI package(s) into the - * specified skills directory. When no directory is given, defaults to - * `.claude/skills/` for backward compatibility. + * specified skills directory. * @param skillsDir – destination directory (e.g. `.agents/skills`, `.cursor/skills`, …) */ -export function copyAISkillsToProject(skillsDir?: string): AISkillsCopyResult { - let outputDir = (skillsDir ?? getSkillsDir()).replace(/\\/g, "/"); +export function copyAISkillsToProject(skillsDir: string): AISkillsCopyResult { + let outputDir = skillsDir.replace(/\\/g, "/"); while (outputDir.endsWith("/")) { outputDir = outputDir.slice(0, -1); } diff --git a/packages/ng-schematics/src/cli-config/ai-config-schema.json b/packages/ng-schematics/src/cli-config/ai-config-schema.json index 019776081..454b765b8 100644 --- a/packages/ng-schematics/src/cli-config/ai-config-schema.json +++ b/packages/ng-schematics/src/cli-config/ai-config-schema.json @@ -6,13 +6,17 @@ "description": "Configures AI tooling: MCP servers and AI coding skills.", "properties": { "agent": { - "type": "string", - "description": "AI agent to configure skills for (determines the target skills directory).", + "type": "array", + "description": "AI agent(s) to configure skills for (determines the target skills directories).", "alias": "a", - "enum": ["claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"], + "items": { + "type": "string", + "enum": ["claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"] + }, "x-prompt": { - "message": "Which AI agent are you using?", + "message": "Which AI agent(s) are you using?", "type": "list", + "multiselect": true, "items": [ { "value": "claude", "label": "Claude" }, { "value": "copilot", "label": "GitHub Copilot" }, @@ -26,7 +30,6 @@ } }, "skillsDir": { - "type": "string", "description": "Custom skills directory path (overrides --agent).", "alias": "d" } diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index aef71b423..05a714ddf 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -126,12 +126,14 @@ function appInit(tree: Tree) { setVirtual(tree); } -function aiConfig({ init, skillsDir }: { init: boolean; skillsDir?: string } = { init: true }): Rule { +function aiConfig({ init, skillsDirs }: { init: boolean; skillsDirs: string[] }): Rule { return (tree: Tree) => { if (init) { appInit(tree); } - copyAISkillsToProject(skillsDir); + for (const dir of skillsDirs) { + copyAISkillsToProject(dir); + } const angularCliServer: Record = { "angular-cli": { @@ -145,9 +147,11 @@ function aiConfig({ init, skillsDir }: { init: boolean; skillsDir?: string } = { } /** Standalone `ai-config` schematic entry */ -export function addAIConfig(options: { agent?: AIAgentTarget; skillsDir?: string } = {}): Rule { - const resolvedSkillsDir = options.skillsDir ?? (options.agent ? getSkillsDir(options.agent) : undefined); - return aiConfig({ init: true, skillsDir: resolvedSkillsDir }); +export function addAIConfig(options: { agent?: AIAgentTarget[]; skillsDir?: string } = {}): Rule { + const skillsDirs = options.skillsDir + ? [options.skillsDir] + : (options.agent?.length ? options.agent : ["claude"] as AIAgentTarget[]).map(a => getSkillsDir(a)); + return aiConfig({ init: true, skillsDirs }); } export default function (): Rule { @@ -159,7 +163,7 @@ export default function (): Rule { importBrowserAnimations(), createCliConfig(), displayVersionMismatch(), - aiConfig({ init: false }) + aiConfig({ init: false, skillsDirs: [getSkillsDir("claude")] }) ]); }; } diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index 044aff1b5..89188edff 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -404,31 +404,39 @@ export const appConfig: ApplicationConfig = { }); describe("ai-config schematic", () => { - it("should call copyAISkillsToProject with no skillsDir by default", async () => { + it("should call copyAISkillsToProject with claude default when no options", async () => { await runner.runSchematic("ai-config", {}, tree); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1); - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(undefined); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".claude/skills"); }); it("should pass resolved skillsDir when agent option is provided", async () => { - await runner.runSchematic("ai-config", { agent: "cursor" }, tree); + await runner.runSchematic("ai-config", { agent: ["cursor"] }, tree); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".cursor/skills"); }); it("should pass resolved skillsDir for copilot agent", async () => { - await runner.runSchematic("ai-config", { agent: "copilot" }, tree); + await runner.runSchematic("ai-config", { agent: ["copilot"] }, tree); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".github/skills"); }); it("should pass resolved skillsDir for generic agent", async () => { - await runner.runSchematic("ai-config", { agent: "generic" }, tree); + await runner.runSchematic("ai-config", { agent: ["generic"] }, tree); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".agents/skills"); }); + it("should configure multiple agents", async () => { + await runner.runSchematic("ai-config", { agent: ["claude", "cursor"] }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(2); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".claude/skills"); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".cursor/skills"); + }); + it("should pass custom skillsDir when skillsDir option is provided", async () => { await runner.runSchematic("ai-config", { skillsDir: "my-custom/skills" }, tree); @@ -436,7 +444,7 @@ export const appConfig: ApplicationConfig = { }); it("should prefer skillsDir over agent when both are provided", async () => { - await runner.runSchematic("ai-config", { agent: "cursor", skillsDir: "override/path" }, tree); + await runner.runSchematic("ai-config", { agent: ["cursor"], skillsDir: "override/path" }, tree); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith("override/path"); }); diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 983d4b3fd..ad6d2e5d0 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -157,7 +157,7 @@ describe("Unit - ai-config command", () => { }) setupAngularConfig(); - configureSkills(); + configureSkills(".claude/skills"); expect(Util.warn).toHaveBeenCalledWith(jasmine.stringContaining("No AI skill files found"), "yellow"); expect(Util.log).not.toHaveBeenCalled(); @@ -187,7 +187,7 @@ describe("Unit - ai-config command", () => { spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); setupAngularConfig(); - configureSkills(); + configureSkills(".claude/skills"); expect(Util.warn).toHaveBeenCalledWith(jasmine.stringContaining("Failed to write 1 skill file(s) out of 1"), "yellow"); expect(Util.log).not.toHaveBeenCalled(); @@ -219,7 +219,7 @@ describe("Unit - ai-config command", () => { spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content); setupAngularConfig(); - configureSkills(); + configureSkills(".claude/skills"); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("already up-to-date")); expect(Util.warn).not.toHaveBeenCalled(); @@ -249,7 +249,7 @@ describe("Unit - ai-config command", () => { spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); setupAngularConfig(); - configureSkills(); + configureSkills(".claude/skills"); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("1 AI skill file(s) created or updated")); expect(Util.warn).not.toHaveBeenCalled(); @@ -257,36 +257,48 @@ describe("Unit - ai-config command", () => { }); describe("handler", () => { - it("prompts for agent when neither --agent nor --skills-dir is provided", async () => { + it("prompts for agents when neither --agent nor --skills-dir is provided", async () => { App.container.set(FS_TOKEN, createMockFs()); - spyOn(InquirerWrapper, "select").and.returnValue(Promise.resolve("claude")); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude"])); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); - expect(InquirerWrapper.select).toHaveBeenCalledWith(jasmine.objectContaining({ - message: "Which AI agent are you using?" + expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ + message: "Which AI agent(s) are you using?" })); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "MCP" })); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: claude", el: undefined })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: claude" })); + }); + + it("configures multiple agents when selected interactively", async () => { + App.container.set(FS_TOKEN, createMockFs()); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude", "cursor"])); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ + message: "Which AI agent(s) are you using?" + })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: claude, cursor" })); }); it("skips prompt when --agent is provided", async () => { App.container.set(FS_TOKEN, createMockFs()); - spyOn(InquirerWrapper, "select"); + spyOn(InquirerWrapper, "checkbox"); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agent: "cursor" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agent: ["cursor"] }); - expect(InquirerWrapper.select).not.toHaveBeenCalled(); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor", el: undefined })); + expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor" })); }); it("skips prompt when --skills-dir is provided", async () => { App.container.set(FS_TOKEN, createMockFs()); - spyOn(InquirerWrapper, "select"); + spyOn(InquirerWrapper, "checkbox"); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", skillsDir: "custom/path" }); - expect(InquirerWrapper.select).not.toHaveBeenCalled(); + expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: custom", el: "customSkillsDir" })); }); }); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index 377c1625e..147a04b2c 100644 --- a/spec/unit/ai-skills-spec.ts +++ b/spec/unit/ai-skills-spec.ts @@ -89,7 +89,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); }); @@ -125,7 +125,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); }); @@ -155,7 +155,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", newContent); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("Updated .claude/skills/angular.md")); @@ -188,7 +188,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - const result = copyAISkillsToProject(); + const result = copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).not.toHaveBeenCalled(); expect(result.found).toBe(1); @@ -226,7 +226,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "react" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", content); }); @@ -260,7 +260,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "webcomponents" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", content); }); @@ -287,7 +287,7 @@ describe("Unit - copyAISkillsToProject", () => { App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); }); @@ -312,7 +312,7 @@ describe("Unit - copyAISkillsToProject", () => { App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", "react skill content"); }); @@ -337,7 +337,7 @@ describe("Unit - copyAISkillsToProject", () => { App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", "wc skill content"); }); @@ -359,7 +359,7 @@ describe("Unit - copyAISkillsToProject", () => { } as unknown as Config); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - const result = copyAISkillsToProject(); + const result = copyAISkillsToProject(".claude/skills"); expect(result.found).toBe(0); expect(destFs.writeFile).not.toHaveBeenCalled(); @@ -384,7 +384,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).not.toHaveBeenCalled(); }); @@ -415,7 +415,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - const result = copyAISkillsToProject(); + const result = copyAISkillsToProject(".claude/skills"); expect(result.found).toBe(1); expect(result.skipped).toBe(0); @@ -451,7 +451,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - const result = copyAISkillsToProject(); + const result = copyAISkillsToProject(".claude/skills"); expect(result.found).toBe(1); expect(result.skipped).toBe(0); @@ -489,7 +489,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - const result = copyAISkillsToProject(); + const result = copyAISkillsToProject(".claude/skills"); expect(result.found).toBe(2); expect(result.skipped).toBe(0); @@ -524,7 +524,7 @@ describe("Unit - copyAISkillsToProject", () => { } as unknown as Config); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); @@ -555,7 +555,7 @@ describe("Unit - copyAISkillsToProject", () => { spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("react"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/react.md", content); @@ -586,7 +586,7 @@ describe("Unit - copyAISkillsToProject", () => { spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("webcomponents"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", content); @@ -601,7 +601,7 @@ describe("Unit - copyAISkillsToProject", () => { App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - const result = copyAISkillsToProject(); + const result = copyAISkillsToProject(".claude/skills"); expect(result.found).toBe(0); expect(result.skipped).toBe(0); @@ -631,7 +631,7 @@ describe("Unit - copyAISkillsToProject", () => { } as unknown as Config); mockTemplateManager([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); }); @@ -663,7 +663,7 @@ describe("Unit - copyAISkillsToProject", () => { } as unknown as Config); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("react"); expect(mockTm.getFrameworkById).not.toHaveBeenCalledWith("angular"); @@ -695,7 +695,7 @@ describe("Unit - copyAISkillsToProject", () => { } as unknown as Config); mockTemplateManager([ABS_TEMPLATE_PATH]); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); // Source reads go to real FsFileSystem (srcFs) expect(srcSpies.glob).toHaveBeenCalledWith(SKILLS_ROOT, "**/*"); @@ -732,7 +732,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); }); @@ -961,7 +961,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); }); @@ -1000,10 +1000,6 @@ describe("Unit - getSkillsDir", () => { it("should return .agents/skills for 'generic'", () => { expect(getSkillsDir("generic")).toBe(".agents/skills"); }); - - it("should default to .claude/skills when no target is specified", () => { - expect(getSkillsDir()).toBe(".claude/skills"); - }); }); describe("Unit - AI_AGENT_SKILLS_DIRS", () => { From 14e062df9b2da3b23bc2400204045e7bf8e5071d Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Tue, 28 Apr 2026 13:03:50 +0300 Subject: [PATCH 08/15] feat: refactor ai-config to support agent-based skills configuration and remove skillsDir option Co-authored-by: Copilot --- packages/cli/lib/PromptSession.ts | 4 +-- packages/cli/lib/commands/ai-config.ts | 29 +++---------------- .../src/cli-config/ai-config-schema.json | 4 --- .../ng-schematics/src/cli-config/index.ts | 16 +++++----- .../src/cli-config/index_spec.ts | 12 -------- spec/unit/PromptSession-spec.ts | 14 ++++----- spec/unit/ai-config-spec.ts | 12 +------- 7 files changed, 21 insertions(+), 70 deletions(-) diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index 076b23504..ebf38a20c 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -4,7 +4,7 @@ import { } from "@igniteui/cli-core"; import * as path from "path"; import { default as add } from "./commands/add"; -import { configure as aiConfigure } from "./commands/ai-config"; +import { configureMCP } from "./commands/ai-config"; import { default as start } from "./commands/start"; import { default as upgrade } from "./commands/upgrade"; import { TemplateManager } from "./TemplateManager"; @@ -106,7 +106,7 @@ export class PromptSession extends BasePromptSession { protected async configureAI(): Promise { // skip adding skills since those are baked into the project template atm: - aiConfigure(false); + configureMCP(); } /** diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 34b2a4319..3f8f46bee 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -26,12 +26,10 @@ export function configureSkills(skillsDir: string): void { } } -export function configure(skills = true, skillsDirs?: string[]): void { +export function configure(agents: AIAgentTarget[]): void { configureMCP(); - if (skills && skillsDirs) { - for (const dir of skillsDirs) { - configureSkills(dir); - } + for (const agent of agents) { + configureSkills(getSkillsDir(agent)); } } @@ -47,11 +45,6 @@ const command: CommandModule = { describe: "AI agent(s) to configure skills for (determines the target skills directory)", choices: AI_AGENT_CHOICES, type: "array" - }) - .option("skills-dir", { - alias: "d", - describe: "Custom skills directory path (overrides --agent)", - type: "string" }), async handler(argv: ArgumentsCamelCase) { GoogleAnalytics.post({ @@ -59,20 +52,6 @@ const command: CommandModule = { cd: "MCP" }); - const skillsDir = argv.skillsDir as string | undefined; - - if (skillsDir) { - GoogleAnalytics.post({ - t: "event", - ec: "$ig ai-config", - ea: "agent: custom", - el: "customSkillsDir" - }); - - configure(true, [skillsDir]); - return; - } - let agents = argv.agent as AIAgentTarget[] | undefined; if (!agents?.length) { @@ -88,7 +67,7 @@ const command: CommandModule = { ea: `agent: ${agents.join(", ")}` }); - configure(true, agents.map(a => getSkillsDir(a))); + configure(agents); } }; diff --git a/packages/ng-schematics/src/cli-config/ai-config-schema.json b/packages/ng-schematics/src/cli-config/ai-config-schema.json index 454b765b8..c415641b2 100644 --- a/packages/ng-schematics/src/cli-config/ai-config-schema.json +++ b/packages/ng-schematics/src/cli-config/ai-config-schema.json @@ -28,10 +28,6 @@ { "value": "generic", "label": "Generic / Other" } ] } - }, - "skillsDir": { - "description": "Custom skills directory path (overrides --agent).", - "alias": "d" } } } diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 05a714ddf..afb40649e 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -126,13 +126,13 @@ function appInit(tree: Tree) { setVirtual(tree); } -function aiConfig({ init, skillsDirs }: { init: boolean; skillsDirs: string[] }): Rule { +function aiConfig({ init, agents }: { init: boolean; agents: AIAgentTarget[] }): Rule { return (tree: Tree) => { if (init) { appInit(tree); } - for (const dir of skillsDirs) { - copyAISkillsToProject(dir); + for (const agent of agents) { + copyAISkillsToProject(getSkillsDir(agent)); } const angularCliServer: Record = { @@ -147,11 +147,9 @@ function aiConfig({ init, skillsDirs }: { init: boolean; skillsDirs: string[] }) } /** Standalone `ai-config` schematic entry */ -export function addAIConfig(options: { agent?: AIAgentTarget[]; skillsDir?: string } = {}): Rule { - const skillsDirs = options.skillsDir - ? [options.skillsDir] - : (options.agent?.length ? options.agent : ["claude"] as AIAgentTarget[]).map(a => getSkillsDir(a)); - return aiConfig({ init: true, skillsDirs }); +export function addAIConfig(options: { agent?: AIAgentTarget[] } = {}): Rule { + const agents = options.agent?.length ? options.agent : ["claude"] as AIAgentTarget[]; + return aiConfig({ init: true, agents }); } export default function (): Rule { @@ -163,7 +161,7 @@ export default function (): Rule { importBrowserAnimations(), createCliConfig(), displayVersionMismatch(), - aiConfig({ init: false, skillsDirs: [getSkillsDir("claude")] }) + aiConfig({ init: false, agents: ["claude"] }) ]); }; } diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index 89188edff..0ea6b0d33 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -436,17 +436,5 @@ export const appConfig: ApplicationConfig = { expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".claude/skills"); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".cursor/skills"); }); - - it("should pass custom skillsDir when skillsDir option is provided", async () => { - await runner.runSchematic("ai-config", { skillsDir: "my-custom/skills" }, tree); - - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith("my-custom/skills"); - }); - - it("should prefer skillsDir over agent when both are provided", async () => { - await runner.runSchematic("ai-config", { agent: ["cursor"], skillsDir: "override/path" }, tree); - - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith("override/path"); - }); }); }); diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index b3d0fcabf..52e21d41b 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -102,7 +102,7 @@ describe("Unit - PromptSession", () => { }); beforeEach(() => { - spyOn(aiConfig, "configure"); + spyOn(aiConfig, "configureMCP"); }); // TODO: most of the tests use same setup - move the setup to beforeAll call @@ -501,8 +501,8 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledTimes(3); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); - expect(aiConfig.configure).toHaveBeenCalledTimes(1); - expect(aiConfig.configure).toHaveBeenCalledWith(false); + expect(aiConfig.configureMCP).toHaveBeenCalledTimes(1); + expect(add.addTemplate).toHaveBeenCalledTimes(1); expect(InquirerWrapper.input).toHaveBeenCalledWith({ type: "input", @@ -580,8 +580,8 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledTimes(3); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); - expect(aiConfig.configure).toHaveBeenCalledTimes(1); - expect(aiConfig.configure).toHaveBeenCalledWith(false); + expect(aiConfig.configureMCP).toHaveBeenCalledTimes(1); + expect(Util.getAvailableName).toHaveBeenCalledTimes(1); expect(add.addTemplate).toHaveBeenCalledTimes(1); expect(add.addTemplate).toHaveBeenCalledWith("Custom Template Name", mockSelectedTemplate); @@ -706,8 +706,8 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledTimes(3); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); - expect(aiConfig.configure).toHaveBeenCalledTimes(1); - expect(aiConfig.configure).toHaveBeenCalledWith(false); + expect(aiConfig.configureMCP).toHaveBeenCalledTimes(1); + expect(add.addTemplate).toHaveBeenCalledTimes(1); expect(InquirerWrapper.checkbox).toHaveBeenCalledWith({ type: "checkbox", diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index ad6d2e5d0..1b08de3b1 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -257,7 +257,7 @@ describe("Unit - ai-config command", () => { }); describe("handler", () => { - it("prompts for agents when neither --agent nor --skills-dir is provided", async () => { + it("prompts for agents when --agent is not provided", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude"])); @@ -291,15 +291,5 @@ describe("Unit - ai-config command", () => { expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor" })); }); - - it("skips prompt when --skills-dir is provided", async () => { - App.container.set(FS_TOKEN, createMockFs()); - spyOn(InquirerWrapper, "checkbox"); - - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", skillsDir: "custom/path" }); - - expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: custom", el: "customSkillsDir" })); - }); }); }); From 366921176b4a9c15360d2caebd0a54fb3bbf6738 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Tue, 28 Apr 2026 15:27:23 +0300 Subject: [PATCH 09/15] feat: add AI agent configuration options to project creation Co-authored-by: Copilot --- packages/cli/lib/PromptSession.ts | 13 ++++++++++++- packages/cli/lib/commands/new.ts | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index ebf38a20c..c60dab682 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -1,10 +1,11 @@ import { + AI_AGENT_SKILLS_DIRS, AIAgentTarget, BasePromptSession, GoogleAnalytics, InquirerWrapper, PackageManager, ProjectConfig, ProjectLibrary, PromptTaskContext, Task, Util } from "@igniteui/cli-core"; import * as path from "path"; import { default as add } from "./commands/add"; -import { configureMCP } from "./commands/ai-config"; +import { configure, configureMCP } from "./commands/ai-config"; import { default as start } from "./commands/start"; import { default as upgrade } from "./commands/upgrade"; import { TemplateManager } from "./TemplateManager"; @@ -76,6 +77,12 @@ export class PromptSession extends BasePromptSession { // project options: theme = await this.getTheme(projLibrary); + const AI_AGENT_CHOICES = Object.keys(AI_AGENT_SKILLS_DIRS) as AIAgentTarget[]; + const agents = await InquirerWrapper.checkbox({ + message: "Which AI agent(s) are you using? (optional)", + choices: AI_AGENT_CHOICES + }) as AIAgentTarget[]; + Util.log(" Generating project structure."); const config = projTemplate.generateConfig(projectName, theme); for (const templatePath of projTemplate.templatePaths) { @@ -89,6 +96,10 @@ export class PromptSession extends BasePromptSession { } // move cwd to project folder process.chdir(projectName); + + if (agents?.length) { + configure(agents); + } } await this.chooseActionLoop(projLibrary); //TODO: restore cwd? diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index 1c1bc309a..aa7717840 100644 --- a/packages/cli/lib/commands/new.ts +++ b/packages/cli/lib/commands/new.ts @@ -1,9 +1,12 @@ -import { GoogleAnalytics, PackageManager, ProjectConfig, ProjectLibrary, Util } from "@igniteui/cli-core"; +import { AI_AGENT_SKILLS_DIRS, AIAgentTarget, GoogleAnalytics, PackageManager, ProjectConfig, ProjectLibrary, Util } from "@igniteui/cli-core"; import * as path from "path"; import { PromptSession } from "./../PromptSession"; import { NewCommandType, PositionalArgs } from "./types"; import { TemplateManager } from "../TemplateManager"; import { ArgumentsCamelCase, Choices } from "yargs"; +import { configure } from "./ai-config"; + +const AI_AGENT_CHOICES = Object.keys(AI_AGENT_SKILLS_DIRS) as AIAgentTarget[]; // explicit typing because `type: "string"` will be inferred as `type: string` which yargs will not like const _framework: { @@ -59,6 +62,12 @@ const command: NewCommandType = { describe: "Project template", type: "string" }) + .option("agent", { + alias: "a", + describe: "AI agent(s) to configure skills for (determines the target skills directory)", + choices: AI_AGENT_CHOICES, + type: "array" + }) .example("$0 new my-app", "Scaffold a new project interactively") .example("$0 new my-app -f angular -t igx-ts", "Scaffold an Ignite UI for Angular project"); }, @@ -162,6 +171,13 @@ const command: NewCommandType = { process.chdir(".."); } + const agents = argv.agent as AIAgentTarget[] | undefined; + if (agents?.length) { + process.chdir(argv.name); + configure(agents); + process.chdir(".."); + } + Util.log(""); Util.log("Next Steps:"); Util.log(` cd ${argv.name}`); From f2f39a929f22b12c57132b306be4aaec2af024a1 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Wed, 29 Apr 2026 13:48:25 +0300 Subject: [PATCH 10/15] change():Moving skills to ai-config skills directory and updating the logic that extracts skills from the templates Co-authored-by: Copilot --- .../igr-ts/projects/ai-config/files/AGENTS.md | 116 ++++ .../igr-ts/projects/ai-config/files/CLAUDE.md | 3 + .../__dot__github/copilot-instructions.md | 3 + .../__dot__github/workflows/github-pages.yml | 50 ++ .../files/__dot__github/workflows/node.js.yml | 36 ++ .../ai-config/files/__dot__vscode/mcp.json | 12 + .../skills/igniteui-react-components/SKILL.md | 161 +++++ .../reference/CHARTS-GRIDS.md | 199 +++++++ .../reference/COMPONENT-CATALOGUE.md | 301 ++++++++++ .../reference/EVENT-HANDLING.md | 70 +++ .../reference/INSTALLATION.md | 139 +++++ .../reference/JSX-PATTERNS.md | 187 ++++++ .../reference/REFS-FORMS.md | 232 ++++++++ .../reference/REVEAL-SDK.md | 198 +++++++ .../reference/TROUBLESHOOTING.md | 147 +++++ .../igniteui-react-customize-theme/SKILL.md | 182 ++++++ .../reference/CSS-THEMING.md | 265 +++++++++ .../reference/MCP-SERVER.md | 75 +++ .../reference/REVEAL-THEME.md | 86 +++ .../reference/SASS-THEMING.md | 125 ++++ .../reference/TROUBLESHOOTING.md | 35 ++ .../SKILL.md | 439 ++++++++++++++ .../react/igr-ts/projects/ai-config/index.ts | 50 ++ .../igc-ts/projects/ai-config/AGENTS.md | 39 ++ .../igc-ts/projects/ai-config/index.ts | 38 ++ .../igniteui-wc-choose-components/SKILL.md | 0 .../SKILL.md | 0 .../SKILL.md | 0 .../references/angular.md | 0 .../references/react.md | 0 .../references/vanilla-js.md | 0 .../references/vue.md | 0 .../igniteui-wc-optimize-bundle-size/SKILL.md | 0 packages/core/util/ai-skills.ts | 17 +- .../igniteui-angular-components/SKILL.md | 0 .../references/charts.md | 0 .../references/data-display.md | 0 .../references/directives.md | 0 .../references/feedback.md | 0 .../references/form-controls.md | 0 .../references/layout-manager.md | 0 .../references/layout.md | 0 .../references/setup.md | 0 .../skills/igniteui-angular-grids/SKILL.md | 0 .../references/data-operations.md | 0 .../references/editing.md | 0 .../references/features.md | 0 .../references/paging-remote.md | 0 .../references/state.md | 0 .../references/structure.md | 0 .../references/types.md | 0 .../skills/igniteui-angular-theming/SKILL.md | 0 .../references/common-patterns.md | 0 .../references/contributing.md | 0 .../references/mcp-setup.md | 0 .../igx-ts/projects/ai-config/AGENTS.md | 58 ++ .../igx-ts/projects/ai-config/index.ts | 57 ++ .../igniteui-angular-components/SKILL.md | 68 +++ .../references/charts.md | 457 +++++++++++++++ .../references/data-display.md | 360 ++++++++++++ .../references/directives.md | 272 +++++++++ .../references/feedback.md | 149 +++++ .../references/form-controls.md | 361 ++++++++++++ .../references/layout-manager.md | 548 ++++++++++++++++++ .../references/layout.md | 225 +++++++ .../references/setup.md | 165 ++++++ .../skills/igniteui-angular-grids/SKILL.md | 110 ++++ .../references/data-operations.md | 445 ++++++++++++++ .../references/editing.md | 491 ++++++++++++++++ .../references/features.md | 234 ++++++++ .../references/paging-remote.md | 397 +++++++++++++ .../references/state.md | 314 ++++++++++ .../references/structure.md | 299 ++++++++++ .../references/types.md | 507 ++++++++++++++++ .../skills/igniteui-angular-theming/SKILL.md | 439 ++++++++++++++ .../references/common-patterns.md | 45 ++ .../references/contributing.md | 471 +++++++++++++++ .../references/mcp-setup.md | 77 +++ scripts/update-skills.ts | 6 +- spec/acceptance/help-spec.ts | 4 + spec/unit/PromptSession-spec.ts | 5 + spec/unit/ai-skills-spec.ts | 9 +- 82 files changed, 9764 insertions(+), 14 deletions(-) create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/AGENTS.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/CLAUDE.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/__dot__github/copilot-instructions.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/__dot__github/workflows/github-pages.yml create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/__dot__github/workflows/node.js.yml create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/__dot__vscode/mcp.json create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/SKILL.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/CHARTS-GRIDS.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/COMPONENT-CATALOGUE.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/EVENT-HANDLING.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/INSTALLATION.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/JSX-PATTERNS.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/REFS-FORMS.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/REVEAL-SDK.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-components/reference/TROUBLESHOOTING.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-customize-theme/SKILL.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-customize-theme/reference/CSS-THEMING.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-customize-theme/reference/MCP-SERVER.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-customize-theme/reference/REVEAL-THEME.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-customize-theme/reference/SASS-THEMING.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-customize-theme/reference/TROUBLESHOOTING.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/files/skills/igniteui-react-optimize-bundle-size/SKILL.md create mode 100644 packages/cli/templates/react/igr-ts/projects/ai-config/index.ts create mode 100644 packages/cli/templates/webcomponents/igc-ts/projects/ai-config/AGENTS.md create mode 100644 packages/cli/templates/webcomponents/igc-ts/projects/ai-config/index.ts rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-choose-components/SKILL.md (100%) rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-customize-component-theme/SKILL.md (100%) rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-integrate-with-framework/SKILL.md (100%) rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-integrate-with-framework/references/angular.md (100%) rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-integrate-with-framework/references/react.md (100%) rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-integrate-with-framework/references/vanilla-js.md (100%) rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-integrate-with-framework/references/vue.md (100%) rename packages/cli/templates/webcomponents/igc-ts/projects/{_base/files/__dot__claude => ai-config}/skills/igniteui-wc-optimize-bundle-size/SKILL.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/SKILL.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/charts.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/data-display.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/directives.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/feedback.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/form-controls.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/layout-manager.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/layout.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-components/references/setup.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/SKILL.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/references/data-operations.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/references/editing.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/references/features.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/references/paging-remote.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/references/state.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/references/structure.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-grids/references/types.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-theming/SKILL.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-theming/references/common-patterns.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-theming/references/contributing.md (100%) rename packages/igx-templates/igx-ts/projects/_base/files/{__dot__claude => }/skills/igniteui-angular-theming/references/mcp-setup.md (100%) create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/AGENTS.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/index.ts create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/SKILL.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/charts.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/data-display.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/directives.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/feedback.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/form-controls.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/layout-manager.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/layout.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/setup.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/SKILL.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/data-operations.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/editing.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/features.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/paging-remote.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/state.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/structure.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/types.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/SKILL.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/common-patterns.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/contributing.md create mode 100644 packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/mcp-setup.md diff --git a/packages/cli/templates/react/igr-ts/projects/ai-config/files/AGENTS.md b/packages/cli/templates/react/igr-ts/projects/ai-config/files/AGENTS.md new file mode 100644 index 000000000..667123223 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/ai-config/files/AGENTS.md @@ -0,0 +1,116 @@ +You are an expert in building front-end web applications with React with deep knowledge of modern hooks, TypeScript, and cutting-edge frontend architecture. You are familiar with the igniteui-react library. You prioritize performance optimizations and accessibility best practices in your work. + +## Your Expertise + +- **React 19.2 Features**: Expert in `` component, `useEffectEvent()`, and React Performance Tracks +- **React 19 Core Features**: Mastery of `use()` hook, `useFormStatus`, `useOptimistic`, `useActionState`, and Actions API +- **Concurrent Rendering**: Expert knowledge of concurrent rendering patterns, transitions, and Suspense boundaries +- **Modern Hooks**: Deep knowledge of all React hooks including new ones and advanced composition patterns +- **TypeScript Integration**: Advanced TypeScript patterns with improved React 19 type inference and type safety +- **Form Handling**: Expert in modern form patterns with Actions API, validation, and optimistic updates +- **State Management**: Mastery of React Context, Zustand, Redux Toolkit, and choosing the right solution +- **Performance Optimization**: Expert in React.memo, useMemo, useCallback, code splitting, lazy loading, and Core Web Vitals +- **Testing Strategies**: Comprehensive testing with Vitest, React Testing Library, and Playwright/Cypress +- **Accessibility**: WCAG compliance, semantic HTML, ARIA attributes, and keyboard navigation +- **Vite**: Configuration, plugins, environment variables, and build optimization +- **Design Systems**: Material UI, Shadcn/ui, and custom design system architecture + +## Your Approach + +- **React 19.2 First**: Leverage the latest features including ``, `useEffectEvent()`, and Performance Tracks +- **Modern Hooks**: Use `use()`, `useFormStatus`, `useOptimistic`, and `useActionState` for cutting-edge patterns +- **Actions for Forms**: Use Actions API for client-side form handling +- **Concurrent by Default**: Leverage concurrent rendering with `startTransition` and `useDeferredValue` +- **TypeScript Throughout**: Use comprehensive type safety with React 19's improved type inference +- **Performance-First**: Optimize rendering, bundle size, and runtime performance +- **Accessibility by Default**: Build inclusive interfaces following WCAG 2.1 AA standards +- **Test-Driven**: Write tests alongside components using Vitest and React Testing Library best practices +- **Modern Development**: Use Vite, ESLint, Prettier, and modern tooling for optimal DX + +## Guidelines + +- Always use functional components with hooks - class components are legacy +- Leverage React 19.2 features: ``, `useEffectEvent()`, Performance Tracks +- Use the `use()` hook for promise handling and async data fetching +- Implement forms with Actions API and `useFormStatus` for loading states +- Use `useOptimistic` for optimistic UI updates during async operations +- Use `useActionState` for managing action state and form submissions +- Leverage `useEffectEvent()` to extract non-reactive logic from effects (React 19.2) +- Use `` component to manage UI visibility and state preservation (React 19.2) +- **Ref as Prop** (React 19): Pass `ref` directly as prop - no need for `forwardRef` anymore +- **Context without Provider** (React 19): Render context directly instead of `Context.Provider` +- Use `startTransition` for non-urgent updates to keep the UI responsive +- Leverage Suspense boundaries for async data fetching and code splitting +- No need to import React in every file - new JSX transform handles it +- Use strict TypeScript with proper interface design and discriminated unions +- Implement proper error boundaries for graceful error handling +- Use semantic HTML elements (`