diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 9f4616521..8ce72b28a 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -1,66 +1,14 @@ -import { copyAISkillsToProject, FsFileSystem, GoogleAnalytics, IFileSystem, Util } from "@igniteui/cli-core"; +import { addMcpServers, copyAISkillsToProject, GoogleAnalytics, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; import { ArgumentsCamelCase, CommandModule } from "yargs"; -import * as path from "path"; -const IGNITEUI_SERVER_KEY = "igniteui-cli"; -const IGNITEUI_THEMING_SERVER_KEY = "igniteui-theming"; - -const igniteuiServer = { - command: "npx", - args: ["-y", "igniteui-cli@next", "mcp"] -}; - -const igniteuiThemingServer = { - command: "npx", - args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] -}; - -interface McpServerEntry { - command: string; - args: string[]; -} - -interface VsCodeMcpConfig { - servers: Record; -} - -function getConfigPath(): string { - return path.join(process.cwd(), ".vscode", "mcp.json"); -} - -function readJson(filePath: string, fallback: T, fileSystem: IFileSystem): T { - try { - return JSON.parse(fileSystem.readFile(filePath)) as T; - } catch { - return fallback; - } -} - -function writeJson(filePath: string, data: unknown, fileSystem: IFileSystem): void { - fileSystem.writeFile(filePath, JSON.stringify(data, null, 2) + "\n"); -} - -export function configureMCP(fileSystem: IFileSystem = new FsFileSystem()): void { - const configPath = getConfigPath(); - const config = readJson(configPath, { servers: {} }, fileSystem); - config.servers = config.servers || {}; - - let modified = false; - if (!config.servers[IGNITEUI_SERVER_KEY]) { - config.servers[IGNITEUI_SERVER_KEY] = igniteuiServer; - modified = true; - } - if (!config.servers[IGNITEUI_THEMING_SERVER_KEY]) { - config.servers[IGNITEUI_THEMING_SERVER_KEY] = igniteuiThemingServer; - modified = true; - } +export function configureMCP(): void { + const modified = addMcpServers(VS_CODE_MCP_PATH); if (!modified) { - Util.log(` Ignite UI MCP servers already configured in ${configPath}`); + Util.log(` Ignite UI MCP servers already configured in ${VS_CODE_MCP_PATH}`); return; } - writeJson(configPath, config, fileSystem); - Util.log(Util.greenCheck() + ` MCP servers configured in ${configPath}`); + Util.log(Util.greenCheck() + ` MCP servers configured in ${VS_CODE_MCP_PATH}`); } export function configureSkills(): void { diff --git a/packages/core/package.json b/packages/core/package.json index 33f4a617e..a94e0d2ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,7 @@ "@inquirer/prompts": "^7.9.0", "chalk": "^2.3.2", "glob": "^11.0.0", + "jsonc-parser": "3.3.1", "through2": "^2.0.3", "typescript": "~5.5.4" }, diff --git a/packages/core/util/index.ts b/packages/core/util/index.ts index b9b7ada55..045234447 100644 --- a/packages/core/util/index.ts +++ b/packages/core/util/index.ts @@ -1,6 +1,7 @@ export * from './ai-skills'; export * from './detect-framework'; export * from './GoogleAnalytics'; +export * from './mcp-config'; export * from './Util'; export * from './ProjectConfig'; export * from './Schematics'; diff --git a/packages/core/util/mcp-config.ts b/packages/core/util/mcp-config.ts new file mode 100644 index 000000000..ce500d99c --- /dev/null +++ b/packages/core/util/mcp-config.ts @@ -0,0 +1,71 @@ +import { FS_TOKEN, IFileSystem } from "../types/FileSystem"; +import * as jsonc from "jsonc-parser"; +import { App } from "./App"; + +export interface McpServerEntry { + command: string; + args: string[]; +} + +const IGNITEUI_MCP_SERVERS: Record = { + "igniteui-cli": { + command: "npx", + args: ["-y", "igniteui-cli@next", "mcp"] + }, + "igniteui-theming": { + command: "npx", + args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] + } +}; + +export const VS_CODE_MCP_PATH = ".vscode/mcp.json"; + +/** + * Reads .vscode/mcp.json, ensures all IgniteUI MCP servers are present, + * optionally adds additional servers. Creates the file if it doesn't exist. + * @param additionalServers optional extra servers to include alongside the built-in ones + * @returns whether the file was modified + */ +export function addMcpServers( + mcpFilePath: string, + additionalServers?: Record +): boolean { + const fileSystem = App.container.get(FS_TOKEN); + const servers = { ...additionalServers, ...IGNITEUI_MCP_SERVERS }; + + let existingContent: string | undefined; + try { + existingContent = fileSystem.readFile(mcpFilePath); + } catch { + existingContent = undefined; + } + + if (!existingContent) { + if (Object.keys(servers).length === 0) { + return false; + } + fileSystem.writeFile(mcpFilePath, JSON.stringify({ servers }, null, 2) + "\n"); + return true; + } + + const parsed = jsonc.parse(existingContent); + const existing = parsed.servers ?? {}; + const formattingOptions: jsonc.FormattingOptions = { tabSize: 2, insertSpaces: true }; + + let text = existingContent; + let modified = false; + + for (const [key, value] of Object.entries(servers)) { + if (!existing[key]) { + const edits = jsonc.modify(text, ["servers", key], value, { formattingOptions }); + text = jsonc.applyEdits(text, edits); + modified = true; + } + } + + if (modified) { + fileSystem.writeFile(mcpFilePath, text); + } + + return modified; +} diff --git a/packages/ng-schematics/package.json b/packages/ng-schematics/package.json index d7b18ae29..9f750cde3 100644 --- a/packages/ng-schematics/package.json +++ b/packages/ng-schematics/package.json @@ -23,7 +23,6 @@ "@igniteui/angular-templates": "~21.1.1500-rc.2", "@igniteui/cli-core": "~15.0.0-rc.2", "@schematics/angular": "^21.0.0", - "jsonc-parser": "3.3.1", "minimatch": "^10.0.1", "rxjs": "~7.8.1" }, diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 147fd331d..35bdb322b 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -1,8 +1,7 @@ import * as ts from "typescript"; import { DependencyNotFoundException } from "@angular-devkit/core"; import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics"; -import * as jsonc from "jsonc-parser"; -import { addClassToBody, App, copyAISkillsToProject, FormatSettings, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core"; +import { addClassToBody, addMcpServers, App, 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"; @@ -119,72 +118,47 @@ function importStyles(): Rule { }; } -export function addAIConfig(): Rule { +/** Initialize the App container with TemplateManager and virtual FS */ +function appInit(tree: Tree) { + App.initialize("angular-cli"); + // must be initialized with physical fs first: + App.container.set(TEMPLATE_MANAGER, new SchematicsTemplateManager()); + setVirtual(tree); +} + +function aiConfig({ init } = { init: true }): Rule { return (tree: Tree) => { + if (init) { + appInit(tree); + } copyAISkillsToProject(); - const mcpFilePath = "/.vscode/mcp.json"; - const angularCliServer = { - command: "npx", - args: ["-y", "@angular/cli", "mcp"] - }; - const igniteuiServer = { - command: "npx", - args: ["-y", "igniteui-cli@next", "mcp"] - }; - const igniteuiThemingServer = { - command: "npx", - args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] + const angularCliServer: Record = { + "angular-cli": { + command: "npx", + args: ["-y", "@angular/cli", "mcp"] + } }; - if (tree.exists(mcpFilePath)) { - let text = tree.read(mcpFilePath)!.toString(); - const content = jsonc.parse(text); - const servers = content.servers ?? {}; - const formattingOptions: jsonc.FormattingOptions = { tabSize: 2, insertSpaces: true }; - const newServers: Record = {}; - if (!servers["angular-cli"]) { - newServers["angular-cli"] = angularCliServer; - } - if (!servers["igniteui-cli"]) { - newServers["igniteui-cli"] = igniteuiServer; - } - if (!servers["igniteui-theming"]) { - newServers["igniteui-theming"] = igniteuiThemingServer; - } - for (const [key, value] of Object.entries(newServers)) { - const edits = jsonc.modify(text, ["servers", key], value, { formattingOptions }); - text = jsonc.applyEdits(text, edits); - } - if (Object.keys(newServers).length > 0) { - tree.overwrite(mcpFilePath, text); - } - } else { - const mcpConfig = { - servers: { - "angular-cli": angularCliServer, - "igniteui-cli": igniteuiServer, - "igniteui-theming": igniteuiThemingServer - } - }; - tree.create(mcpFilePath, JSON.stringify(mcpConfig, null, 2)); - } + addMcpServers(VS_CODE_MCP_PATH, angularCliServer); }; } +/** Standalone `ai-config` schematic entry */ +export function addAIConfig(): Rule { + return aiConfig(); +} + export default function (): Rule { return (tree: Tree) => { - App.initialize("angular-cli"); - // must be initialized with physical fs first: - App.container.set(TEMPLATE_MANAGER, new SchematicsTemplateManager()); - setVirtual(tree); + appInit(tree); return chain([ importStyles(), addTypographyToProj(), importBrowserAnimations(), createCliConfig(), displayVersionMismatch(), - addAIConfig() + aiConfig({ init: false }) ]); }; } diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 40724feca..c3bddebfe 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -40,8 +40,9 @@ describe("Unit - ai-config command", () => { describe("configureMCP", () => { it("creates .vscode/mcp.json with both servers when file does not exist", () => { const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); - configureMCP(mockFs); + configureMCP(); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -51,8 +52,9 @@ describe("Unit - ai-config command", () => { 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(mockFs); + configureMCP(); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -64,8 +66,9 @@ describe("Unit - ai-config command", () => { const mockFs = createMockFs(JSON.stringify({ servers: { [IGNITEUI_SERVER_KEY]: igniteuiServer } })); + App.container.set(FS_TOKEN, mockFs); - configureMCP(mockFs); + configureMCP(); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -77,8 +80,9 @@ describe("Unit - ai-config command", () => { const mockFs = createMockFs(JSON.stringify({ servers: { [IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer } })); + App.container.set(FS_TOKEN, mockFs); - configureMCP(mockFs); + configureMCP(); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -93,8 +97,9 @@ describe("Unit - ai-config command", () => { [IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer } })); + App.container.set(FS_TOKEN, mockFs); - configureMCP(mockFs); + configureMCP(); expect(mockFs.writeFile).not.toHaveBeenCalled(); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("already configured")); @@ -105,8 +110,9 @@ describe("Unit - ai-config command", () => { const mockFs = createMockFs(JSON.stringify({ servers: { "other-server": thirdPartyServer } })); + App.container.set(FS_TOKEN, mockFs); - configureMCP(mockFs); + configureMCP(); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -238,11 +244,6 @@ describe("Unit - ai-config command", () => { describe("handler", () => { it("posts analytics and calls configure", async () => { App.container.set(FS_TOKEN, createMockFs()); - const fs = require("fs"); - spyOn(fs, "readFileSync").and.throwError(new Error("ENOENT")); - spyOn(fs, "existsSync").and.returnValue(false); - spyOn(fs, "mkdirSync"); - spyOn(fs, "writeFileSync"); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" });