diff --git a/.gitignore b/.gitignore index 0a1faf325..44561d19c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # ignore compiled files in packages /packages/**/*.js.map /packages/**/*.js +/packages/**/*.d.ts /scripts/**/*.js.map /scripts/**/*.js /spec/**/*.js.map diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index e2c1975ab..18ebe2678 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -1,4 +1,4 @@ -import { checkbox, input, select, Separator } from '@inquirer/prompts'; +import { checkbox, confirm, input, select, Separator } from '@inquirer/prompts'; import { Context } from '@inquirer/type'; // ref - node_modules\@inquirer\input\dist\cjs\types\index.d.ts - bc for some reason this is not publicly exported @@ -32,4 +32,8 @@ export class InquirerWrapper { public static async checkbox(message: InputConfig & { choices: (string | Separator)[] }, context?: Context): Promise { return checkbox(message, context); } + + public static async confirm(message: { message: string; default?: boolean }, context?: Context): Promise { + return confirm(message, context); + } } diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 37a21f111..4f67d5d7d 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -1,12 +1,19 @@ +import * as path from "path"; import * as ts from "typescript"; import { DependencyNotFoundException } from "@angular-devkit/core"; import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics"; -import { addClassToBody, FormatSettings, NPM_ANGULAR, resolvePackage, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core"; +import { addClassToBody, FormatSettings, InquirerWrapper, NPM_ANGULAR, resolvePackage, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core"; import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates"; import { createCliConfig } from "../utils/cli-config"; import { setVirtual } from "../utils/NgFileSystem"; import { addFontsToIndexHtml, getProjects, importDefaultTheme } from "../utils/theme-import"; +interface CliConfigOptions { + addAISkills?: boolean; + aiSkillsTargets?: string[]; + aiSkillsCustomPath?: string; +} + function getDependencyVersion(pkg: string, tree: Tree): string { const targetFile = "/package.json"; if (tree.exists(targetFile)) { @@ -117,16 +124,134 @@ function importStyles(): Rule { }; } +/** Agent target → destination path mapping */ +const AGENT_DEST_MAP: Record = { + copilot: ".github/copilot-instructions.md", + claude: "CLAUDE.md", + cursor: ".cursor/skills", + agents: ".agents/skills" +}; + +/** Agent choices for interactive prompt, maps agent key → display label */ +export const AGENT_CHOICES: { key: string; label: string }[] = [ + { key: "copilot", label: "copilot (.github/copilot-instructions.md)" }, + { key: "claude", label: "claude (CLAUDE.md)" }, + { key: "cursor", label: "cursor (.cursor/skills/)" }, + { key: "agents", label: "agents (.agents/skills/)" }, + { key: "custom", label: "custom (add custom path)" } +]; + +function copySkillFile(tree: Tree, sourcePath: string, destPath: string, context: SchematicContext): void { + if (!tree.exists(sourcePath)) { + context.logger.debug(`Source skill file not found: ${sourcePath}`); + return; + } + if (tree.exists(destPath)) { + context.logger.info(`${destPath} already exists. Skipping.`); + return; + } + const content = tree.read(sourcePath); + if (!content) { + context.logger.debug(`Could not read source skill file: ${sourcePath}`); + return; + } + tree.create(destPath, content); + context.logger.info(`Created ${destPath}`); +} + +function addAISkillsFiles(options: CliConfigOptions): Rule { + return async (tree: Tree, context: SchematicContext) => { + // Step 1: Ask if user wants AI skills (only if not already specified) + let addSkills = options.addAISkills; + if (addSkills === undefined) { + addSkills = await InquirerWrapper.confirm({ + message: "Would you like to add AI coding skills for your IDE?", + default: true + }); + } + if (!addSkills) { + return; + } + + // Step 2: Ask which agents to target (only if not already specified) + let targets = options.aiSkillsTargets || []; + if (targets.length === 0) { + const selected = await InquirerWrapper.checkbox({ + message: "Which AI coding assistant(s) would you like to add skills for?", + choices: AGENT_CHOICES.map(c => c.label) + }); + // Map display labels back to agent keys + const labelToKey = new Map(AGENT_CHOICES.map(c => [c.label, c.key])); + targets = selected.map(s => labelToKey.get(s) || s); + } + if (targets.length === 0) { + return; + } + + // Step 3: If "custom" selected, ask for path (only if not already specified) + let customPath = options.aiSkillsCustomPath; + if (targets.includes("custom") && customPath === undefined) { + customPath = await InquirerWrapper.input({ + message: "Enter the custom path for AI skill files:" + }); + } + + const igxPackage = resolvePackage(NPM_ANGULAR); + const skillsSourceDir = `/node_modules/${igxPackage}/skills`; + const skillsDir = tree.getDir(skillsSourceDir); + const skillFiles = skillsDir.subfiles; + + if (!skillFiles.length) { + context.logger.warn(`No skill files found in ${skillsSourceDir}. Skipping AI skills setup.`); + return; + } + + for (const target of targets) { + let destDir: string; + if (target === "custom") { + if (!customPath) { + context.logger.warn("Custom AI skills path was selected but no path was provided. Skipping."); + continue; + } + destDir = customPath; + } else { + const dest = AGENT_DEST_MAP[target]; + if (!dest) { + context.logger.warn(`Unknown AI agent target: ${target}. Skipping.`); + continue; + } + destDir = dest; + } + + // Check if the dest is a specific file path (has .md extension) or a directory + if (destDir.endsWith(".md")) { + // For single-file destinations (Copilot, Claude), copy first skill file + const sourcePath = path.posix.join(skillsSourceDir, skillFiles[0]); + copySkillFile(tree, sourcePath, destDir, context); + } else { + // For directory destinations (Cursor, Agents, Custom), copy all skill files + for (const file of skillFiles) { + const sourcePath = path.posix.join(skillsSourceDir, file); + const destPath = path.posix.join(destDir, file); + copySkillFile(tree, sourcePath, destPath, context); + } + } + } + }; +} + // tslint:disable-next-line:space-before-function-paren -export default function (): Rule { +export default function (options: CliConfigOptions = {}): Rule { return (tree: Tree) => { setVirtual(tree); - return chain([ + const rules: Rule[] = [ importStyles(), addTypographyToProj(), importBrowserAnimations(), createCliConfig(), - displayVersionMismatch() - ]); + displayVersionMismatch(), + addAISkillsFiles(options) + ]; + return chain(rules); }; } diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index ddeba92e1..35378b799 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -2,7 +2,8 @@ import * as path from "path"; import { EmptyTree } from "@angular-devkit/schematics"; import { SchematicTestRunner, UnitTestTree } from "@angular-devkit/schematics/testing"; -import { FEED_ANGULAR, NPM_ANGULAR } from "@igniteui/cli-core"; +import { FEED_ANGULAR, InquirerWrapper, NPM_ANGULAR } from "@igniteui/cli-core"; +import { AGENT_CHOICES } from "./index"; describe("cli-config schematic", () => { const collectionPath = path.join(__dirname, "../collection.json"); @@ -85,6 +86,9 @@ describe("cli-config schematic", () => { `); createIgPkgJson(); populatePkgJson(); + // Default: return empty selection so existing tests aren't affected by AI skills prompts + spyOn(InquirerWrapper, "confirm").and.returnValue(Promise.resolve(false)); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); }); it("should create the needed files correctly", () => { @@ -309,4 +313,206 @@ export const appConfig: ApplicationConfig = { await runner.runSchematic("cli-config", {}, tree); expect(warns).toContain(jasmine.stringMatching(pattern)); }); + + describe("addAISkills", () => { + const mockSkillContent = "# Ignite UI for Angular - AI Skills\nBest practices..."; + const copilotDest = ".github/copilot-instructions.md"; + const claudeDest = "CLAUDE.md"; + const cursorDest = ".cursor/skills"; + const agentsDest = ".agents/skills"; + + function createSkillFiles(igxPkg = NPM_ANGULAR) { + const dir = `node_modules/${igxPkg}/skills`; + tree.create(`${dir}/igniteui-angular.md`, mockSkillContent); + } + + it("should copy skill file to .github/copilot-instructions.md for copilot target", async () => { + createSkillFiles(); + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["copilot"] + }, tree); + expect(tree.exists(copilotDest)).toBeTruthy(); + expect(tree.readContent(copilotDest)).toEqual(mockSkillContent); + }); + + it("should copy skill file to CLAUDE.md for claude target", async () => { + createSkillFiles(); + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["claude"] + }, tree); + expect(tree.exists(claudeDest)).toBeTruthy(); + expect(tree.readContent(claudeDest)).toEqual(mockSkillContent); + }); + + it("should copy skill files to .cursor/skills/ for cursor target", async () => { + createSkillFiles(); + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["cursor"] + }, tree); + expect(tree.exists(`${cursorDest}/igniteui-angular.md`)).toBeTruthy(); + expect(tree.readContent(`${cursorDest}/igniteui-angular.md`)).toEqual(mockSkillContent); + }); + + it("should copy skill files to .agents/skills/ for agents target", async () => { + createSkillFiles(); + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["agents"] + }, tree); + expect(tree.exists(`${agentsDest}/igniteui-angular.md`)).toBeTruthy(); + expect(tree.readContent(`${agentsDest}/igniteui-angular.md`)).toEqual(mockSkillContent); + }); + + it("should copy skill files to custom path", async () => { + createSkillFiles(); + const customPath = "my-custom/ai-skills"; + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["custom"], + aiSkillsCustomPath: customPath + }, tree); + expect(tree.exists(`${customPath}/igniteui-angular.md`)).toBeTruthy(); + expect(tree.readContent(`${customPath}/igniteui-angular.md`)).toEqual(mockSkillContent); + }); + + it("should handle multiple targets at once", async () => { + createSkillFiles(); + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["copilot", "claude", "cursor", "agents"] + }, tree); + expect(tree.exists(copilotDest)).toBeTruthy(); + expect(tree.exists(claudeDest)).toBeTruthy(); + expect(tree.exists(`${cursorDest}/igniteui-angular.md`)).toBeTruthy(); + expect(tree.exists(`${agentsDest}/igniteui-angular.md`)).toBeTruthy(); + }); + + it("should NOT create skill files when addAISkills is false", async () => { + createSkillFiles(); + await runner.runSchematic("cli-config", { + addAISkills: false + }, tree); + expect(tree.exists(copilotDest)).toBeFalsy(); + expect(tree.exists(claudeDest)).toBeFalsy(); + }); + + it("should NOT create skill files when user selects no targets from prompt", async () => { + createSkillFiles(); + // checkbox returns empty selection → no files created + (InquirerWrapper.checkbox as jasmine.Spy).and.returnValue(Promise.resolve([])); + await runner.runSchematic("cli-config", {}, tree); + expect(tree.exists(copilotDest)).toBeFalsy(); + expect(tree.exists(claudeDest)).toBeFalsy(); + }); + + it("should prompt for targets when user confirms and no targets provided", async () => { + createSkillFiles(); + const copilotChoice = AGENT_CHOICES.find(c => c.key === "copilot")!.label; + (InquirerWrapper.checkbox as jasmine.Spy).and.returnValue( + Promise.resolve([copilotChoice]) + ); + + // addAISkills defaults to true via schema, aiSkillsTargets is undefined → prompts + await runner.runSchematic("cli-config", {}, tree); + expect(InquirerWrapper.checkbox).toHaveBeenCalled(); + expect(tree.exists(copilotDest)).toBeTruthy(); + }); + + it("should prompt for custom path when custom target selected via prompt", async () => { + createSkillFiles(); + const customChoice = AGENT_CHOICES.find(c => c.key === "custom")!.label; + (InquirerWrapper.checkbox as jasmine.Spy).and.returnValue( + Promise.resolve([customChoice]) + ); + spyOn(InquirerWrapper, "input").and.returnValue(Promise.resolve("my-custom-path")); + + await runner.runSchematic("cli-config", {}, tree); + expect(InquirerWrapper.input).toHaveBeenCalled(); + expect(tree.exists("my-custom-path/igniteui-angular.md")).toBeTruthy(); + }); + + it("should not overwrite existing copilot-instructions.md", async () => { + createSkillFiles(); + const existingContent = "# Existing instructions"; + tree.create(copilotDest, existingContent); + + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["copilot"] + }, tree); + expect(tree.readContent(copilotDest)).toEqual(existingContent); + }); + + it("should not overwrite existing CLAUDE.md", async () => { + createSkillFiles(); + const existingContent = "# Existing CLAUDE.md"; + tree.create(claudeDest, existingContent); + + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["claude"] + }, tree); + expect(tree.readContent(claudeDest)).toEqual(existingContent); + }); + + it("should not overwrite existing cursor skill files", async () => { + createSkillFiles(); + const existingContent = "# Existing cursor skill"; + tree.create(`${cursorDest}/igniteui-angular.md`, existingContent); + + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["cursor"] + }, tree); + expect(tree.readContent(`${cursorDest}/igniteui-angular.md`)).toEqual(existingContent); + }); + + it("should not overwrite existing agents skill files", async () => { + createSkillFiles(); + const existingContent = "# Existing agents skill"; + tree.create(`${agentsDest}/igniteui-angular.md`, existingContent); + + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["agents"] + }, tree); + expect(tree.readContent(`${agentsDest}/igniteui-angular.md`)).toEqual(existingContent); + }); + + it("should warn when custom target has no path", async () => { + createSkillFiles(); + const warns: string[] = []; + runner.logger.subscribe(entry => { + if (entry.level === "warn") { + warns.push(entry.message); + } + }); + + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["custom"], + aiSkillsCustomPath: "" + }, tree); + expect(warns).toContain(jasmine.stringMatching(/Custom AI skills path was selected but no path was provided/)); + }); + + it("should work with FEED_ANGULAR package", async () => { + // Run schematic first to create ignite-ui-cli.json (required by resetTree) + await runner.runSchematic("cli-config", {}, tree); + + resetTree(); + createIgPkgJson(FEED_ANGULAR); + populatePkgJson(FEED_ANGULAR); + createSkillFiles(FEED_ANGULAR); + + await runner.runSchematic("cli-config", { + addAISkills: true, + aiSkillsTargets: ["copilot"] + }, tree); + expect(tree.exists(copilotDest)).toBeTruthy(); + }); + }); }); diff --git a/packages/ng-schematics/src/cli-config/schema.json b/packages/ng-schematics/src/cli-config/schema.json new file mode 100644 index 000000000..c1e2d7812 --- /dev/null +++ b/packages/ng-schematics/src/cli-config/schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "igniteui-angular-cli-config", + "title": "Ignite UI for Angular CLI Config Options Schema", + "type": "object", + "properties": { + "addAISkills": { + "type": "boolean", + "description": "Add AI coding skills for your IDE", + "default": true + }, + "aiSkillsTargets": { + "type": "array", + "description": "AI agent targets for skill files", + "items": { + "type": "string", + "enum": ["copilot", "claude", "cursor", "agents", "custom"] + } + }, + "aiSkillsCustomPath": { + "type": "string", + "description": "Custom path for AI skill files" + } + }, + "required": [] +} diff --git a/packages/ng-schematics/src/collection.json b/packages/ng-schematics/src/collection.json index ac4f616c9..aeb0b03b0 100644 --- a/packages/ng-schematics/src/collection.json +++ b/packages/ng-schematics/src/collection.json @@ -31,7 +31,8 @@ }, "cli-config": { "description": "Installs the needed dependencies onto the host application.", - "factory": "./cli-config/index" + "factory": "./cli-config/index", + "schema": "./cli-config/schema.json" }, "upgrade-packages": { "description": "Upgrades to the licensed Ignite UI for Angular packages",