Skip to content
Merged
62 changes: 5 additions & 57 deletions packages/cli/lib/commands/ai-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, McpServerEntry>;
}

function getConfigPath(): string {
return path.join(process.cwd(), ".vscode", "mcp.json");
}

function readJson<T>(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<VsCodeMcpConfig>(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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/util/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
71 changes: 71 additions & 0 deletions packages/core/util/mcp-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, McpServerEntry> = {
"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<string, McpServerEntry>
): boolean {
const fileSystem = App.container.get<IFileSystem>(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 ?? {};
Comment thread
damyanpetev marked this conversation as resolved.
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]) {
Comment thread
damyanpetev marked this conversation as resolved.
const edits = jsonc.modify(text, ["servers", key], value, { formattingOptions });
text = jsonc.applyEdits(text, edits);
modified = true;
}
}

if (modified) {
fileSystem.writeFile(mcpFilePath, text);
}

return modified;
}
1 change: 0 additions & 1 deletion packages/ng-schematics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
78 changes: 26 additions & 52 deletions packages/ng-schematics/src/cli-config/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, McpServerEntry> = {
"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<string, object> = {};
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 })
]);
};
}
23 changes: 12 additions & 11 deletions spec/unit/ai-config-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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"));
Expand All @@ -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);
Expand Down Expand Up @@ -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" });

Expand Down
Loading