diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index 48a4561ca..074640271 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -1,5 +1,5 @@ import { - BasePromptSession, GoogleAnalytics, InquirerWrapper, PackageManager, ProjectConfig, + BasePromptSession, FsFileSystem, GoogleAnalytics, IFileSystem, InquirerWrapper, PackageManager, ProjectConfig, ProjectLibrary, PromptTaskContext, Task, Util } from "@igniteui/cli-core"; import * as path from "path"; @@ -10,8 +10,11 @@ import { TemplateManager } from "./TemplateManager"; export class PromptSession extends BasePromptSession { - constructor(templateManager: TemplateManager) { + private readonly mcpFs: IFileSystem; + + constructor(templateManager: TemplateManager, mcpFs: IFileSystem = new FsFileSystem()) { super(templateManager); + this.mcpFs = mcpFs; } public static async chooseTerm() { @@ -103,6 +106,37 @@ export class PromptSession extends BasePromptSession { await upgrade.upgrade({ skipInstall: true, _: ["upgrade"], $0: "upgrade" }); } + protected async configureMcp(): Promise { + const MCP_SERVER_KEY = "igniteui-mcp-server"; + let command: string; + let args: string[]; + try { + const pkgEntry = require.resolve("igniteui-mcp-server"); + command = "node"; + args = [pkgEntry]; + } catch { + command = "npx"; + args = ["-y", "igniteui-mcp-server"]; + } + const configPath = path.join(process.cwd(), ".vscode", "mcp.json"); + let config: { servers: Record } = { servers: {} }; + try { + config = JSON.parse(this.mcpFs.readFile(configPath, "utf8")); + } catch { /* file doesn't exist yet */ } + config.servers = config.servers || {}; + + if (config.servers[MCP_SERVER_KEY]) { + Util.log(Util.greenCheck() + ` Ignite UI MCP server already configured in ${configPath}`); + return; + } + + // Preserve existing MCP entries and add ours + config.servers[MCP_SERVER_KEY] = { command, args }; + this.mcpFs.mkdir(path.dirname(configPath), { recursive: true }); + this.mcpFs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n"); + Util.log(Util.greenCheck() + ` MCP server configured in ${configPath}`); + } + /** * Get user name and set template's extra configurations if any * @param projectLibrary to add component to diff --git a/packages/cli/lib/cli.ts b/packages/cli/lib/cli.ts index 9ab0b7661..117c8584c 100644 --- a/packages/cli/lib/cli.ts +++ b/packages/cli/lib/cli.ts @@ -4,6 +4,7 @@ import { add, ADD_COMMAND_NAME, ALL_COMMANDS, + aiConfig, build, config, doc, @@ -56,6 +57,7 @@ export async function run(args = null) { .command(list) .command(upgrade) .command(mcp) + .command(aiConfig) .version(false) // disable built-in `yargs.version` to override it with our custom option .options({ version: { diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts new file mode 100644 index 000000000..4102f07bb --- /dev/null +++ b/packages/cli/lib/commands/ai-config.ts @@ -0,0 +1,87 @@ +import { FsFileSystem, GoogleAnalytics, IFileSystem, Util } 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.mkdir(path.dirname(filePath), { recursive: true }); + fileSystem.writeFile(filePath, JSON.stringify(data, null, 2) + "\n"); +} + +export function configureVsCode(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; + } + + if (!modified) { + Util.log(` Ignite UI MCP servers already configured in ${configPath}`); + return; + } + writeJson(configPath, config, fileSystem); + Util.log(Util.greenCheck() + ` MCP servers configured in ${configPath}`); +} + +const command: CommandModule = { + command: "ai-config", + describe: "Configure the Ignite UI MCP server for an AI client", + builder: (yargs) => yargs.usage(""), + async handler(_argv: ArgumentsCamelCase) { + GoogleAnalytics.post({ + t: "screenview", + cd: "MCP" + }); + + GoogleAnalytics.post({ + t: "event", + ec: "$ig ai-config", + ea: "client: vscode" + }); + + configureVsCode(); + } +}; + +export default command; diff --git a/packages/cli/lib/commands/index.ts b/packages/cli/lib/commands/index.ts index af5a4f3b0..8ac2a429d 100644 --- a/packages/cli/lib/commands/index.ts +++ b/packages/cli/lib/commands/index.ts @@ -1,4 +1,5 @@ export { default as add } from "./add"; +export { default as aiConfig } from "./ai-config"; export { default as build } from "./build"; export { default as config } from "./config"; export { default as doc } from "./doc"; diff --git a/packages/cli/lib/commands/types.ts b/packages/cli/lib/commands/types.ts index f6ec139d1..cbf6a3fe5 100644 --- a/packages/cli/lib/commands/types.ts +++ b/packages/cli/lib/commands/types.ts @@ -13,6 +13,7 @@ export const TEST_COMMAND_NAME = "test"; export const LIST_COMMAND_NAME = "list"; export const UPGRADE_COMMAND_NAME = "upgrade-packages"; export const MCP_COMMAND_NAME = "mcp"; +export const AI_CONFIG_COMMAND_NAME = "ai-config"; export const ALL_COMMANDS = new Set([ ADD_COMMAND_NAME, @@ -25,7 +26,8 @@ export const ALL_COMMANDS = new Set([ TEST_COMMAND_NAME, LIST_COMMAND_NAME, UPGRADE_COMMAND_NAME, - MCP_COMMAND_NAME + MCP_COMMAND_NAME, + AI_CONFIG_COMMAND_NAME ]); export interface PositionalArgs { diff --git a/packages/core/prompt/BasePromptSession.ts b/packages/core/prompt/BasePromptSession.ts index 40e273c02..eedd64630 100644 --- a/packages/core/prompt/BasePromptSession.ts +++ b/packages/core/prompt/BasePromptSession.ts @@ -98,6 +98,9 @@ export abstract class BasePromptSession { /** Upgrade packages to use private Infragistics feed */ protected abstract upgradePackages(); + /** Configure the Ignite UI MCP server for the project */ + protected abstract configureMcp(): Promise; + /** * Get user name and set template's extra configurations if any * @param projectLibrary to add component to @@ -418,6 +421,8 @@ export abstract class BasePromptSession { await this.upgradePackages(); } } + + await this.configureMcp(); const defaultPort = config.project.defaultPort; const port = await this.getUserInput({ diff --git a/packages/core/types/FileSystem.ts b/packages/core/types/FileSystem.ts index 8e9f33194..5be8938ba 100644 --- a/packages/core/types/FileSystem.ts +++ b/packages/core/types/FileSystem.ts @@ -2,6 +2,7 @@ export interface IFileSystem { fileExists(filePath: string): boolean; readFile(filePath: string, encoding?: string): string; writeFile(filePath: string, text: string): void; + mkdir(dirPath: string, options?: { recursive?: boolean }): void; directoryExists(dirPath: string): boolean; /** diff --git a/packages/core/util/FileSystem.ts b/packages/core/util/FileSystem.ts index ddce66d24..6fe253aa9 100644 --- a/packages/core/util/FileSystem.ts +++ b/packages/core/util/FileSystem.ts @@ -21,6 +21,9 @@ export class FsFileSystem implements IFileSystem { public writeFile(filePath: string, text: string): void { fs.writeFileSync(filePath, text); } + public mkdir(dirPath: string, options?: { recursive?: boolean }): void { + fs.mkdirSync(dirPath, options); + } public directoryExists(dirPath: string): boolean { try { return fs.statSync(dirPath).isDirectory(); diff --git a/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts b/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts index 2a2a5dbef..f6e4d622a 100644 --- a/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts +++ b/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts @@ -58,6 +58,10 @@ export class SchematicsPromptSession extends BasePromptSession { // TODO? } + protected async configureMcp(): Promise { + // No-op in schematics context + } + protected async upgradePackages() { this.userAnswers.set("upgradePackages", true); } diff --git a/packages/ng-schematics/src/utils/NgFileSystem.ts b/packages/ng-schematics/src/utils/NgFileSystem.ts index d5d652309..51e250ba0 100644 --- a/packages/ng-schematics/src/utils/NgFileSystem.ts +++ b/packages/ng-schematics/src/utils/NgFileSystem.ts @@ -19,6 +19,10 @@ export class NgTreeFileSystem implements IFileSystem { : this.tree.create(filePath, text); } + public mkdir(_dirPath: string, _options?: { recursive?: boolean }): void { + // Angular Tree manages directories implicitly; no-op here. + } + public directoryExists(dirPath: string): boolean { const dir = this.tree.getDir(dirPath); return dir.subdirs.length || dir.subfiles.length ? true : false; diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index d92b28952..2f2c31119 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -26,6 +26,7 @@ describe("Help command", () => { upgrade-packages upgrades Ignite UI Packages mcp Starts the Ignite UI MCP server for AI assistant integration + ai-config Configure the Ignite UI MCP server for an AI client Options: -v, --version Show current Ignite UI CLI version [boolean] -h, --help Show help [boolean]`.replace(/\s/g, ""); diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index 265ea850b..0411b6e16 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -1,4 +1,4 @@ -import { App, BaseTemplate, Config, ControlExtraConfigType, GoogleAnalytics, InquirerWrapper, PackageManager, ProjectConfig, +import { App, BaseTemplate, Config, ControlExtraConfigType, GoogleAnalytics, IFileSystem, InquirerWrapper, PackageManager, ProjectConfig, ProjectLibrary, ProjectTemplate, Template, Util } from "@igniteui/cli-core"; import * as path from "path"; import { default as add } from "../../packages/cli/lib/commands/add"; @@ -8,6 +8,17 @@ import { PromptSession } from "../../packages/cli/lib/PromptSession"; import { TemplateManager } from "../../packages/cli/lib/TemplateManager"; import { Separator } from "@inquirer/prompts"; +function createMockMcpFs(): IFileSystem { + return { + fileExists: jasmine.createSpy("fileExists").and.returnValue(false), + readFile: jasmine.createSpy("readFile").and.throwError("ENOENT"), + writeFile: jasmine.createSpy("writeFile"), + mkdir: jasmine.createSpy("mkdir"), + directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), + glob: jasmine.createSpy("glob").and.returnValue([]) + }; +} + function createMockBaseTemplate(): BaseTemplate { return { id: "mock-template-id", @@ -457,7 +468,7 @@ describe("Unit - PromptSession", () => { getProjectLibraryNames: projectLibraries, getProjectLibraryByName: mockProjectLibrary }); - const mockSession = new PromptSession(mockTemplate); + const mockSession = new PromptSession(mockTemplate, createMockMcpFs()); const mockProjectConfig = { project: { defaultPort: 4200 @@ -491,7 +502,7 @@ describe("Unit - PromptSession", () => { await mockSession.chooseActionLoop(mockProjectLibrary); expect(mockSession.chooseActionLoop).toHaveBeenCalledTimes(1); expect(InquirerWrapper.select).toHaveBeenCalledTimes(9); - expect(Util.log).toHaveBeenCalledTimes(3); + expect(Util.log).toHaveBeenCalledTimes(4); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); expect(add.addTemplate).toHaveBeenCalledTimes(1); @@ -531,7 +542,7 @@ describe("Unit - PromptSession", () => { getProjectLibrary: mockProjectLibrary, getProjectLibraryByName: mockProjectLibrary }); - const mockSession = new PromptSession(mockTemplate); + const mockSession = new PromptSession(mockTemplate, createMockMcpFs()); const mockProjectConfig = { packagesInstalled: true, project: { @@ -568,7 +579,7 @@ describe("Unit - PromptSession", () => { expect(mockSession.chooseActionLoop).toHaveBeenCalledTimes(1); expect(InquirerWrapper.select).toHaveBeenCalledTimes(5); expect(InquirerWrapper.input).toHaveBeenCalledTimes(2); - expect(Util.log).toHaveBeenCalledTimes(3); + expect(Util.log).toHaveBeenCalledTimes(4); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); expect(Util.getAvailableName).toHaveBeenCalledTimes(1); @@ -649,12 +660,12 @@ describe("Unit - PromptSession", () => { getProjectLibraryNames: projectLibraries, getProjectLibraryByName: mockProjectLibrary }); - const mockSession = new PromptSession(mockTemplate); + const mockSession = new PromptSession(mockTemplate, createMockMcpFs()); const mockProjectConfig = { project: { defaultPort: 4200 } - } as unknown as Config; + } as unknown as Config; spyOn(ProjectConfig, "localConfig").and.returnValue(mockProjectConfig); spyOn(mockSession, "chooseActionLoop").and.callThrough(); spyOn(Util, "log"); @@ -691,7 +702,7 @@ describe("Unit - PromptSession", () => { expect(InquirerWrapper.select).toHaveBeenCalledTimes(10); expect(InquirerWrapper.input).toHaveBeenCalledTimes(2); expect(InquirerWrapper.checkbox).toHaveBeenCalledTimes(1); - expect(Util.log).toHaveBeenCalledTimes(3); + expect(Util.log).toHaveBeenCalledTimes(4); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); expect(add.addTemplate).toHaveBeenCalledTimes(1); @@ -731,7 +742,7 @@ describe("Unit - PromptSession", () => { } as unknown as Config; spyOn(ProjectConfig, "localConfig").and.returnValue(mockProjectConfig); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - const mockSession = new PromptSession(mockTemplate); + const mockSession = new PromptSession(mockTemplate, createMockMcpFs()); spyOn(mockSession, "chooseActionLoop").and.callThrough(); spyOn(InquirerWrapper, "select").and.returnValues( Promise.resolve("Complete & Run"), @@ -773,7 +784,7 @@ describe("Unit - PromptSession", () => { spyOn(ProjectConfig, "localConfig").and.returnValue(mockProjectConfig); spyOn(ProjectConfig, "setConfig"); - const mockSession = new PromptSession({} as any); + const mockSession = new PromptSession({} as any, createMockMcpFs()); spyOn(mockSession as any, "generateActionChoices").and.returnValues([]); spyOn(mockSession as any, "getUserInput").and.returnValues( Promise.resolve("Complete & Run"), diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts new file mode 100644 index 000000000..b1716684c --- /dev/null +++ b/spec/unit/ai-config-spec.ts @@ -0,0 +1,129 @@ +import * as path from "path"; +import { GoogleAnalytics, IFileSystem, Util } from "@igniteui/cli-core"; +import { configureVsCode } from "../../packages/cli/lib/commands/ai-config"; +import { default as aiConfig } from "../../packages/cli/lib/commands/ai-config"; + +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"] }; + +function createMockFs(existingContent?: string): IFileSystem { + return { + fileExists: jasmine.createSpy("fileExists"), + readFile: existingContent + ? jasmine.createSpy("readFile").and.returnValue(existingContent) + : jasmine.createSpy("readFile").and.throwError("ENOENT"), + writeFile: jasmine.createSpy("writeFile"), + mkdir: jasmine.createSpy("mkdir"), + directoryExists: jasmine.createSpy("directoryExists"), + glob: jasmine.createSpy("glob").and.returnValue([]) + }; +} + +describe("Unit - ai-config command", () => { + const configPath = path.join(process.cwd(), ".vscode", "mcp.json"); + + beforeAll(() => { + spyOn(GoogleAnalytics, "post"); + }); + + beforeEach(() => { + spyOn(Util, "log"); + spyOn(Util, "greenCheck").and.returnValue("✓"); + }); + + function writtenConfig(mockFs: IFileSystem): Record { + const content = (mockFs.writeFile as jasmine.Spy).calls.mostRecent().args[1] as string; + return JSON.parse(content); + } + + describe("configureVsCode", () => { + it("creates .vscode/mcp.json with both servers when file does not exist", () => { + const mockFs = createMockFs(); + + configureVsCode(mockFs); + + expect(mockFs.mkdir).toHaveBeenCalledWith(path.dirname(configPath), { recursive: true }); + expect(mockFs.writeFile).toHaveBeenCalled(); + const config = writtenConfig(mockFs); + expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); + }); + + it("adds both servers when file exists but servers object is empty", () => { + const mockFs = createMockFs(JSON.stringify({ servers: {} })); + + configureVsCode(mockFs); + + expect(mockFs.writeFile).toHaveBeenCalled(); + const config = writtenConfig(mockFs); + expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); + }); + + it("adds missing igniteui-theming server when only igniteui-cli is present", () => { + const mockFs = createMockFs(JSON.stringify({ + servers: { [IGNITEUI_SERVER_KEY]: igniteuiServer } + })); + + configureVsCode(mockFs); + + expect(mockFs.writeFile).toHaveBeenCalled(); + const config = writtenConfig(mockFs); + expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); + }); + + it("adds missing igniteui-cli server when only igniteui-theming is present", () => { + const mockFs = createMockFs(JSON.stringify({ + servers: { [IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer } + })); + + configureVsCode(mockFs); + + expect(mockFs.writeFile).toHaveBeenCalled(); + const config = writtenConfig(mockFs); + expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); + }); + + it("is a no-op and logs when both servers are already configured", () => { + const mockFs = createMockFs(JSON.stringify({ + servers: { + [IGNITEUI_SERVER_KEY]: igniteuiServer, + [IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer + } + })); + + configureVsCode(mockFs); + + expect(mockFs.writeFile).not.toHaveBeenCalled(); + expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("already configured")); + }); + + it("preserves existing third-party servers when adding igniteui servers", () => { + const thirdPartyServer = { command: "node", args: ["server.js"] }; + const mockFs = createMockFs(JSON.stringify({ + servers: { "other-server": thirdPartyServer } + })); + + configureVsCode(mockFs); + + expect(mockFs.writeFile).toHaveBeenCalled(); + const config = writtenConfig(mockFs); + expect((config.servers as any)["other-server"]).toEqual(thirdPartyServer); + expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); + }); + }); + + describe("handler", () => { + it("posts analytics and calls configureVsCode", async () => { + await aiConfig.handler({ _: ["ai-config"], $0: "ig" }); + + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "MCP" })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ec: "$ig ai-config" })); + }); + }); +}); diff --git a/spec/unit/packageResolve-spec.ts b/spec/unit/packageResolve-spec.ts index 0898399c7..1d396e5bc 100644 --- a/spec/unit/packageResolve-spec.ts +++ b/spec/unit/packageResolve-spec.ts @@ -15,6 +15,9 @@ class MockFileSystem implements IFileSystem { public writeFile(filePath: string, text: string): void { throw new Error("writeFile not implemented."); } + public mkdir(dirPath: string): void { + throw new Error("mkdir not implemented."); + } public directoryExists(dirPath: string): boolean { throw new Error("directoryExists not implemented."); } diff --git a/spec/unit/ts-transform/Mock-FS.ts b/spec/unit/ts-transform/Mock-FS.ts index 4385a678d..a644f6bc5 100644 --- a/spec/unit/ts-transform/Mock-FS.ts +++ b/spec/unit/ts-transform/Mock-FS.ts @@ -28,6 +28,10 @@ export class MockFS implements IFileSystem { this.fsMap.set(filePath, text); } + public mkdir(_dirPath: string, _options?: { recursive?: boolean }): void { + // no-op for in-memory mock + } + public directoryExists(dirPath: string): boolean { throw new Error('directoryExists is not implemented.'); }