diff --git a/README.md b/README.md index 2c065d170..7928ecb3c 100644 --- a/README.md +++ b/README.md @@ -145,13 +145,21 @@ ig start ## Configure AI Tooling -To automatically configure Ignite UI AI tooling - MCP servers and AI coding skills, run: +To configure Ignite UI AI tooling — MCP servers and AI coding skills — run: ```bash ig ai-config ``` -This creates or updates `.vscode/mcp.json` in the current project with entries for both the [Ignite UI MCP](#mcp-server) and `igniteui-theming` MCP servers. Existing servers in the file are preserved. It also copies any AI coding skill files from installed Ignite UI packages into the project. New projects are created with AI tooling configuration out of the box. +You will be prompted to select which AI tools to configure (Claude and Generic are selected by default). You can also pass agents directly: + +```bash +ig ai-config --agent claude copilot generic +``` + +This creates or updates `.vscode/mcp.json` with entries for the [Ignite UI MCP](#mcp-server) and `igniteui-theming` MCP servers (existing servers are preserved), copies AI coding skill files from installed Ignite UI packages, and generates agent-specific instruction files (e.g. `CLAUDE.md`, `AGENTS.md`). + +The `ig new` command also prompts for AI tool configuration as part of project creation. ## MCP Server @@ -170,14 +178,18 @@ ig mcp --debug # Enable debug logging to mcp-server.log ### Using with AI Assistants -For VS Code, the `ig ai-config` command handles configuration automatically (see above). For other MCP clients (e.g., Claude Desktop, Cursor), configure them manually to use the CLI as the MCP server: +For VS Code, the `ig ai-config` command handles configuration automatically (see above). For other MCP clients (e.g., Claude Desktop, Cursor), configure them manually: ```json { "mcpServers": { "igniteui-cli": { - "command": "ig", - "args": ["mcp"] + "command": "npx", + "args": ["-y", "igniteui-cli", "mcp"] + }, + "igniteui-theming": { + "command": "npx", + "args": ["-y", "igniteui-theming", "igniteui-theming-mcp"] } } } diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index 076b23504..19b948ee9 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -4,7 +4,7 @@ import { } from "@igniteui/cli-core"; import * as path from "path"; import { default as add } from "./commands/add"; -import { configure as aiConfigure } from "./commands/ai-config"; +import { configure, configureMCP, promptForAgents } from "./commands/ai-config"; import { default as start } from "./commands/start"; import { default as upgrade } from "./commands/upgrade"; import { TemplateManager } from "./TemplateManager"; @@ -76,6 +76,8 @@ export class PromptSession extends BasePromptSession { // project options: theme = await this.getTheme(projLibrary); + const agents = await promptForAgents(); + Util.log(" Generating project structure."); const config = projTemplate.generateConfig(projectName, theme); for (const templatePath of projTemplate.templatePaths) { @@ -89,6 +91,10 @@ export class PromptSession extends BasePromptSession { } // move cwd to project folder process.chdir(projectName); + + if (agents?.length) { + await configure(agents); + } } await this.chooseActionLoop(projLibrary); //TODO: restore cwd? @@ -106,7 +112,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..c68d97c2b 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, 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(agents: AIAgentTarget[]): void { + const result = copyAISkillsToProject(agents); if (result.found === 0) { Util.warn("No AI skill files found. Make sure packages are installed (npm install) " + "and your Ignite UI packages are up-to-date.", "yellow"); @@ -26,18 +26,61 @@ export function configureSkills(): void { } } -export function configure(skills = true): void { +export async function configure(agents?: AIAgentTarget[], skills = true): Promise { + if (!agents?.length) { + agents = await promptForAgents(); + } + if (!agents.length) return; configureMCP(); if (skills) { - configureSkills(); + configureSkills(agents); } + 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" + })) +]; + +export async function promptForAgents(): Promise { + const selected = await InquirerWrapper.checkbox({ + message: "Which AI tools do you want to generate configuration files for?", + required: true, + choices: AI_AGENT_CHECKBOX_CHOICES + }); + return selected.includes("none") ? [] : selected as AIAgentTarget[]; } 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) { + let agents = argv.agent as AIAgentTarget[] | undefined; + + if (!agents?.length) { + agents = await promptForAgents(); + } + + if (!agents.length) { + Util.log("No AI configuration selected. Skipping."); + return; + } + GoogleAnalytics.post({ t: "screenview", cd: "MCP" @@ -46,10 +89,10 @@ const command: CommandModule = { GoogleAnalytics.post({ t: "event", ec: "$ig ai-config", - ea: "client: vscode" + ea: `agent: ${agents.join(", ")}` }); - configure(); + await configure(agents); } }; diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index 1c1bc309a..949523d5e 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"); }, @@ -152,16 +161,20 @@ const command: NewCommandType = { Util.log(Util.greenCheck() + " Project Created"); - if (!argv["skip-git"] && !ProjectConfig.getConfig().skipGit) { - Util.gitInit(process.cwd(), argv.name); - } - if (!argv.skipInstall) { process.chdir(argv.name); await PackageManager.installPackages(); process.chdir(".."); } + process.chdir(argv.name); + await configure(argv.agent as AIAgentTarget[] | undefined); + process.chdir(".."); + + if (!argv["skip-git"] && !ProjectConfig.getConfig().skipGit) { + Util.gitInit(process.cwd(), argv.name); + } + 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/files/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/files/AGENTS.md 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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/skills/igniteui-react-optimize-bundle-size/SKILL.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..6fb85e96d --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/ai-config/index.ts @@ -0,0 +1,38 @@ +import { ControlExtraConfiguration, defaultDelimiters, ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; + +// currently reusing hidden project impl as components/views pipeline go through registerInProject +// ideally would define a separate type/category for those partial files +export class IgrTsAiConfigPartial implements ProjectTemplate { + public id: string = "ai-config"; + public name = "ai-config"; + public description = "Ignite UI CLI AI config for React partial project files"; + 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 { /* partials not using Util.processTemplates atm */ }; + } + + 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."); + } +} +export default new IgrTsAiConfigPartial(); 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/files/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/files/AGENTS.md 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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/skills/igniteui-wc-optimize-bundle-size/SKILL.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..278c1e90c --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/ai-config/index.ts @@ -0,0 +1,40 @@ +import { ControlExtraConfiguration, defaultDelimiters, ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; + +// currently reusing hidden project impl as components/views pipeline go through registerInProject +// ideally would define a separate type/category for those partial files +export class IgcTsAiConfigPartial implements ProjectTemplate { + + public id: string = "ai-config"; + public name = "ai-config"; + public description = "Ignite UI CLI AI config for Web Components partial project files"; + public framework: string = "webcomponents"; + public projectType: string = "igc-ts"; + public dependencies: string[]; + public hasExtraConfiguration: boolean = false; + public isHidden: boolean = true; + public delimiters = defaultDelimiters; + + 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."); + } + getExtraConfiguration(): ControlExtraConfiguration[] { + throw new Error("Method not implemented."); + } + setExtraConfiguration(_extraConfigKeys: {}) { + throw new Error("Method not implemented."); + } +} +export default new IgcTsAiConfigPartial(); + + diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index 18ebe2678..d0f33279e 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -8,14 +8,18 @@ type InputConfig = { required?: boolean; type?: string; name?: string; - choices?: (string | Separator)[]; + choices?: (string | Separator)[] | ({ value: string; name?: string; checked?: boolean } | Separator)[]; transformer?: (value: string, { isFinal }: { isFinal: boolean; }) => string; // TODO: consider typing these by extracting the types from the inquirer package validate?: any; - theme?: unknown; + theme?: any; +}; + +type InputChoicesConfig = InputConfig & { + choices: (string | Separator)[] | ({ value: string; name?: string; checked?: boolean } | Separator)[]; }; export class InquirerWrapper { @@ -25,11 +29,11 @@ export class InquirerWrapper { return input(message, context); } - public static async select(message: InputConfig & { choices: (string | Separator)[] }, context?: Context): Promise { + public static async select(message: InputChoicesConfig, context?: Context): Promise { return select(message, context); } - public static async checkbox(message: InputConfig & { choices: (string | Separator)[] }, context?: Context): Promise { + public static async checkbox(message: InputChoicesConfig, context?: Context): Promise { return checkbox(message, context); } diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 70625f4f8..0803680e5 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 = { + generic: ".agents/skills", + claude: ".claude/skills", + copilot: ".github/skills", + cursor: ".cursor/skills", + codex: ".codex/skills", + windsurf: ".windsurf/skills", + gemini: ".gemini/skills", + junie: ".junie/skills" +}; + +export const AI_AGENT_INSTRUCTION_FILES: Record = { + generic: "AGENTS.md", + 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" +}; + +export const AI_AGENT_LABELS: Record = { + generic: "Generic (Adding .agents/skills and AGENTS.md)", + 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)" +}; + +/** + * Returns the project-level skills directory for the given AI agent target. + */ +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; @@ -18,9 +64,24 @@ export interface AISkillsCopyResult { failed: number; } +export const AGENTS_TEMPLATE_FILE = "AGENTS.md"; + +/** + * Returns the `files/` directory of the ai-config project template for the + * detected framework. This is where AGENTS.md (and the bundled skills/) live + * when no npm package is installed. + */ +function resolveTemplateFilesDir(framework: string): string | null { + const templateManager = App.container.get(TEMPLATE_MANAGER); + const projectLib = templateManager?.getFrameworkById(framework)?.projectLibraries[0]; + const templatePaths = projectLib?.getProject("ai-config")?.templatePaths ?? []; + return templatePaths[0] ?? null; +} + /** * Returns the list of 'skills/' directory paths found in installed * Ignite UI packages that are relevant to the project's detected framework. + * Falls back to the bundled template skills when no npm package is installed. */ function resolveSkillsRoots(): string[] { const fs = App.container.get(FS_TOKEN); @@ -58,14 +119,10 @@ function resolveSkillsRoots(): string[] { // if no root discovered, take the root from the appropriate project template files: framework ??= detectFrameworkFromPackageJson(); 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 filesDir = resolveTemplateFilesDir(framework); + if (filesDir) { + roots.push(path.join(filesDir, "skills")); + } } } @@ -73,9 +130,11 @@ 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 + * skills directories for each of the given AI agents. + * @param agents – list of AI agent targets to copy skills for */ -export function copyAISkillsToProject(): AISkillsCopyResult { +export function copyAISkillsToProject(agents: AIAgentTarget[]): AISkillsCopyResult { 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): @@ -90,40 +149,109 @@ export function copyAISkillsToProject(): AISkillsCopyResult { const multiRoot = skillsRoots.length > 1; - for (const skillsRoot of skillsRoots) { - const rawPaths = srcFs.glob(skillsRoot, "**/*"); - const pkgDirName = multiRoot ? path.basename(path.dirname(skillsRoot)) : ""; - - for (const p of rawPaths) { - result.found++; - // Normalize to posix and strip leading '/' so path.posix.relative works - // across both FsFileSystem (relative paths) and NgTreeFileSystem (tree-rooted paths) - const normP = p.replace(/\\/g, "/").replace(/^\//, ""); - 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}`; - - const newContent = srcFs.readFile(p); - try { - if (destFs.fileExists(dest)) { - const existingContent = destFs.readFile(dest); - if (existingContent === newContent) { - result.skipped++; - continue; + for (const agent of agents) { + const outputDir = getSkillsDir(agent); + + for (const skillsRoot of skillsRoots) { + const rawPaths = srcFs.glob(skillsRoot, "**/*"); + const pkgDirName = multiRoot ? path.basename(path.dirname(skillsRoot)) : ""; + + for (const p of rawPaths) { + result.found++; + // Normalize to posix and strip leading '/' so path.posix.relative works + // across both FsFileSystem (relative paths) and NgTreeFileSystem (tree-rooted paths) + const normP = p.replace(/\\/g, "/").replace(/^\//, ""); + const normRoot = skillsRoot.replace(/\\/g, "/").replace(/^\//, ""); + const rel = path.posix.relative(normRoot, normP); + const dest = multiRoot + ? `${outputDir}/${pkgDirName}/${rel}` + : `${outputDir}/${rel}`; + + const newContent = srcFs.readFile(p); + try { + if (destFs.fileExists(dest)) { + const existingContent = destFs.readFile(dest); + if (existingContent === newContent) { + result.skipped++; + continue; + } + destFs.writeFile(dest, newContent); + Util.log(`${Util.greenCheck()} Updated ${dest}`); + } else { + destFs.writeFile(dest, newContent); + Util.log(`${Util.greenCheck()} Created ${dest}`); } - destFs.writeFile(dest, newContent); - Util.log(`${Util.greenCheck()} Updated ${dest}`); - } else { - destFs.writeFile(dest, newContent); - Util.log(`${Util.greenCheck()} Created ${dest}`); + } catch { + result.failed++; } - } catch { - result.failed++; } } } return result; } + +/** + * Resolves the AGENTS.md source file content from the bundled project template files. + * AGENTS.md lives only in the template files/ directory, not in npm packages. + */ +function resolveAgentsContent(): string | null { + let framework: string | null = null; + try { + if (ProjectConfig.hasLocalConfig()) { + framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() ?? null; + } + } catch { /* fall through */ } + framework ??= detectFrameworkFromPackageJson(); + + if (!framework) { + return null; + } + + const filesDir = resolveTemplateFilesDir(framework); + if (!filesDir) { + return null; + } + + try { + return new FsFileSystem().readFile(path.join(filesDir, AGENTS_TEMPLATE_FILE)); + } catch { + 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/files/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/files/AGENTS.md 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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/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/files/skills/igniteui-angular-theming/references/mcp-setup.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..07cff3e58 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/ai-config/index.ts @@ -0,0 +1,46 @@ +import { ControlExtraConfiguration, ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; + +// currently reusing hidden project impl as components/views pipeline go through registerInProject +// ideally would define a separate type/category for those partial files +export class IgxAiConfigPartial implements ProjectTemplate { + public id: string = "ai-config"; + public name = "ai-config"; + public description = "Ignite UI CLI AI config for Angular partial project files"; + 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 { + throw new Error("Method not implemented."); + } + public getExtraConfiguration(): ControlExtraConfiguration[] { + throw new Error("Method not implemented."); + } + public setExtraConfiguration(_extraConfigKeys: any[]) { + throw new Error("Method not implemented."); + } + public generateConfig(_name: string, _theme: string, ..._options: any[]): { [key: string]: any } { + return { /* partials not using Util.processTemplates atm */ }; + } +} + +export default new IgxAiConfigPartial(); 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..fdcb1a7c9 --- /dev/null +++ b/packages/ng-schematics/src/cli-config/ai-config-schema.json @@ -0,0 +1,35 @@ +{ + "$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", + "default": ["claude", "generic"], + "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": "generic", "label": "Generic (Adding .agents/skills and AGENTS.md)" }, + { "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)" } + ] + } + } + } +} diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 35bdb322b..f8756ab67 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -1,7 +1,8 @@ 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 { RunSchematicTask } from "@angular-devkit/schematics/tasks"; +import { addClassToBody, addMcpServers, AIAgentTarget, App, copyAgentInstructionFiles, copyAISkillsToProject, FormatSettings, 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 +127,13 @@ 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(); + copyAISkillsToProject(agents); + copyAgentInstructionFiles(agents); const angularCliServer: Record = { "angular-cli": { @@ -145,20 +147,27 @@ 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 { - return (tree: Tree) => { + return (tree: Tree, context: SchematicContext) => { appInit(tree); + // queue ai-config with prompts: + context.addTask(new RunSchematicTask("ai-config", {})); + return chain([ importStyles(), addTypographyToProj(), importBrowserAnimations(), createCliConfig(), displayVersionMismatch(), - aiConfig({ init: false }) ]); }; } diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index fc42a5bd9..4bedc7a56 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 () => { @@ -316,90 +317,120 @@ export const appConfig: ApplicationConfig = { expect(warns).toContain(jasmine.stringMatching(pattern)); }); - describe("addAIConfig", () => { - const mcpFilePath = "/.vscode/mcp.json"; + it("should schedule the ai-config schematic task", async () => { + await runner.runSchematic("cli-config", {}, tree); - it("should create .vscode/mcp.json with both servers when file does not exist", async () => { - await runner.runSchematic("cli-config", {}, tree); + const taskOptions = runner.tasks.map(task => task.options); + expect(taskOptions).toContain(jasmine.objectContaining({ name: "ai-config" })); + }); - expect(tree.exists(mcpFilePath)).toBeTruthy(); - const content = JSON.parse(tree.readContent(mcpFilePath)); - expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); - expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] }); - }); - it("should call copyAISkillsToProject", async () => { - await runner.runSchematic("cli-config", {}, tree); + describe("ai-config schematic", () => { + const mcpFilePath = "/.vscode/mcp.json"; + + it("should call copyAISkillsToProject with claude and generic defaults when no options", async () => { + await runner.runSchematic("ai-config", {}, tree); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "generic"]); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "generic"]); }); - it("should add both servers to existing .vscode/mcp.json that has no servers", async () => { - tree.create(mcpFilePath, JSON.stringify({ servers: {} })); - - await runner.runSchematic("cli-config", {}, tree); + it("should create .vscode/mcp.json with igniteui and angular-cli servers when file does not exist", async () => { + await runner.runSchematic("ai-config", {}, tree); + expect(tree.exists(mcpFilePath)).toBeTruthy(); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] }); + expect(content.servers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] }); }); - it("should add missing igniteui-theming server if only igniteui is already present", async () => { - tree.create(mcpFilePath, JSON.stringify({ - servers: { - "igniteui-cli": { command: "npx", args: ["-y", "igniteui-cli", "mcp"] } - } - })); + it("should add all three servers to existing .vscode/mcp.json that has no servers", async () => { + tree.create(mcpFilePath, JSON.stringify({ servers: {} })); - await runner.runSchematic("cli-config", {}, tree); + await runner.runSchematic("ai-config", {}, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] }); + expect(content.servers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] }); }); - it("should add missing igniteui server if only igniteui-theming is already present", async () => { + it("should add missing servers when only some are already present", async () => { tree.create(mcpFilePath, JSON.stringify({ servers: { - "igniteui-theming": { command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] } + "igniteui-cli": { command: "npx", args: ["-y", "igniteui-cli", "mcp"] } } })); - await runner.runSchematic("cli-config", {}, tree); + await runner.runSchematic("ai-config", {}, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] }); + expect(content.servers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] }); }); - it("should not modify .vscode/mcp.json if both servers are already present", async () => { + it("should not modify .vscode/mcp.json if all servers are already present", async () => { const existing = { servers: { - "angular-cli": { command: "npx", args: ["-y", "@angular/cli", "mcp"] }, "igniteui-cli": { command: "npx", args: ["-y", "igniteui-cli", "mcp"] }, - "igniteui-theming": { command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] } + "igniteui-theming": { command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] }, + "angular-cli": { command: "npx", args: ["-y", "@angular/cli", "mcp"] } } }; tree.create(mcpFilePath, JSON.stringify(existing)); - await runner.runSchematic("cli-config", {}, tree); + await runner.runSchematic("ai-config", {}, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content).toEqual(existing); }); - it("should preserve existing servers when adding igniteui servers", async () => { + it("should preserve existing servers when adding new ones", async () => { tree.create(mcpFilePath, JSON.stringify({ servers: { "other-server": { command: "node", args: ["server.js"] } } })); - await runner.runSchematic("cli-config", {}, tree); + await runner.runSchematic("ai-config", {}, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content.servers["other-server"]).toEqual({ command: "node", args: ["server.js"] }); expect(content.servers["igniteui-cli"]).toBeDefined(); expect(content.servers["igniteui-theming"]).toBeDefined(); + expect(content.servers["angular-cli"]).toBeDefined(); + }); + + it("should pass agents when agent option is provided", async () => { + await runner.runSchematic("ai-config", { agent: ["cursor"] }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["cursor"]); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["cursor"]); + }); + + it("should pass agents for copilot agent", async () => { + await runner.runSchematic("ai-config", { agent: ["copilot"] }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["copilot"]); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["copilot"]); + }); + + it("should pass agents for generic agent", async () => { + await runner.runSchematic("ai-config", { agent: ["generic"] }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["generic"]); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["generic"]); + }); + + it("should configure multiple agents", async () => { + await runner.runSchematic("ai-config", { agent: ["claude", "cursor"] }, tree); + + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "cursor"]); + 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..d89db0556 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/files/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/files/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/files/skills") } ]; diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index 9573ec178..c274387df 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: "generic", "claude", "copilot", "cursor", "codex", + "windsurf", "gemini", "junie"] Examples: ig new my-app Scaffold a new project interactively diff --git a/spec/acceptance/new-spec.ts b/spec/acceptance/new-spec.ts index 5df6c033a..1fae2c71b 100644 --- a/spec/acceptance/new-spec.ts +++ b/spec/acceptance/new-spec.ts @@ -1,4 +1,4 @@ -import { GoogleAnalytics, GoogleAnalyticsParameters, PackageManager, Util } from "@igniteui/cli-core"; +import { GoogleAnalytics, GoogleAnalyticsParameters, InquirerWrapper, PackageManager, Util } from "@igniteui/cli-core"; import * as fs from "fs"; import * as cli from "../../packages/cli/lib/cli"; import { deleteAll, filesDiff, resetSpy } from "../helpers/utils"; @@ -11,6 +11,7 @@ describe("New command", () => { spyOn(console, "error"); spyOn(GoogleAnalytics, "post"); spyOn(PackageManager, "installPackages"); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); process.chdir("./output"); }); diff --git a/spec/templates/angular-spec.ts b/spec/templates/angular-spec.ts index 38fa1dbcd..2d1187708 100644 --- a/spec/templates/angular-spec.ts +++ b/spec/templates/angular-spec.ts @@ -64,4 +64,26 @@ describe("Angular templates", () => { } } }); + + describe("ai-config template file presence", () => { + it("ai-config project template must be registered", () => { + const angularFramework: Framework = require(templatesLocation); + const projLibrary = angularFramework.projectLibraries.find(x => x.projectType === "igx-ts"); + expect(projLibrary.getProject("ai-config")).toBeDefined(); + }); + + const filesDir = path.resolve(__dirname, "../..", "packages/igx-templates/igx-ts/projects/ai-config/files"); + + it("AGENTS.md must exist in files/", () => { + expect(fs.existsSync(path.join(filesDir, "AGENTS.md"))) + .withContext(`Missing AGENTS.md in ${filesDir}`) + .toBeTrue(); + }); + + it("skills/ directory must exist in files/", () => { + expect(fs.existsSync(path.join(filesDir, "skills"))) + .withContext(`Missing skills/ in ${filesDir}`) + .toBeTrue(); + }); + }); }); diff --git a/spec/templates/react-spec.ts b/spec/templates/react-spec.ts index f682d4092..f7ba1ba45 100644 --- a/spec/templates/react-spec.ts +++ b/spec/templates/react-spec.ts @@ -1,4 +1,6 @@ import { App, Framework, Util } from "@igniteui/cli-core"; +import path from "path"; +import * as fs from "fs"; const templatesLocation = "../../packages/cli/templates/react"; describe("React templates", () => { @@ -31,4 +33,26 @@ describe("React templates", () => { } } }); + + describe("ai-config template file presence", () => { + it("ai-config project template must be registered", () => { + const reactFramework: Framework = require(templatesLocation); + const projLibrary = reactFramework.projectLibraries.find(x => x.projectType === "igr-ts"); + expect(projLibrary.getProject("ai-config")).toBeDefined(); + }); + + const filesDir = path.resolve(__dirname, "../..", "packages/cli/templates/react/igr-ts/projects/ai-config/files"); + + it("AGENTS.md must exist in files/", () => { + expect(fs.existsSync(path.join(filesDir, "AGENTS.md"))) + .withContext(`Missing AGENTS.md in ${filesDir}`) + .toBeTrue(); + }); + + it("skills/ directory must exist in files/", () => { + expect(fs.existsSync(path.join(filesDir, "skills"))) + .withContext(`Missing skills/ in ${filesDir}`) + .toBeTrue(); + }); + }); }); diff --git a/spec/templates/webcomponents-spec.ts b/spec/templates/webcomponents-spec.ts index 60816bd0a..d57f6b6bf 100644 --- a/spec/templates/webcomponents-spec.ts +++ b/spec/templates/webcomponents-spec.ts @@ -1,4 +1,6 @@ import { App, Framework, Util } from "@igniteui/cli-core"; +import path from "path"; +import * as fs from "fs"; const templatesLocation = "../../packages/cli/templates/webcomponents"; describe("Web Components templates", () => { @@ -35,4 +37,26 @@ describe("Web Components templates", () => { } } }); + + describe("ai-config template file presence", () => { + it("ai-config project template must be registered", () => { + const wcFramework: Framework = require(templatesLocation); + const projLibrary = wcFramework.projectLibraries.find(x => x.projectType === "igc-ts"); + expect(projLibrary.getProject("ai-config")).toBeDefined(); + }); + + const filesDir = path.resolve(__dirname, "../..", "packages/cli/templates/webcomponents/igc-ts/projects/ai-config/files"); + + it("AGENTS.md must exist in files/", () => { + expect(fs.existsSync(path.join(filesDir, "AGENTS.md"))) + .withContext(`Missing AGENTS.md in ${filesDir}`) + .toBeTrue(); + }); + + it("skills/ directory must exist in files/", () => { + expect(fs.existsSync(path.join(filesDir, "skills"))) + .withContext(`Missing skills/ in ${filesDir}`) + .toBeTrue(); + }); + }); }); 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..69d5f4fae 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"]); 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"]); 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"]); 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"]); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("1 AI skill file(s) created or updated")); expect(Util.warn).not.toHaveBeenCalled(); @@ -257,14 +257,55 @@ describe("Unit - ai-config command", () => { }); describe("handler", () => { - it("posts analytics and calls configure", async () => { + beforeEach(() => { + (GoogleAnalytics.post as jasmine.Spy).calls.reset(); + }); + + 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?", + required: true + })); 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("logs skipping and does not post analytics when none is selected", async () => { + App.container.set(FS_TOKEN, createMockFs()); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("Skipping")); + expect(GoogleAnalytics.post).not.toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview" })); + expect(GoogleAnalytics.post).not.toHaveBeenCalledWith(jasmine.objectContaining({ t: "event" })); + }); + + 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..1c5060cef 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, 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); 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"]); // Source reads go to real FsFileSystem (srcFs) expect(srcSpies.glob).toHaveBeenCalledWith(SKILLS_ROOT, "**/*"); @@ -732,9 +735,255 @@ describe("Unit - copyAISkillsToProject", () => { project: { framework: "angular" } } as unknown as Config); - copyAISkillsToProject(); + copyAISkillsToProject(["claude"]); 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(["cursor"]); + + expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); + }); + + it("should copy skills to .agents/skills/ when targeting generic agent", () => { + 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(["generic"]); + + expect(destFs.writeFile).toHaveBeenCalledWith(".agents/skills/angular.md", content); + }); + + it("should copy skills to .github/skills/ when targeting copilot agent", () => { + 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(["copilot"]); + + expect(destFs.writeFile).toHaveBeenCalledWith(".github/skills/overview.md", content); + }); + + it("should copy skills to multiple agent directories when given multiple agents", () => { + 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); + + const result = copyAISkillsToProject(["claude", "cursor", "generic"]); + + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".agents/skills/angular.md", content); + expect(destFs.writeFile).toHaveBeenCalledTimes(3); + expect(result.found).toBe(3); + }); + }); }); + +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 FAKE_FILES_DIR = "/fake/template/files"; + + spySrcFs({ + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(agentsContent) + }); + + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + mockTemplateManager([FAKE_FILES_DIR]); + + 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 FAKE_FILES_DIR = "/fake/template/files"; + const claudeDest = ".claude/CLAUDE.md"; + + spySrcFs({ + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(agentsContent) + }); + + const destFs = makeDestFs({ + 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); + mockTemplateManager([FAKE_FILES_DIR]); + + copyAgentInstructionFiles(["claude"]); + + expect(destFs.writeFile).not.toHaveBeenCalled(); + }); + + it("should not write anything when AGENTS.md source is not found", () => { + spySrcFs({ + readFile: spyOn(FsFileSystem.prototype, "readFile").and.throwError("ENOENT") + }); + + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + mockTemplateManager(["/fake/files"]); + + copyAgentInstructionFiles(["claude", "generic"]); + + expect(destFs.writeFile).not.toHaveBeenCalled(); + }); +}); + diff --git a/spec/unit/new-spec.ts b/spec/unit/new-spec.ts index 0c9efc7cf..224f5c6dd 100644 --- a/spec/unit/new-spec.ts +++ b/spec/unit/new-spec.ts @@ -1,4 +1,4 @@ -import { App, BaseTemplate, Config, GoogleAnalytics, PackageManager, ProjectConfig, ProjectTemplate, Util } from "@igniteui/cli-core"; +import { App, BaseTemplate, Config, GoogleAnalytics, InquirerWrapper, PackageManager, ProjectConfig, ProjectTemplate, Util } from "@igniteui/cli-core"; import * as path from "path"; import { default as newCmd } from "../../packages/cli/lib/commands/new"; import { PromptSession } from "../../packages/cli/lib/PromptSession"; @@ -35,6 +35,8 @@ describe("Unit - New command", () => { spyOn(Util, "execSync"); spyOn(process, "chdir"); spyOn(PackageManager, "installPackages"); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); + spyOn(Util, "directoryExists").and.returnValue(false); }); afterEach(() => { @@ -196,7 +198,6 @@ describe("Unit - New command", () => { spyOn(process, "cwd").and.returnValue("Mock dir"); spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); - spyOn(Util, "directoryExists").and.returnValue(false); spyOn(Util, "fileExists").and.returnValue(false); const mockFileSystem = { @@ -242,7 +243,6 @@ describe("Unit - New command", () => { spyOn(process, "cwd").and.returnValue("Mock dir"); spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); - spyOn(Util, "directoryExists").and.returnValue(false); spyOn(Util, "fileExists").and.returnValue(false); const mockFileSystem = { @@ -317,7 +317,6 @@ describe("Unit - New command", () => { }); spyOn(Util, "gitInit"); - spyOn(Util, "directoryExists").and.returnValue(false); await newCmd.handler({ "name": projectName, "framework": "jq", "skip-git": true, _: ["new"], $0: "new" }); @@ -376,6 +375,5 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "title", framework: "jq", skipInstall: true, _: ["new"], $0: "new" }); expect(PackageManager.installPackages).not.toHaveBeenCalled(); - expect(process.chdir).not.toHaveBeenCalled(); }); });