diff --git a/.vscode/launch.json b/.vscode/launch.json index e3596beae..9be8ccd85 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -80,12 +80,11 @@ "program": "${workspaceRoot}/packages/cli/bin/execute.js", "console": "externalTerminal", "preLaunchTask": "build", - "outFiles": [ "${workspaceRoot}/lib/**/*.js", - "${workspaceRoot}/spec/**/*.js" ], "args": [ "new", "angularproj", - "--framework=angular" + "--framework=angular", + "--skip-install" ] }, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 149d76ccd..b2ae7c551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 15.1.0 + +## What's Changed +* feat(ai-config): The ai-config command now enables users to select and configure an AI coding assistant integration for their Ignite UI project. Based on the selected provider, the command adds the required skills, instruction files, and MCP configuration to streamline AI-assisted development workflows. The `new` command now accepts `--agents` (for adding skills and instruction files) and `--assistants`(for adding mcp configuration) options, allowing users to configure AI integration directly during project creation. When using the step-by-step wizard, users are automatically prompted to select their preferred AI agents and coding assistants after the project structure is generated. The Angular schematics package includes a dedicated `ai-config` schematic that runs automatically during `ng new` with Ignite UI and can also be invoked standalone via `ng generate @igniteui/angular-schematics:ai-config` to add AI configuration to existing Angular projects. by @Marina-L-Stoyanova https://github.com/IgniteUI/igniteui-cli/pull/1684 + # 15.0.1 (2026-04-28) ## What's Changed diff --git a/README.md b/README.md index eb747c84f..689cc9e82 100644 --- a/README.md +++ b/README.md @@ -151,13 +151,17 @@ To configure Ignite UI AI tooling — MCP servers and AI coding skills — run: ig ai-config ``` -You will be prompted to select which AI tools to configure (Claude and Generic are selected by default). You can also pass agents directly: +You will be prompted with two selections: +- **AI agents** — which tools to generate skill and instruction files for (Generic and Claude are selected by default) +- **Coding assistants** — which assistants to configure MCP servers for (general `.mcp.json` is selected by default, compatible with Claude Code, VS Code, and others) + +You can also pass options directly: ```bash -ig ai-config --agents claude copilot generic +ig ai-config --agents claude copilot generic --assistants vscode cursor ``` -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`). +This creates or updates the assistant-specific MCP config file (e.g. `.mcp.json`, `.vscode/mcp.json`, `.cursor/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. diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 20e697d39..496dd825a 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -1,14 +1,17 @@ -import { addMcpServers, AI_AGENT_LABELS, AI_AGENT_CHOICES, AIAgentTarget, copyAgentInstructionFiles, copyAISkillsToProject, GoogleAnalytics, InquirerWrapper, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; +import { addMcpServers, AI_AGENT_LABELS, AI_AGENT_CHOICES, AIAgentTarget, copyAgentInstructionFiles, copyAISkillsToProject, GoogleAnalytics, InquirerWrapper, Util, AiCodingAssistant, AI_ASSISTANT_MCP_CONFIGS, AI_ASSISTANT_CHOICES, AI_ASSISTANT_LABELS } from "@igniteui/cli-core"; import { ArgumentsCamelCase, CommandModule } from "yargs"; -export function configureMCP(): void { - const modified = addMcpServers(VS_CODE_MCP_PATH); +export function configureMCP(assistants: AiCodingAssistant[]): void { + for (const assistant of assistants) { + const { mcpFilePath } = AI_ASSISTANT_MCP_CONFIGS[assistant]; + const modified = addMcpServers(assistant); - if (!modified) { - Util.log(` Ignite UI MCP servers already configured in ${VS_CODE_MCP_PATH}`); - return; + if (!modified) { + Util.log(` Ignite UI MCP servers already configured in ${mcpFilePath}`); + } else { + Util.log(Util.greenCheck() + ` MCP servers configured in ${mcpFilePath}`); + } } - Util.log(Util.greenCheck() + ` MCP servers configured in ${VS_CODE_MCP_PATH}`); } export function configureSkills(agents: AIAgentTarget[]): void { @@ -26,20 +29,43 @@ export function configureSkills(agents: AIAgentTarget[]): void { } } -export async function configure(agents?: AIAgentTarget[], skills = true): Promise { - if (!agents?.length) { +type AIAgentOption = AIAgentTarget | "none"; +type AIAssistantOption = AiCodingAssistant | "none"; + +export async function configure(agents: AIAgentOption[] = [], assistants: AIAssistantOption[] = [], skills = true): Promise<{ agents: AIAgentTarget[], assistants: AiCodingAssistant[] }> { + if (!agents.length) { agents = await promptForAgents(); } - if (!agents.length) return; - configureMCP(); - if (skills) { - configureSkills(agents); + + if (!assistants.length) { + assistants = await promptForAssistant(); + } + + const resolvedAgents: AIAgentTarget[] = agents.includes("none") ? [] : agents as AIAgentTarget[]; + const resolvedAssistants: AiCodingAssistant[] = assistants.includes("none") ? [] : assistants as AiCodingAssistant[]; + + if (!resolvedAssistants.length) { + Util.log("No MCP configuration selected. Skipping."); } - copyAgentInstructionFiles(agents); + configureMCP(resolvedAssistants); + + if (!resolvedAgents.length) { + Util.log("No AI configuration selected. Skipping."); + } else { + if (skills) { + configureSkills(resolvedAgents); + } + copyAgentInstructionFiles(resolvedAgents); + } + + return { agents: resolvedAgents, assistants: resolvedAssistants }; } + const AI_AGENT_CHECKBOX_DEFAULTS: AIAgentTarget[] = ["generic", "claude"]; +const AI_ASSISTANT_CHECKBOX_DEFAULTS: AiCodingAssistant[] = ["generic"]; + const AI_AGENT_CHECKBOX_CHOICES = [ - { value: "none", name: "None (skip AI configuration)" }, + { value: "none", name: "None (skip skills and instructions)" }, ...AI_AGENT_CHOICES.map(agent => ({ value: agent, name: AI_AGENT_LABELS[agent], @@ -47,15 +73,37 @@ const AI_AGENT_CHECKBOX_CHOICES = [ })) ]; -export async function promptForAgents(): Promise { - let selected: AIAgentTarget[] = AI_AGENT_CHECKBOX_DEFAULTS; +const AI_ASSISTANT_CHECKBOX_CHOICES = [ + { value: "none", name: "None (skip MCP configuration)" }, + ...AI_ASSISTANT_CHOICES.map(a => ({ + value: a, + name: AI_ASSISTANT_LABELS[a], + checked: AI_ASSISTANT_CHECKBOX_DEFAULTS.includes(a) + })) +]; + +export async function promptForAgents(): Promise { + let selected: AIAgentOption[] = AI_AGENT_CHECKBOX_DEFAULTS; if (Util.canPrompt()) { const result = await InquirerWrapper.checkbox({ - message: "Which AI tools do you want to generate configuration files for?", + message: "Which AI agents do you want to generate skills and instructions for?", required: true, choices: AI_AGENT_CHECKBOX_CHOICES }); - selected = result.includes("none") ? [] : result as AIAgentTarget[]; + selected = result as AIAgentOption[]; + } + return selected; +} + +export async function promptForAssistant(): Promise { + let selected: AIAssistantOption[] = AI_ASSISTANT_CHECKBOX_DEFAULTS; + if (Util.canPrompt()) { + const result = await InquirerWrapper.checkbox({ + message: "Which coding assistants should MCP servers be configured for?", + required: true, + choices: AI_ASSISTANT_CHECKBOX_CHOICES + }); + selected = result as AIAssistantOption[]; } return selected; } @@ -65,33 +113,31 @@ const command: CommandModule = { describe: "Configures Ignite UI AI tooling (MCP servers, AI coding skills and instructions)", builder: (yargs) => yargs .usage("") - .option("agent", { - alias: "a", + .option("agents", { describe: "AI agents/tools to generate configuration files for", - choices: AI_AGENT_CHOICES, + choices: [...AI_AGENT_CHOICES, "none"] as string[], + type: "array" + }) + .option("assistants", { + describe: "Coding assistant(s) to configure MCP servers for", + choices: [...AI_ASSISTANT_CHOICES, "none"] as string[], type: "array" }), async handler(argv: ArgumentsCamelCase) { - let agents = argv.agent as AIAgentTarget[] | undefined; + const agents = (argv.agents ?? []) as AIAgentOption[]; + const assistants = (argv.assistants ?? []) as AIAssistantOption[]; GoogleAnalytics.post({ t: "screenview", cd: "Ai Config" }); - if (!agents?.length) { - agents = await promptForAgents(); - } + const result = await configure(agents, assistants); + GoogleAnalytics.post({ t: "event", ec: "$ig ai-config", - ea: `agent: ${agents.join(", ")}` + ea: `agent: ${result.agents.join(", ") || "none"}; assistant: ${result.assistants.join(", ") || "none"}` }); - - if (!agents.length) { - Util.log("No AI configuration selected. Skipping."); - return; - } - await configure(agents); } }; diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index 96813d2d3..ff0f467e5 100644 --- a/packages/cli/lib/commands/new.ts +++ b/packages/cli/lib/commands/new.ts @@ -1,4 +1,4 @@ -import { AI_AGENT_CHOICES, AIAgentTarget, App, type BaseTemplateManager, GoogleAnalytics, PackageManager, ProjectConfig, ProjectLibrary, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { AI_AGENT_CHOICES, AI_ASSISTANT_CHOICES, AIAgentTarget, type AiCodingAssistant, App, type BaseTemplateManager, GoogleAnalytics, PackageManager, ProjectConfig, ProjectLibrary, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; import * as path from "path"; import { PromptSession } from "./../PromptSession"; import { NewCommandType, PositionalArgs } from "./types"; @@ -60,9 +60,13 @@ const command: NewCommandType = { type: "string" }) .option("agents", { - alias: "a", describe: "AI agents/tools to generate configuration files for", - choices: AI_AGENT_CHOICES, + choices: [...AI_AGENT_CHOICES, "none"] as string[], + type: "array" + }) + .option("assistants", { + describe: "Coding assistant(s) to configure MCP servers for", + choices: [...AI_ASSISTANT_CHOICES, "none"] as string[], type: "array" }) .example("$0 new my-app", "Scaffold a new project interactively") @@ -157,6 +161,10 @@ const command: NewCommandType = { config, projTemplate.delimiters, false); } + process.chdir(argv.name); + await configure(argv.agents as (AIAgentTarget | "none")[], argv.assistants as (AiCodingAssistant | "none")[]); + process.chdir(".."); + Util.log(Util.greenCheck() + " Project Created"); if (!argv.skipInstall) { @@ -165,10 +173,6 @@ const command: NewCommandType = { process.chdir(".."); } - process.chdir(argv.name); - await configure(argv.agents as AIAgentTarget[] | undefined); - process.chdir(".."); - if (!argv["skip-git"] && !ProjectConfig.getConfig().skipGit) { Util.gitInit(process.cwd(), argv.name); } diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 52326cbc1..76cfd4476 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -35,14 +35,14 @@ const AI_AGENT_INSTRUCTION_FILES: Record = { }; 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)" + generic: "Generic (Add .agents/skills and AGENTS.md general for most assistants)", + claude: "Claude (Add .claude/skills and CLAUDE.md)", + copilot: "Copilot (Add .github/skills and copilot-instructions.md)", + cursor: "Cursor (Add .cursor/skills and .cursor/rules/cursor.mdc)", + codex: "Codex (Add .codex/skills and .codex/instructions.md)", + windsurf: "Windsurf (Add .windsurf/skills and .windsurf/rules/guidelines.md)", + gemini: "Gemini (Add .gemini/skills and .gemini/GEMINI.md)", + junie: "Junie (Add .junie/skills and .junie/guidelines.md)" }; /** diff --git a/packages/core/util/mcp-config.ts b/packages/core/util/mcp-config.ts index bf41928df..081b27328 100644 --- a/packages/core/util/mcp-config.ts +++ b/packages/core/util/mcp-config.ts @@ -7,6 +7,30 @@ export interface McpServerEntry { args: string[]; } +export const AI_ASSISTANT_CHOICES = ["generic", "vscode", "cursor", "gemini", "junie"] as const; +export type AiCodingAssistant = typeof AI_ASSISTANT_CHOICES[number]; + +interface AssistantMcpConfig { + mcpFilePath: string; + rootKey: "servers" | "mcpServers"; +} + +export const AI_ASSISTANT_LABELS: Record = { + "generic": ".mcp.json (generic for Claude Code, VS Code, and other assistants)", + "vscode": "VS Code (GitHub Copilot)", + "cursor": "Cursor", + "gemini": "Gemini", + "junie": "JetBrains Junie", +}; + +export const AI_ASSISTANT_MCP_CONFIGS: Record = { + "generic": { mcpFilePath: ".mcp.json", rootKey: "mcpServers" }, + "vscode": { mcpFilePath: ".vscode/mcp.json", rootKey: "servers" }, + "cursor": { mcpFilePath: ".cursor/mcp.json", rootKey: "mcpServers" }, + "gemini": { mcpFilePath: ".gemini/settings.json", rootKey: "mcpServers" }, + "junie": { mcpFilePath: ".junie/mcp/mcp.json", rootKey: "mcpServers" }, +}; + const IGNITEUI_MCP_SERVERS: Record = { "igniteui-cli": { command: "npx", @@ -18,18 +42,18 @@ const IGNITEUI_MCP_SERVERS: Record = { } }; -export const VS_CODE_MCP_PATH = ".vscode/mcp.json"; - /** - * Reads .vscode/mcp.json, ensures all IgniteUI MCP servers are present, + * Reads the assistant-specific MCP config file, ensures all IgniteUI MCP servers are present, * optionally adds additional servers. Creates the file if it doesn't exist. + * @param assistant target AI coding assistant (defaults to "vscode") * @param additionalServers optional extra servers to include alongside the built-in ones * @returns whether the file was modified */ export function addMcpServers( - mcpFilePath: string, + assistant: AiCodingAssistant, additionalServers?: Record ): boolean { + const { mcpFilePath, rootKey } = AI_ASSISTANT_MCP_CONFIGS[assistant]; const fileSystem = App.container.get(FS_TOKEN); const servers = { ...additionalServers, ...IGNITEUI_MCP_SERVERS }; @@ -44,12 +68,12 @@ export function addMcpServers( if (Object.keys(servers).length === 0) { return false; } - fileSystem.writeFile(mcpFilePath, JSON.stringify({ servers }, null, 2) + "\n"); + fileSystem.writeFile(mcpFilePath, JSON.stringify({ [rootKey]: servers }, null, 2) + "\n"); return true; } const parsed = jsonc.parse(existingContent); - const existing = parsed.servers ?? {}; + const existing = parsed[rootKey] ?? {}; const formattingOptions: jsonc.FormattingOptions = { tabSize: 2, insertSpaces: true }; let text = existingContent; @@ -57,7 +81,7 @@ export function addMcpServers( for (const [key, value] of Object.entries(servers)) { if (!existing[key]) { - const edits = jsonc.modify(text, ["servers", key], value, { formattingOptions }); + const edits = jsonc.modify(text, [rootKey, key], value, { formattingOptions }); text = jsonc.applyEdits(text, edits); modified = true; } diff --git a/packages/ng-schematics/src/cli-config/ai-config-schema.json b/packages/ng-schematics/src/cli-config/ai-config-schema.json index 6e68dc9fd..4cdd16ce9 100644 --- a/packages/ng-schematics/src/cli-config/ai-config-schema.json +++ b/packages/ng-schematics/src/cli-config/ai-config-schema.json @@ -15,19 +15,41 @@ "enum": ["none", "claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "generic"] }, "x-prompt": { - "message": "Which AI tools do you want to generate configuration files for?", + "message": "Which AI agents do you want to generate skills and instructions 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)" } + { "value": "none", "label": "None (skip skills and instructions)" }, + { "value": "generic", "label": "Generic (Add .agents/skills and AGENTS.md general for most assistants)" }, + { "value": "claude", "label": "Claude (Add .claude/skills and CLAUDE.md)" }, + { "value": "copilot", "label": "Copilot (Add .github/skills and copilot-instructions.md)" }, + { "value": "cursor", "label": "Cursor (Add .cursor/skills and .cursor/rules/cursor.mdc)" }, + { "value": "codex", "label": "Codex (Add .codex/skills and .codex/instructions.md)" }, + { "value": "windsurf", "label": "Windsurf (Add .windsurf/skills and .windsurf/rules/guidelines.md)" }, + { "value": "gemini", "label": "Gemini (Add .gemini/skills and .gemini/GEMINI.md)" }, + { "value": "junie", "label": "Junie (Add .junie/skills and .junie/guidelines.md)" } + ] + } + }, + "assistants": { + "type": "array", + "description": "Coding assistant(s) to configure MCP servers for.", + "default": ["generic"], + "items": { + "type": "string", + "enum": ["none", "generic", "vscode", "cursor", "gemini", "junie"] + }, + "x-prompt": { + "message": "Which coding assistants should MCP servers be configured for?", + "type": "list", + "multiselect": true, + "items": [ + { "value": "none", "label": "None (skip MCP configuration)" }, + { "value": "generic", "label": ".mcp.json (generic for Claude Code, VS Code, and other assistants)" }, + { "value": "vscode", "label": "VS Code (GitHub Copilot)" }, + { "value": "cursor", "label": "Cursor" }, + { "value": "gemini", "label": "Gemini" }, + { "value": "junie", "label": "JetBrains Junie" } ] } } diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 15c72be7c..803d8db47 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -2,7 +2,7 @@ import * as ts from "typescript"; import { DependencyNotFoundException } from "@angular-devkit/core"; import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics"; 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 { addClassToBody, addMcpServers, AIAgentTarget, AiCodingAssistant, App, copyAgentInstructionFiles, copyAISkillsToProject, FormatSettings, McpServerEntry, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core"; import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates"; import { createCliConfig } from "../utils/cli-config"; import { setVirtual } from "../utils/NgFileSystem"; @@ -127,7 +127,7 @@ function appInit(tree: Tree) { setVirtual(tree); } -function aiConfig({ init, agents }: { init: boolean; agents: AIAgentTarget[] }): Rule { +function aiConfig({ init, agents, assistants }: { init: boolean; agents: AIAgentTarget[]; assistants: AiCodingAssistant[] }): Rule { return (tree: Tree) => { if (init) { appInit(tree); @@ -142,18 +142,19 @@ function aiConfig({ init, agents }: { init: boolean; agents: AIAgentTarget[] }): } }; - addMcpServers(VS_CODE_MCP_PATH, angularCliServer); + for (const assistant of assistants) { + addMcpServers(assistant, angularCliServer); + } }; } /** Standalone `ai-config` schematic entry */ -export function addAIConfig(options: { agents?: AIAgentTarget[] } = {}): Rule { +export function addAIConfig(options: { agents?: AIAgentTarget[]; assistants?: string[] } = {}): Rule { const selected = options.agents?.length ? options.agents : [] as AIAgentTarget[]; const agents = selected.includes("none" as any) ? [] : selected; - if (!agents.length) { - return (tree: Tree) => tree; - } - return aiConfig({ init: true, agents }); + const selectedAssistants = options.assistants?.length ? options.assistants : []; + const assistants = (selectedAssistants.includes("none")? [] : selectedAssistants) as AiCodingAssistant[]; + return aiConfig({ init: true, agents, assistants }); } export default function (): Rule { diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index 726a42241..0f9d2a202 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -337,7 +337,7 @@ export const appConfig: ApplicationConfig = { }); it("should create .vscode/mcp.json with igniteui and angular-cli servers when file does not exist", async () => { - await runner.runSchematic("ai-config", {}, tree); + await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree); expect(tree.exists(mcpFilePath)).toBeTruthy(); const content = JSON.parse(tree.readContent(mcpFilePath)); @@ -349,7 +349,7 @@ export const appConfig: ApplicationConfig = { 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("ai-config", {}, tree); + await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); @@ -364,7 +364,7 @@ export const appConfig: ApplicationConfig = { } })); - await runner.runSchematic("ai-config", {}, tree); + await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); @@ -382,7 +382,7 @@ export const appConfig: ApplicationConfig = { }; tree.create(mcpFilePath, JSON.stringify(existing)); - await runner.runSchematic("ai-config", {}, tree); + await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content).toEqual(existing); @@ -395,7 +395,7 @@ export const appConfig: ApplicationConfig = { } })); - await runner.runSchematic("ai-config", {}, tree); + await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree); const content = JSON.parse(tree.readContent(mcpFilePath)); expect(content.servers["other-server"]).toEqual({ command: "node", args: ["server.js"] }); @@ -432,5 +432,37 @@ export const appConfig: ApplicationConfig = { expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "cursor"]); expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "cursor"]); }); + + it("should default MCP config to .vscode/mcp.json with servers key", async () => { + await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree); + + const filePath = "/.vscode/mcp.json"; + expect(tree.exists(filePath)).toBeTruthy(); + const content = JSON.parse(tree.readContent(filePath)); + expect(content.servers).toBeDefined(); + expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); + }); + + it("should write to .cursor/mcp.json with mcpServers key when assistant is cursor", async () => { + await runner.runSchematic("ai-config", { assistants: ["cursor"] }, tree); + + const filePath = "/.cursor/mcp.json"; + expect(tree.exists(filePath)).toBeTruthy(); + const content = JSON.parse(tree.readContent(filePath)); + expect(content.mcpServers).toBeDefined(); + expect(content.mcpServers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); + expect(content.mcpServers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] }); + expect(content.servers).toBeUndefined(); + }); + + it("should write to .mcp.json when assistant is claude-code", async () => { + await runner.runSchematic("ai-config", { assistants: ["generic"] }, tree); + + const filePath = "/.mcp.json"; + expect(tree.exists(filePath)).toBeTruthy(); + const content = JSON.parse(tree.readContent(filePath)); + expect(content.mcpServers["igniteui-cli"]).toBeDefined(); + expect(content.mcpServers["angular-cli"]).toBeDefined(); + }); }); }); diff --git a/packages/ng-schematics/src/ng-new/index_spec.ts b/packages/ng-schematics/src/ng-new/index_spec.ts index 62d9f0225..b0f2bcc90 100644 --- a/packages/ng-schematics/src/ng-new/index_spec.ts +++ b/packages/ng-schematics/src/ng-new/index_spec.ts @@ -224,7 +224,7 @@ describe("Schematics ng-new", () => { describe("addAIConfig via ng-new", () => { const workingDirectory = "my-test-project"; - const mcpFilePath = `${workingDirectory}/.vscode/mcp.json`; + const mcpFilePath = `${workingDirectory}/.mcp.json`; function setupAndRun(runner: SchematicTestRunner, myTree: Tree): Promise { spyOn(AppProjectSchematic, "default").and.returnValue((currentTree: Tree, _context: SchematicContext) => { @@ -239,7 +239,7 @@ describe("Schematics ng-new", () => { return runner.runSchematic("ng-new", { version: "8.0.3", name: workingDirectory, skipInstall: true, skipGit: true }, myTree); } - it("should create .vscode/mcp.json with both servers during ng-new", async () => { + it("should create .mcp.json with both servers during ng-new", async () => { const runner = new SchematicTestRunner("schematics", collectionPath); const myTree = Tree.empty(); @@ -247,8 +247,8 @@ describe("Schematics ng-new", () => { expect(e.exists(mcpFilePath)).toBeTruthy(); const content = JSON.parse(e.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.mcpServers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); + expect(content.mcpServers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] }); }); }); }); diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index 846fecc44..b0551b64d 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -70,9 +70,12 @@ describe("Help command", () => { [boolean] --skip-install, --si Do not install packages after scaffolding [boolean] --template Project template [string] - -a, --agents AI agents/tools to generate configuration files for + --agents AI agents/tools to generate configuration files for [array] [choices: "generic", "claude", "copilot", "cursor", "codex", - "windsurf", "gemini", "junie"] + "windsurf", "gemini", "junie", "none"] + --assistants Coding assistant(s) to configure MCP servers for + [array] [choices: "generic", "vscode", "cursor", "gemini", "junie", + "none"] Examples: ig new my-app Scaffold a new project interactively diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 740d7847b..915bd481c 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -42,7 +42,7 @@ describe("Unit - ai-config command", () => { const mockFs = createMockFs(); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMCP(["vscode"]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -50,11 +50,24 @@ describe("Unit - ai-config command", () => { expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); }); + it("creates config with mcpServers key for non-vscode assistants", () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + + configureMCP(["cursor"]); + + expect(mockFs.writeFile).toHaveBeenCalledWith(".cursor/mcp.json", jasmine.any(String)); + const config = writtenConfig(mockFs); + expect((config.mcpServers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + expect((config.mcpServers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); + expect(config.servers).toBeUndefined(); + }); + it("adds both servers when file exists but servers object is empty", () => { const mockFs = createMockFs(JSON.stringify({ servers: {} })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMCP(["vscode"]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -68,7 +81,7 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMCP(["vscode"]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -82,7 +95,7 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMCP(["vscode"]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -99,7 +112,7 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMCP(["vscode"]); expect(mockFs.writeFile).not.toHaveBeenCalled(); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("already configured")); @@ -112,7 +125,7 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMCP(["vscode"]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -264,16 +277,19 @@ describe("Unit - ai-config command", () => { it("prompts for agents when --agent is not provided", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude"])); + spyOn(InquirerWrapper, "checkbox").and.returnValues( + Promise.resolve(["claude"]), + Promise.resolve(["vscode"]) + ); 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?", + message: "Which AI agents do you want to generate skills and instructions for?", required: true })); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "Ai Config" })); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: claude" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: claude; assistant: vscode" })); }); it("uses defaults without prompting when canPrompt returns false", async () => { @@ -284,7 +300,7 @@ describe("Unit - ai-config command", () => { await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: generic, claude" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: generic, claude; assistant: generic" })); }); it("logs skipping and does not post analytics when none is selected", async () => { @@ -296,30 +312,101 @@ describe("Unit - ai-config command", () => { expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("Skipping")); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "Ai Config" })); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: " })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: none; assistant: none" })); + }); + + it("still configures MCP when none is selected for skills", async () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + spyOn(Util, "canPrompt").and.returnValue(true); + spyOn(InquirerWrapper, "checkbox").and.returnValues( + Promise.resolve(["none"]), + Promise.resolve(["vscode"]) + ); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(mockFs.writeFile).toHaveBeenCalled(); + const config = writtenConfig(mockFs); + expect(config.servers).toBeDefined(); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "Ai Config" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: none; assistant: vscode" })); + expect(InquirerWrapper.checkbox).toHaveBeenCalledTimes(2); + expect( + (Util.log as jasmine.Spy).calls.allArgs() + .filter(([msg]) => String(msg).includes("Skipping")) + ).toHaveSize(1); + expect(Util.log).toHaveBeenCalledWith("No AI configuration selected. Skipping."); }); it("configures multiple agents when selected interactively", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude", "cursor"])); + spyOn(InquirerWrapper, "checkbox").and.returnValues( + Promise.resolve(["claude", "cursor"]), + Promise.resolve(["vscode"]) + ); 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?" + message: "Which AI agents do you want to generate skills and instructions for?" })); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: claude, cursor" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: claude, cursor; assistant: vscode" })); }); it("skips prompt when --agent is provided", async () => { App.container.set(FS_TOKEN, createMockFs()); - spyOn(InquirerWrapper, "checkbox"); + spyOn(Util, "canPrompt").and.returnValue(true); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["vscode"])); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agent: ["cursor"] }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agents: ["cursor"] }); - expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); - expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor" })); + expect(InquirerWrapper.checkbox).not.toHaveBeenCalledWith(jasmine.objectContaining({ + message: "Which AI agents do you want to generate skills and instructions for?" + })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor; assistant: vscode" })); + }); + + it("skips assistant prompt when --assistant is provided", async () => { + App.container.set(FS_TOKEN, createMockFs()); + spyOn(Util, "canPrompt").and.returnValue(true); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude"])); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", assistants: ["cursor"] }); + + expect(InquirerWrapper.checkbox).toHaveBeenCalledTimes(1); + }); + + it("prompts for assistant with correct message", async () => { + App.container.set(FS_TOKEN, createMockFs()); + spyOn(Util, "canPrompt").and.returnValue(true); + spyOn(InquirerWrapper, "checkbox").and.returnValues( + Promise.resolve(["claude"]), + Promise.resolve(["vscode"]) + ); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ + message: "Which coding assistants should MCP servers be configured for?" + })); + }); + + it("writes to correct config path for selected assistant", async () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + spyOn(Util, "canPrompt").and.returnValue(true); + spyOn(InquirerWrapper, "checkbox").and.returnValues( + Promise.resolve(["claude"]), + Promise.resolve(["generic"]) + ); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(mockFs.writeFile).toHaveBeenCalledWith(".mcp.json", jasmine.any(String)); + const config = writtenConfig(mockFs); + expect((config.mcpServers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); }); }); }); diff --git a/spec/unit/new-spec.ts b/spec/unit/new-spec.ts index f5decf167..5c7e62c33 100644 --- a/spec/unit/new-spec.ts +++ b/spec/unit/new-spec.ts @@ -36,7 +36,7 @@ describe("Unit - New command", () => { spyOn(Util, "execSync"); spyOn(process, "chdir"); spyOn(PackageManager, "installPackages"); - spyOn(aiConfig, "configure").and.returnValue(Promise.resolve()); + spyOn(aiConfig, "configure").and.returnValue(Promise.resolve({ agents: [], assistants: [] })); spyOn(Util, "directoryExists").and.returnValue(false); }); @@ -414,7 +414,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", agents: ["claude", "cursor"], _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith(["claude", "cursor"]); + expect(configureSpy).toHaveBeenCalledWith(["claude", "cursor"], undefined); }); it("calls configure with undefined when --agents is not provided", async () => { @@ -422,7 +422,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith(undefined); + expect(configureSpy).toHaveBeenCalledWith(undefined, undefined); }); it("calls configure with single agent", async () => { @@ -430,10 +430,10 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", agents: ["generic"], _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith(["generic"]); + expect(configureSpy).toHaveBeenCalledWith(["generic"], undefined); }); - it("calls configure after project creation and package install", async () => { + it("calls configure before package install", async () => { createProjectMocks(); const callOrder: string[] = []; (PackageManager.installPackages as jasmine.Spy).and.callFake(() => { @@ -447,7 +447,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", agents: ["claude"], _: ["new"], $0: "new" }); - expect(callOrder).toEqual(["install", "configure"]); + expect(callOrder).toEqual(["configure", "install"]); }); it("calls configure from within the project directory", async () => { @@ -475,7 +475,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", skipInstall: true, agents: ["claude"], _: ["new"], $0: "new" }); expect(PackageManager.installPackages).not.toHaveBeenCalled(); - expect(configureSpy).toHaveBeenCalledWith(["claude"]); + expect(configureSpy).toHaveBeenCalledWith(["claude"], undefined); }); it("does not call configure when project creation fails (bad name)", async () => {