diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index 076b23504..ba2bf6025 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -1,10 +1,11 @@ import { + AI_AGENT_LABELS, 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 { configure as aiConfigure } 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,20 @@ 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 selected = await InquirerWrapper.checkbox({ + message: "Which AI tools do you want to generate configuration files for?", + choices: [ + { value: "none", name: "None (skip AI configuration)" }, + ...AI_AGENT_CHOICES.map(agent => ({ + value: agent, + name: AI_AGENT_LABELS[agent], + checked: agent === "generic" || agent === "claude" + })) + ] + }); + const agents = selected.includes("none") ? [] : selected as AIAgentTarget[]; + Util.log(" Generating project structure."); const config = projTemplate.generateConfig(projectName, theme); for (const templatePath of projTemplate.templatePaths) { @@ -89,6 +104,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? @@ -106,7 +125,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 a0f1f924f..8396e085b 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_LABELS, AI_AGENT_SKILLS_DIRS, AIAgentTarget, copyAgentInstructionFiles, copyAISkillsToProject, getSkillsDir, GoogleAnalytics, InquirerWrapper, 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,30 +26,64 @@ export function configureSkills(): void { } } -export function configure(skills = true): void { +export function configure(agents: AIAgentTarget[]): void { configureMCP(); - if (skills) { - configureSkills(); + for (const agent of agents) { + configureSkills(getSkillsDir(agent)); } + copyAgentInstructionFiles(agents); } +const AI_AGENT_CHOICES = Object.keys(AI_AGENT_SKILLS_DIRS) as AIAgentTarget[]; + +const AI_AGENT_CHECKBOX_CHOICES = [ + { value: "none", name: "None (skip AI configuration)" }, + ...AI_AGENT_CHOICES.map(agent => ({ + value: agent, + name: AI_AGENT_LABELS[agent], + checked: agent === "generic" || agent === "claude" + })) +]; + const command: CommandModule = { command: "ai-config", describe: "Configures Ignite UI AI tooling (MCP servers and AI coding skills)", - builder: (yargs) => yargs, - async handler(_argv: ArgumentsCamelCase) { + builder: (yargs) => yargs + .usage("") + .option("agent", { + alias: "a", + describe: "AI agent(s) to configure skills for (determines the target skills directory)", + choices: AI_AGENT_CHOICES, + type: "array" + }), + async handler(argv: ArgumentsCamelCase) { GoogleAnalytics.post({ t: "screenview", cd: "MCP" }); + let agents = argv.agent as AIAgentTarget[] | undefined; + + if (!agents?.length) { + const selected = await InquirerWrapper.checkbox({ + message: "Which AI tools do you want to generate configuration files for?", + choices: AI_AGENT_CHECKBOX_CHOICES + }); + agents = selected.includes("none") ? [] : selected as AIAgentTarget[]; + } + + if (!agents.length) { + Util.log("No AI configuration selected. Skipping."); + return; + } + GoogleAnalytics.post({ t: "event", ec: "$ig ai-config", - ea: "client: vscode" + ea: `agent: ${agents.join(", ")}` }); - configure(); + configure(agents); } }; 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}`); diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/CLAUDE.md b/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/CLAUDE.md deleted file mode 100644 index 3397e20e2..000000000 --- a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -Strictly follow the rules in ../AGENTS.md - -The canonical AI agent instructions are in [AGENTS.md](../AGENTS.md) in the root directory. \ No newline at end of file diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__github/copilot-instructions.md b/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__github/copilot-instructions.md deleted file mode 100644 index 3397e20e2..000000000 --- a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__github/copilot-instructions.md +++ /dev/null @@ -1,3 +0,0 @@ -Strictly follow the rules in ../AGENTS.md - -The canonical AI agent instructions are in [AGENTS.md](../AGENTS.md) in the root directory. \ No newline at end of file diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/AGENTS.md b/packages/cli/templates/react/igr-ts/projects/ai-config/AGENTS.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/AGENTS.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/AGENTS.md diff --git a/packages/cli/templates/react/igr-ts/projects/ai-config/index.ts b/packages/cli/templates/react/igr-ts/projects/ai-config/index.ts new file mode 100644 index 000000000..281cbe573 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/ai-config/index.ts @@ -0,0 +1,50 @@ +import { ControlExtraConfiguration, defaultDelimiters, ProjectTemplate, updateWorkspace, Util } from "@igniteui/cli-core"; +import * as path from "path"; + +export class BaseIgrTsAiConfigPartial implements ProjectTemplate { + public id: string = "ai-config"; + public name = "ai-config"; + public description = "Ignite UI CLI project for React"; + public framework: string = "react"; + public projectType: string = "tsx"; + public dependencies: string[]; + public hasExtraConfiguration: boolean = false; + public isHidden: boolean = true; + public delimiters = defaultDelimiters; + + public get templatePaths(): string[] { + return [path.join(__dirname, "files")]; + } + + public generateConfig(name: string, theme: string, ...options: any[]): {[key: string]: any} { + return this.getVariablesConfig(name, theme); + } + + public installModules(): void { + throw new Error("Method not implemented."); + } + public async upgradeIgniteUIPackages(projectPath: string, packagePath: string): Promise { + throw new Error("Method not implemented."); + } + public getExtraConfiguration(): ControlExtraConfiguration[] { + throw new Error("Method not implemented."); + } + public setExtraConfiguration(extraConfigKeys: {}) { + throw new Error("Method not implemented."); + } + + protected getVariablesConfig(name: string, theme: string) { + return { + name, + theme, + "cliVersion": Util.version(), + "dash-name": Util.lowerDashed(name), + "description": this.description, + "dot": ".", + "path": name, + "projectTemplate": this.id, + "yamlDefaultBranch": this.id === "base" ? "<%=yaml-default-branch%>" : "main" + }; + } +} +export default new BaseIgrTsAiConfigPartial(); \ No newline at end of file diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/SKILL.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/SKILL.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/SKILL.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/SKILL.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/CHARTS-GRIDS.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/CHARTS-GRIDS.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/CHARTS-GRIDS.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/CHARTS-GRIDS.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/COMPONENT-CATALOGUE.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/COMPONENT-CATALOGUE.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/COMPONENT-CATALOGUE.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/COMPONENT-CATALOGUE.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/EVENT-HANDLING.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/EVENT-HANDLING.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/EVENT-HANDLING.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/EVENT-HANDLING.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/INSTALLATION.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/INSTALLATION.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/INSTALLATION.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/INSTALLATION.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/JSX-PATTERNS.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/JSX-PATTERNS.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/JSX-PATTERNS.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/JSX-PATTERNS.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/REFS-FORMS.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/REFS-FORMS.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/REFS-FORMS.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/REFS-FORMS.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/REVEAL-SDK.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/REVEAL-SDK.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/REVEAL-SDK.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/REVEAL-SDK.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/TROUBLESHOOTING.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/TROUBLESHOOTING.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-components/reference/TROUBLESHOOTING.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-components/reference/TROUBLESHOOTING.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/SKILL.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/SKILL.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/SKILL.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/SKILL.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/CSS-THEMING.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/CSS-THEMING.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/CSS-THEMING.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/CSS-THEMING.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/MCP-SERVER.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/MCP-SERVER.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/MCP-SERVER.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/MCP-SERVER.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/REVEAL-THEME.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/REVEAL-THEME.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/REVEAL-THEME.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/REVEAL-THEME.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/SASS-THEMING.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/SASS-THEMING.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/SASS-THEMING.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/SASS-THEMING.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/TROUBLESHOOTING.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/TROUBLESHOOTING.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-customize-theme/reference/TROUBLESHOOTING.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-customize-theme/reference/TROUBLESHOOTING.md diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-optimize-bundle-size/SKILL.md b/packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-optimize-bundle-size/SKILL.md similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills/igniteui-react-optimize-bundle-size/SKILL.md rename to packages/cli/templates/react/igr-ts/projects/ai-config/skills/igniteui-react-optimize-bundle-size/SKILL.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/CLAUDE.md b/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/CLAUDE.md deleted file mode 100644 index 3397e20e2..000000000 --- a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -Strictly follow the rules in ../AGENTS.md - -The canonical AI agent instructions are in [AGENTS.md](../AGENTS.md) in the root directory. \ No newline at end of file diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__github/copilot-instructions.md b/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__github/copilot-instructions.md deleted file mode 100644 index 3397e20e2..000000000 --- a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__github/copilot-instructions.md +++ /dev/null @@ -1,3 +0,0 @@ -Strictly follow the rules in ../AGENTS.md - -The canonical AI agent instructions are in [AGENTS.md](../AGENTS.md) in the root directory. \ No newline at end of file diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/AGENTS.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/AGENTS.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/AGENTS.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/AGENTS.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/index.ts b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/index.ts new file mode 100644 index 000000000..6cc3c28ef --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/index.ts @@ -0,0 +1,38 @@ +import { ControlExtraConfiguration, ProjectTemplate, TemplateDelimiters } from "@igniteui/cli-core"; +import * as path from "path"; + +export class BaseIgcTsAiConfigPartial implements ProjectTemplate { + + public id: string = "ai-config"; + public name = "ai-config"; + public description = "Project structure with routing"; + public framework: string = "webcomponents"; + public projectType: string = "igc-ts"; + public dependencies: string[]; + public hasExtraConfiguration: boolean = false; + public isHidden: boolean = true; + + public get templatePaths(): string[] { + return [path.join(__dirname, "files")]; + } + + installModules(): void { + throw new Error("Method not implemented."); + } + upgradeIgniteUIPackages(projectPath: string, packagePath: string): Promise { + throw new Error("Method not implemented."); + } + generateConfig(name: string, theme: string, ...options: any[]): { [key: string]: any; } { + throw new Error("Method not implemented."); + } + delimiters: TemplateDelimiters; + getExtraConfiguration(): ControlExtraConfiguration[] { + throw new Error("Method not implemented."); + } + setExtraConfiguration(extraConfigKeys: {}) { + throw new Error("Method not implemented."); + } +} +export default new BaseIgcTsAiConfigPartial(); + + diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-choose-components/SKILL.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-choose-components/SKILL.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-choose-components/SKILL.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-choose-components/SKILL.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-customize-component-theme/SKILL.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-customize-component-theme/SKILL.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-customize-component-theme/SKILL.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-customize-component-theme/SKILL.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/SKILL.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/SKILL.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/SKILL.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/SKILL.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/angular.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/angular.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/angular.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/angular.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/react.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/react.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/react.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/react.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/vanilla-js.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/vanilla-js.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/vanilla-js.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/vanilla-js.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/vue.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/vue.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-integrate-with-framework/references/vue.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-integrate-with-framework/references/vue.md diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-optimize-bundle-size/SKILL.md b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-optimize-bundle-size/SKILL.md similarity index 100% rename from packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills/igniteui-wc-optimize-bundle-size/SKILL.md rename to packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills/igniteui-wc-optimize-bundle-size/SKILL.md diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index 18ebe2678..32db9ac66 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -8,7 +8,7 @@ type InputConfig = { required?: boolean; type?: string; name?: string; - choices?: (string | Separator)[]; + choices?: (string | { value: string; name?: string; checked?: boolean } | Separator)[]; transformer?: (value: string, { isFinal }: { isFinal: boolean; }) => string; @@ -29,8 +29,8 @@ export class InquirerWrapper { return select(message, context); } - public static async checkbox(message: InputConfig & { choices: (string | Separator)[] }, context?: Context): Promise { - return checkbox(message, context); + public static async checkbox(message: InputConfig & { choices: (string | { value: string; name?: string; checked?: boolean } | Separator)[] }, context?: Context): Promise { + return checkbox(message as any, context); } public static async confirm(message: { message: string; default?: boolean }, context?: Context): Promise { diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 70625f4f8..ae7bdd42b 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -9,8 +9,54 @@ 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"; +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" +}; + +export const AI_AGENT_INSTRUCTION_FILES: Record = { + claude: ".claude/CLAUDE.md", + copilot: ".github/copilot-instructions.md", + cursor: ".cursor/rules/cursor.mdc", + codex: ".codex/instructions.md", + windsurf: ".windsurf/rules/guidelines.md", + gemini: ".gemini/GEMINI.md", + junie: ".junie/guidelines.md", + generic: "AGENTS.md" +}; + +export const AI_AGENT_LABELS: Record = { + claude: "Claude (Adding .claude/skills and CLAUDE.md)", + copilot: "Copilot (Adding .github/skills and copilot-instructions.md)", + cursor: "Cursor (Adding .cursor/skills and .cursor/rules/cursor.mdc)", + codex: "Codex (Adding .codex/skills and .codex/instructions.md)", + windsurf: "Windsurf (Adding .windsurf/skills and .windsurf/rules/guidelines.md)", + gemini: "Gemini (Adding .gemini/skills and .gemini/GEMINI.md)", + junie: "Junie (Adding .junie/skills and .junie/guidelines.md)", + generic: "Generic (Adding .agents/skills and AGENTS.md)" +}; + +/** + * Returns the project-level skills directory for the given AI agent target. + */ +export function getSkillsDir(target: AIAgentTarget): string { + return AI_AGENT_SKILLS_DIRS[target]; +} + +/** + * Returns the agent-specific instruction file path for the given AI agent target. + */ +export function getInstructionFilePath(target: AIAgentTarget): string { + return AI_AGENT_INSTRUCTION_FILES[target]; +} export interface AISkillsCopyResult { found: number; @@ -60,12 +106,15 @@ function resolveSkillsRoots(): string[] { if (framework) { const templateManager = App.container.get(TEMPLATE_MANAGER); const projectLib = templateManager?.getFrameworkById(framework)?.projectLibraries[0]; - const filePaths = projectLib?.getProject(projectLib.projectIds[0]).templatePaths ?? []; - roots.push( - ...filePaths - .map((p) => path.join(p, CLAUDE_SKILLS_DIR_TEMPLATE)) - .slice(0, 1), - ); + const aiConfigProject = projectLib?.getProject("ai-config"); + if (aiConfigProject) { + const filePaths = aiConfigProject.templatePaths ?? []; + roots.push( + ...filePaths + .map((p) => path.join(p, "..", "skills")) + .slice(0, 1), + ); + } } } @@ -73,9 +122,15 @@ 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. + * @param skillsDir – destination directory (e.g. `.agents/skills`, `.cursor/skills`, …) */ -export function copyAISkillsToProject(): AISkillsCopyResult { +export function copyAISkillsToProject(skillsDir: string): AISkillsCopyResult { + let outputDir = skillsDir.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): @@ -102,8 +157,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 { @@ -127,3 +182,58 @@ export function copyAISkillsToProject(): AISkillsCopyResult { return result; } + +/** + * Resolves the AGENTS.md source file content from installed packages or template files. + * Uses the same resolution logic as `resolveSkillsRoots()` – the AGENTS.md is expected + * to be a sibling of the `skills/` directory. + */ +function resolveAgentsContent(): string | null { + const srcFs = new FsFileSystem(); + const skillsRoots = resolveSkillsRoots(); + + for (const skillsRoot of skillsRoots) { + const agentsPath = path.join(path.dirname(skillsRoot), "AGENTS.md"); + if (srcFs.fileExists(agentsPath)) { + return srcFs.readFile(agentsPath); + } + } + + return null; +} + +/** + * Copies the AGENTS.md content into agent-specific instruction files for + * each of the given agents. + * @param agents – list of AI agent targets to create instruction files for + */ +export function copyAgentInstructionFiles(agents: AIAgentTarget[]): void { + const content = resolveAgentsContent(); + if (!content) { + return; + } + + const destFs = App.container.get(FS_TOKEN); + + for (const agent of agents) { + const dest = getInstructionFilePath(agent); + const fileContent = agent === "cursor" + ? `---\ncontext: true\npriority: high\nscope: project\n---\n${content}` + : content; + try { + if (destFs.fileExists(dest)) { + const existing = destFs.readFile(dest); + if (existing === fileContent) { + continue; + } + destFs.writeFile(dest, fileContent); + Util.log(`${Util.greenCheck()} Updated ${dest}`); + } else { + destFs.writeFile(dest, fileContent); + Util.log(`${Util.greenCheck()} Created ${dest}`); + } + } catch { + /* skip on error */ + } + } +} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/CLAUDE.md b/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/CLAUDE.md deleted file mode 100644 index 3397e20e2..000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -Strictly follow the rules in ../AGENTS.md - -The canonical AI agent instructions are in [AGENTS.md](../AGENTS.md) in the root directory. \ No newline at end of file diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__github/copilot-instructions.md b/packages/igx-templates/igx-ts/projects/_base/files/__dot__github/copilot-instructions.md deleted file mode 100644 index 3397e20e2..000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__github/copilot-instructions.md +++ /dev/null @@ -1,3 +0,0 @@ -Strictly follow the rules in ../AGENTS.md - -The canonical AI agent instructions are in [AGENTS.md](../AGENTS.md) in the root directory. \ No newline at end of file diff --git a/packages/igx-templates/igx-ts/projects/_base/files/AGENTS.md b/packages/igx-templates/igx-ts/projects/ai-config/AGENTS.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/AGENTS.md rename to packages/igx-templates/igx-ts/projects/ai-config/AGENTS.md diff --git a/packages/igx-templates/igx-ts/projects/ai-config/index.ts b/packages/igx-templates/igx-ts/projects/ai-config/index.ts new file mode 100644 index 000000000..7f07165d7 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/ai-config/index.ts @@ -0,0 +1,57 @@ +import { ControlExtraConfiguration, NPM_ANGULAR, ProjectTemplate, Util, updateWorkspace } from "@igniteui/cli-core"; +import * as path from "path"; + +export class BaseIgxAiConfigPartial implements ProjectTemplate { + public id: string = "ai-config"; + public name = "ai-config"; + public description = "Empty project layout structure for Ignite UI for Angular"; + public dependencies: string[] = []; + public framework: string = "angular"; + public projectType: string = "igx-ts"; + public hasExtraConfiguration = false; + public isHidden: boolean = true; + public delimiters = { + content: { + end: `%>`, + start: `<%=` + }, + path: { + end: `__`, + start: `__` + } + }; + public get templatePaths(): string[] { + return [path.join(__dirname, "files")]; + } + + public installModules(): void { + throw new Error("Method not implemented."); + } + + public async upgradeIgniteUIPackages(projectPath: string, packagePath: string): Promise { + return updateWorkspace(projectPath); + } + public getExtraConfiguration(): ControlExtraConfiguration[] { + return []; + } + public setExtraConfiguration(extraConfigKeys: any[]) { } + public generateConfig(name: string, theme: string, ...options: any[]): { [key: string]: any } { + return{} + } + + protected getVariablesConfig(name: string, theme: string) { + return { + name, + theme, + "cliVersion": Util.version(), + "dash-name": Util.lowerDashed(name), + "description": this.description, + "dot": ".", + "path": name, + "projectTemplate": this.id, + "yamlDefaultBranch": this.id === "base" ? "<%=yaml-default-branch%>" : "main" + }; + } +} + +export default new BaseIgxAiConfigPartial(); diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/SKILL.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/SKILL.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/SKILL.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/SKILL.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/charts.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/charts.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/charts.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/charts.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/data-display.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/data-display.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/data-display.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/data-display.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/directives.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/directives.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/directives.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/directives.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/feedback.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/feedback.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/feedback.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/feedback.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/form-controls.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/form-controls.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/form-controls.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/form-controls.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/layout-manager.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/layout-manager.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/layout-manager.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/layout-manager.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/layout.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/layout.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/layout.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/layout.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/setup.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/setup.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-components/references/setup.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-components/references/setup.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/SKILL.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/SKILL.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/SKILL.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/SKILL.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/data-operations.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/data-operations.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/data-operations.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/data-operations.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/editing.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/editing.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/editing.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/editing.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/features.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/features.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/features.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/features.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/paging-remote.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/paging-remote.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/paging-remote.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/paging-remote.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/state.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/state.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/state.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/state.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/structure.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/structure.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/structure.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/structure.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/types.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/types.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-grids/references/types.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-grids/references/types.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/SKILL.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/SKILL.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/SKILL.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/SKILL.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/references/common-patterns.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/common-patterns.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/references/common-patterns.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/common-patterns.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/references/contributing.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/contributing.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/references/contributing.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/contributing.md diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/references/mcp-setup.md b/packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/mcp-setup.md similarity index 100% rename from packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills/igniteui-angular-theming/references/mcp-setup.md rename to packages/igx-templates/igx-ts/projects/ai-config/skills/igniteui-angular-theming/references/mcp-setup.md 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..60b061162 --- /dev/null +++ b/packages/ng-schematics/src/cli-config/ai-config-schema.json @@ -0,0 +1,34 @@ +{ + "$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": "array", + "description": "AI agent(s) to configure skills for (determines the target skills directories).", + "alias": "a", + "items": { + "type": "string", + "enum": ["none", "claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"] + }, + "x-prompt": { + "message": "Which AI tools do you want to generate configuration files for?", + "type": "list", + "multiselect": true, + "items": [ + { "value": "none", "label": "None (skip AI configuration)" }, + { "value": "claude", "label": "Claude (Adding .claude/skills and CLAUDE.md)" }, + { "value": "copilot", "label": "Copilot (Adding .github/skills and copilot-instructions.md)" }, + { "value": "cursor", "label": "Cursor (Adding .cursor/skills and .cursor/rules/cursor.mdc)" }, + { "value": "codex", "label": "Codex (Adding .codex/skills and .codex/instructions.md)" }, + { "value": "windsurf", "label": "Windsurf (Adding .windsurf/skills and .windsurf/rules/guidelines.md)" }, + { "value": "gemini", "label": "Gemini (Adding .gemini/skills and .gemini/GEMINI.md)" }, + { "value": "junie", "label": "Junie (Adding .junie/skills and .junie/guidelines.md)" }, + { "value": "generic", "label": "Generic (Adding .agents/skills and AGENTS.md)" } + ] + } + } + } +} diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 35bdb322b..bb76300e2 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, copyAgentInstructionFiles, 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,15 @@ function appInit(tree: Tree) { setVirtual(tree); } -function aiConfig({ init } = { init: true }): Rule { +function aiConfig({ init, agents }: { init: boolean; agents: AIAgentTarget[] }): Rule { return (tree: Tree) => { if (init) { appInit(tree); } - copyAISkillsToProject(); + for (const agent of agents) { + copyAISkillsToProject(getSkillsDir(agent)); + } + copyAgentInstructionFiles(agents); const angularCliServer: Record = { "angular-cli": { @@ -145,8 +148,13 @@ function aiConfig({ init } = { init: true }): Rule { } /** Standalone `ai-config` schematic entry */ -export function addAIConfig(): Rule { - return aiConfig(); +export function addAIConfig(options: { agent?: AIAgentTarget[] } = {}): Rule { + const selected = options.agent?.length ? options.agent : ["claude", "generic"] as AIAgentTarget[]; + const agents = selected.includes("none" as any) ? [] : selected; + if (!agents.length) { + return (tree: Tree) => tree; + } + return aiConfig({ init: true, agents }); } export default function (): Rule { @@ -158,7 +166,7 @@ export default function (): Rule { importBrowserAnimations(), createCliConfig(), displayVersionMismatch(), - aiConfig({ init: false }) + aiConfig({ init: false, agents: ["claude", "generic"] }) ]); }; } diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index fc42a5bd9..6f6be65ee 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -93,6 +93,7 @@ describe("cli-config schematic", () => { createIgPkgJson(); populatePkgJson(); spyOn(aiSkillsModule, "copyAISkillsToProject"); + spyOn(aiSkillsModule, "copyAgentInstructionFiles"); }); it("should set the template manager correctly", async () => { @@ -330,7 +331,7 @@ export const appConfig: ApplicationConfig = { it("should call copyAISkillsToProject", async () => { await runner.runSchematic("cli-config", {}, tree); - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(2); }); it("should add both servers to existing .vscode/mcp.json that has no servers", async () => { @@ -402,4 +403,45 @@ export const appConfig: ApplicationConfig = { expect(content.servers["igniteui-theming"]).toBeDefined(); }); }); + + describe("ai-config schematic", () => { + it("should call copyAISkillsToProject with claude and generic defaults when no options", async () => { + await runner.runSchematic("ai-config", {}, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(2); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".claude/skills"); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".agents/skills"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "generic"]); + }); + + 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"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["cursor"]); + }); + + it("should pass resolved skillsDir for copilot agent", async () => { + await runner.runSchematic("ai-config", { agent: ["copilot"] }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".github/skills"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["copilot"]); + }); + + it("should pass resolved skillsDir for generic agent", async () => { + await runner.runSchematic("ai-config", { agent: ["generic"] }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(".agents/skills"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["generic"]); + }); + + 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"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "cursor"]); + }); + }); }); 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", diff --git a/scripts/update-skills.ts b/scripts/update-skills.ts index ce12a7d35..ef2eaa085 100644 --- a/scripts/update-skills.ts +++ b/scripts/update-skills.ts @@ -16,19 +16,19 @@ const mappings = [ name: "angular", repo: join(root, "packages/igniteui-mcp/igniteui-doc-mcp/angular/igniteui-angular"), src: join(root, "packages/igniteui-mcp/igniteui-doc-mcp/angular/igniteui-angular/skills"), - dest: join(root, "packages/igx-templates/igx-ts/projects/_base/files/__dot__claude/skills") + dest: join(root, "packages/igx-templates/igx-ts/projects/ai-config/skills") }, { name: "react", repo: join(root, "packages/igniteui-mcp/igniteui-doc-mcp/react/igniteui-react"), src: join(root, "packages/igniteui-mcp/igniteui-doc-mcp/react/igniteui-react/skills"), - dest: join(root, "packages/cli/templates/react/igr-ts/projects/_base/files/__dot__claude/skills") + dest: join(root, "packages/cli/templates/react/igr-ts/projects/ai-config/skills") }, { name: "webcomponents", repo: join(root, "packages/igniteui-mcp/igniteui-doc-mcp/webcomponents/igniteui-webcomponents"), src: join(root, "packages/igniteui-mcp/igniteui-doc-mcp/webcomponents/igniteui-webcomponents/skills"), - dest: join(root, "packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__claude/skills") + dest: join(root, "packages/cli/templates/webcomponents/igc-ts/projects/ai-config/skills") } ]; diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index 9573ec178..246125de6 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -70,6 +70,10 @@ describe("Help command", () => { [boolean] --skip-install, --si Do not install packages after scaffolding [boolean] --template Project template [string] + -a, --agent AI agent(s) to configure skills for (determines the + target skills directory) + [array] [choices: "claude", "copilot", "cursor", "codex", "windsurf", + "gemini", "junie", "generic"] Examples: ig new my-app Scaffold a new project interactively diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index b3d0fcabf..23b4f98ea 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 @@ -179,6 +179,7 @@ describe("Unit - PromptSession", () => { Promise.resolve("jQuery"), Promise.resolve("infragistics") ); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); spyOn(process, "chdir"); spyOn(mockSession, "chooseActionLoop"); await mockSession.start(); @@ -283,6 +284,7 @@ describe("Unit - PromptSession", () => { spyOn(mockSession, "chooseActionLoop"); spyOn(process, "cwd").and.returnValue("Mock"); spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); await mockSession.start(); // prompt only for project name: @@ -368,6 +370,7 @@ describe("Unit - PromptSession", () => { Promise.resolve("jQuery"), Promise.resolve("infragistics") ); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); spyOn(process, "chdir"); spyOn(mockSession, "chooseActionLoop"); await mockSession.start(); @@ -501,8 +504,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 +583,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 +709,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", @@ -821,6 +824,7 @@ describe("Unit - PromptSession", () => { Promise.resolve("Default side navigation"), Promise.resolve("Custom") ); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); spyOn(mockSession, "chooseActionLoop").and.returnValue(Promise.resolve()); spyOn(process, "chdir"); await mockSession.start(); @@ -857,6 +861,7 @@ describe("Unit - PromptSession", () => { Promise.resolve("Angular"), Promise.resolve("Default side navigation"), Promise.resolve("Default")); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); spyOn(mockSession, "chooseActionLoop").and.returnValue(Promise.resolve()); spyOn(process, "chdir"); await mockSession.start(); diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index bb2eb84f0..249905842 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"; @@ -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,14 +257,39 @@ describe("Unit - ai-config command", () => { }); describe("handler", () => { - it("posts analytics and calls configure", 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"])); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); - expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("MCP servers configured")); + expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ + message: "Which AI tools do you want to generate configuration files for?" + })); 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("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 tools do you want to generate configuration files for?" + })); + 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, "checkbox"); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agent: ["cursor"] }); + + expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor" })); }); }); }); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index 2d8fb0a89..debcf306c 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_INSTRUCTION_FILES, AI_AGENT_SKILLS_DIRS, AIAgentTarget, App, Config, copyAgentInstructionFiles, copyAISkillsToProject, FS_TOKEN, FsFileSystem, getInstructionFilePath, getSkillsDir, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; function skillsDir(pkgName: string) { return `node_modules/${pkgName}/skills`; @@ -13,7 +13,10 @@ function mockTemplateManager(templatePaths: string[]) { const mockProject = { templatePaths }; const mockProjectLib = { projectIds: ["base"], - getProject: jasmine.createSpy("getProject").and.returnValue(mockProject) + getProject: jasmine.createSpy("getProject").and.callFake((id: string) => { + if (id === "ai-config") return mockProject; + return null; + }) }; const mockTm = jasmine.createSpyObj("TemplateManager", ["getFrameworkById"]); mockTm.getFrameworkById.and.returnValue({ projectLibraries: [mockProjectLib] }); @@ -89,7 +92,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 +128,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 +158,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 +191,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 +229,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 +263,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 +290,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 +315,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 +340,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 +362,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 +387,7 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); expect(destFs.writeFile).not.toHaveBeenCalled(); }); @@ -415,7 +418,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 +454,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 +492,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); @@ -500,7 +503,7 @@ describe("Unit - copyAISkillsToProject", () => { describe("Template fallback (no package skills found)", () => { const FAKE_TEMPLATE_PATH = "/fake/template"; - const FAKE_SKILLS_ROOT = path.join(FAKE_TEMPLATE_PATH, "__dot__claude/skills"); + const FAKE_SKILLS_ROOT = path.join(FAKE_TEMPLATE_PATH, "..", "skills"); it("should use angular template paths when framework is in config and no npm skills are found", () => { const skillFilePath = path.join(FAKE_SKILLS_ROOT, "angular.md"); @@ -524,7 +527,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 +558,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 +589,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 +604,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 +634,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 +666,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"); @@ -673,7 +676,7 @@ describe("Unit - copyAISkillsToProject", () => { // Simulates the schematics scenario: srcFs (FsFileSystem) reads from disk, // destFs (NgTreeFileSystem) writes into the virtual Tree. const ABS_TEMPLATE_PATH = path.resolve("/usr/lib/node_modules/fake-templates/base/files"); - const SKILLS_ROOT = path.join(ABS_TEMPLATE_PATH, "__dot__claude/skills"); + const SKILLS_ROOT = path.join(ABS_TEMPLATE_PATH, "..", "skills"); const skillFilePath = path.join(SKILLS_ROOT, "angular.md"); const content = "# Angular skills from template"; @@ -695,7 +698,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,9 +735,410 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(".claude/skills"); 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 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"); + 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(".claude/skills"); + + 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"); + }); +}); + +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()); + }); +}); + +describe("Unit - getInstructionFilePath", () => { + it("should return .claude/CLAUDE.md for 'claude'", () => { + expect(getInstructionFilePath("claude")).toBe(".claude/CLAUDE.md"); + }); + + it("should return .github/copilot-instructions.md for 'copilot'", () => { + expect(getInstructionFilePath("copilot")).toBe(".github/copilot-instructions.md"); + }); + + it("should return .cursor/rules/cursor.mdc for 'cursor'", () => { + expect(getInstructionFilePath("cursor")).toBe(".cursor/rules/cursor.mdc"); + }); + + it("should return .codex/instructions.md for 'codex'", () => { + expect(getInstructionFilePath("codex")).toBe(".codex/instructions.md"); + }); + + it("should return .windsurf/rules/guidelines.md for 'windsurf'", () => { + expect(getInstructionFilePath("windsurf")).toBe(".windsurf/rules/guidelines.md"); + }); + + it("should return .gemini/GEMINI.md for 'gemini'", () => { + expect(getInstructionFilePath("gemini")).toBe(".gemini/GEMINI.md"); + }); + + it("should return .junie/guidelines.md for 'junie'", () => { + expect(getInstructionFilePath("junie")).toBe(".junie/guidelines.md"); + }); + + it("should return AGENTS.md for 'generic'", () => { + expect(getInstructionFilePath("generic")).toBe("AGENTS.md"); + }); +}); + +describe("Unit - AI_AGENT_INSTRUCTION_FILES", () => { + it("should contain entries for all expected agents", () => { + const expected: AIAgentTarget[] = ["claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"]; + expect(Object.keys(AI_AGENT_INSTRUCTION_FILES).sort()).toEqual(expected.sort()); + }); +}); + +describe("Unit - copyAgentInstructionFiles", () => { + beforeEach(() => { + spyOn(Util, "log"); + spyOn(Util, "greenCheck").and.returnValue("✓"); + }); + + it("should copy AGENTS.md content to each agent's instruction file path", () => { + const agentsContent = "# AI Agent Instructions\nFollow these rules."; + const angularSkillsDir = skillsDir("igniteui-angular"); + const agentsPath = path.join(path.dirname(angularSkillsDir), "AGENTS.md"); + + spySrcFs({ + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.callFake((p: string) => + p === agentsPath + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(agentsContent) + }); + + 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); + + copyAgentInstructionFiles(["claude", "cursor"]); + + const cursorFrontmatter = "---\ncontext: true\npriority: high\nscope: project\n---\n"; + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/CLAUDE.md", agentsContent); + expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/rules/cursor.mdc", cursorFrontmatter + agentsContent); + }); + + it("should skip writing when instruction file already has same content", () => { + const agentsContent = "# AI Agent Instructions - same content"; + const angularSkillsDir = skillsDir("igniteui-angular"); + const agentsPath = path.join(path.dirname(angularSkillsDir), "AGENTS.md"); + const claudeDest = ".claude/CLAUDE.md"; + + spySrcFs({ + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.callFake((p: string) => + p === agentsPath + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === agentsPath) return agentsContent; + return ""; + }) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => + p === claudeDest + ), + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { + if (p === claudeDest) return agentsContent; + return "{}"; + }) + }); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + copyAgentInstructionFiles(["claude"]); + + expect(destFs.writeFile).not.toHaveBeenCalled(); + }); + + it("should not write anything when AGENTS.md source is not found", () => { + spySrcFs({ + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.returnValue(false) + }); + + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + + copyAgentInstructionFiles(["claude", "generic"]); + + expect(destFs.writeFile).not.toHaveBeenCalled(); + }); });