Skip to content

Commit 2ae6a3b

Browse files
committed
feat(ai-config): add framework prompt & early detect so command can always run
1 parent 87bebdc commit 2ae6a3b

16 files changed

Lines changed: 290 additions & 379 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# 15.1.1 (2026-05-14)
2+
3+
## What's Changed
4+
* feat(ai-config): `ig ai-config` now accepts a `--framework` / `-f` option for explicit framework specification. When omitted, the command still attempts to auto-detect the framework, but if detection fails it now also prompts the user for selection (in TTY).
5+
16
# 15.1.0 (2026-05-13)
27

38
## What's Changed

packages/cli/lib/PromptSession.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ export class PromptSession extends BasePromptSession {
3838
await upgrade.upgrade({ skipInstall: true, _: ["upgrade"], $0: "upgrade" });
3939
}
4040

41-
protected override async configureAI(): Promise<void> {
42-
await aiConfigure();
41+
protected override async configureAI(frameworkId: string): Promise<void> {
42+
await aiConfigure(frameworkId);
4343
}
4444

4545
protected override templateSelectedTask(type: "component" | "view" = "component"): Task<PromptTaskContext> {

packages/cli/lib/commands/ai-config.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
1-
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";
1+
import {
2+
addMcpServers,
3+
AI_AGENT_LABELS,
4+
AI_AGENT_CHOICES,
5+
type AIAgentTarget,
6+
copyAgentInstructionFiles,
7+
copyAISkillsToProject,
8+
GoogleAnalytics,
9+
InquirerWrapper,
10+
Util,
11+
type AiCodingAssistant,
12+
AI_ASSISTANT_MCP_CONFIGS,
13+
AI_ASSISTANT_CHOICES,
14+
AI_ASSISTANT_LABELS,
15+
detectFramework,
16+
App,
17+
type BaseTemplateManager,
18+
TEMPLATE_MANAGER,
19+
} from "@igniteui/cli-core";
220
import { ArgumentsCamelCase, CommandModule } from "yargs";
321

422
export function configureMCP(assistants: AiCodingAssistant[]): void {
@@ -14,8 +32,8 @@ export function configureMCP(assistants: AiCodingAssistant[]): void {
1432
}
1533
}
1634

17-
export function configureSkills(agents: AIAgentTarget[]): void {
18-
const result = copyAISkillsToProject(agents);
35+
export function configureSkills(agents: AIAgentTarget[], framework: string): void {
36+
const result = copyAISkillsToProject(agents, framework);
1937
if (result.found === 0) {
2038
Util.warn("No AI skill files found. Make sure packages are installed (npm install) " +
2139
"and your Ignite UI packages are up-to-date.", "yellow");
@@ -32,7 +50,7 @@ export function configureSkills(agents: AIAgentTarget[]): void {
3250
type AIAgentOption = AIAgentTarget | "none";
3351
type AIAssistantOption = AiCodingAssistant | "none";
3452

35-
export async function configure(agents: AIAgentOption[] = [], assistants: AIAssistantOption[] = [], skills = true): Promise<{ agents: AIAgentTarget[], assistants: AiCodingAssistant[] }> {
53+
export async function configure(framework: string, agents: AIAgentOption[] = [], assistants: AIAssistantOption[] = [], skills = true): Promise<{ agents: AIAgentTarget[], assistants: AiCodingAssistant[] }> {
3654
if (!agents.length) {
3755
agents = await promptForAgents();
3856
}
@@ -53,9 +71,9 @@ export async function configure(agents: AIAgentOption[] = [], assistants: AIAssi
5371
Util.log("No AI configuration selected. Skipping.");
5472
} else {
5573
if (skills) {
56-
configureSkills(resolvedAgents);
74+
configureSkills(resolvedAgents, framework);
5775
}
58-
copyAgentInstructionFiles(resolvedAgents);
76+
copyAgentInstructionFiles(resolvedAgents, framework);
5977
}
6078

6179
return { agents: resolvedAgents, assistants: resolvedAssistants };
@@ -82,7 +100,7 @@ const AI_ASSISTANT_CHECKBOX_CHOICES = [
82100
}))
83101
];
84102

85-
export async function promptForAgents(): Promise<AIAgentOption[]> {
103+
async function promptForAgents(): Promise<AIAgentOption[]> {
86104
let selected: AIAgentOption[] = AI_AGENT_CHECKBOX_DEFAULTS;
87105
if (Util.canPrompt()) {
88106
const result = await InquirerWrapper.checkbox({
@@ -95,7 +113,7 @@ export async function promptForAgents(): Promise<AIAgentOption[]> {
95113
return selected;
96114
}
97115

98-
export async function promptForAssistant(): Promise<AIAssistantOption[]> {
116+
async function promptForAssistant(): Promise<AIAssistantOption[]> {
99117
let selected: AIAssistantOption[] = AI_ASSISTANT_CHECKBOX_DEFAULTS;
100118
if (Util.canPrompt()) {
101119
const result = await InquirerWrapper.checkbox({
@@ -108,6 +126,23 @@ export async function promptForAssistant(): Promise<AIAssistantOption[]> {
108126
return selected;
109127
}
110128

129+
/** delayed call so it's not immediate on module import for testing purposes */
130+
function getTemplateManager(): BaseTemplateManager {
131+
return App.container.get<BaseTemplateManager>(TEMPLATE_MANAGER);
132+
}
133+
134+
/** Separate from the PromptSession prompt due to step by step config */
135+
async function promptForFrameworkId(): Promise<string> {
136+
const tm = getTemplateManager();
137+
const frameRes: string = await InquirerWrapper.select({
138+
name: "framework",
139+
message: "Choose framework:",
140+
choices: tm.getFrameworkNames(),
141+
default: "Angular"
142+
});
143+
return tm.getFrameworkByName(frameRes).id;
144+
}
145+
111146
const command: CommandModule = {
112147
command: "ai-config",
113148
describe: "Configures Ignite UI AI tooling (MCP servers, AI coding skills and instructions)",
@@ -122,6 +157,12 @@ const command: CommandModule = {
122157
describe: "Coding assistant(s) to configure MCP servers for",
123158
choices: [...AI_ASSISTANT_CHOICES, "none"] as string[],
124159
type: "array"
160+
})
161+
.option("framework", {
162+
alias: "f",
163+
describe: "Manually set project framework to configure AI for.",
164+
choices: getTemplateManager()?.getFrameworkIds(),
165+
type: "string"
125166
}),
126167
async handler(argv: ArgumentsCamelCase) {
127168
const agents = (argv.agents ?? []) as AIAgentOption[];
@@ -131,7 +172,20 @@ const command: CommandModule = {
131172
cd: "Ai Config"
132173
});
133174

134-
const result = await configure(agents, assistants);
175+
let framework: string = argv.framework as string ?? detectFramework();
176+
if (!framework) {
177+
Util.log("Framework not provided and couldn't detect project from config or structure.");
178+
if (Util.canPrompt()) {
179+
framework = await promptForFrameworkId();
180+
} else {
181+
return Util.error("Please provide --framework argument.", "red");
182+
}
183+
}
184+
if (!getTemplateManager()?.getFrameworkById(framework)) {
185+
return Util.error("Framework not supported", "red");
186+
}
187+
188+
const result = await configure(framework, agents, assistants);
135189

136190
GoogleAnalytics.post({
137191
t: "event",

packages/cli/lib/commands/new.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ const command: NewCommandType = {
162162
}
163163

164164
process.chdir(argv.name);
165-
await configure(argv.agents as (AIAgentTarget | "none")[], argv.assistants as (AiCodingAssistant | "none")[]);
165+
await configure(argv.framework, argv.agents as (AIAgentTarget | "none")[], argv.assistants as (AiCodingAssistant | "none")[]);
166166
process.chdir("..");
167167

168168
Util.log(Util.greenCheck() + " Project Created");

packages/core/prompt/BasePromptSession.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export abstract class BasePromptSession {
7373
}
7474
// move cwd to project folder
7575
process.chdir(projectName);
76-
await this.configureAI();
76+
await this.configureAI(framework.id);
7777
}
7878
await this.chooseActionLoop(projLibrary);
7979
//TODO: restore cwd?
@@ -102,7 +102,7 @@ export abstract class BasePromptSession {
102102
protected abstract upgradePackages();
103103

104104
/** Configure Ignite UI AI tooling (MCP servers and AI coding skills) for the project */
105-
protected abstract configureAI(): Promise<void>;
105+
protected abstract configureAI(frameworkId: string): Promise<void>;
106106

107107
/**
108108
* Get user name and set template's extra configurations if any

packages/core/util/ai-skills.ts

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ import type { BaseTemplateManager } from "../templates";
33
import { FS_TOKEN, IFileSystem } from "../types/FileSystem";
44
import { NPM_ANGULAR, NPM_REACT, NPM_WEBCOMPONENTS, resolvePackage, UPGRADEABLE_PACKAGES } from "../update/package-resolve";
55
import { App } from "./App";
6-
import { detectFrameworkFromPackageJson } from "./detect-framework";
76
import { FsFileSystem } from "./FileSystem";
87
import { TEMPLATE_MANAGER } from "./GlobalConstants";
9-
import { ProjectConfig } from "./ProjectConfig";
108
import { Util } from "./Util";
119

1210
export const AI_AGENT_CHOICES = ["generic", "claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie"] as const;
@@ -86,17 +84,10 @@ function resolveTemplateFilesDir(framework: string): string | null {
8684
* Ignite UI packages that are relevant to the project's detected framework.
8785
* Falls back to the bundled template skills when no npm package is installed.
8886
*/
89-
function resolveSkillsRoots(): string[] {
87+
function resolveSkillsRoots(framework: string): string[] {
9088
const fs = App.container.get<IFileSystem>(FS_TOKEN);
9189
const roots: string[] = [];
9290

93-
let framework: string | null = null;
94-
try {
95-
if (ProjectConfig.hasLocalConfig()) {
96-
framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() ?? null;
97-
}
98-
} catch { /* config not readable – fall through to scan all */ }
99-
10091
const allPkgKeys = Object.keys(UPGRADEABLE_PACKAGES);
10192
let candidates = new Set<string>();
10293
if (framework === "angular") {
@@ -120,12 +111,9 @@ function resolveSkillsRoots(): string[] {
120111

121112
if (!roots.length) {
122113
// if no root discovered, take the root from the appropriate project template files:
123-
framework ??= detectFrameworkFromPackageJson();
124-
if (framework) {
125-
const filesDir = resolveTemplateFilesDir(framework);
126-
if (filesDir) {
127-
roots.push(path.join(filesDir, AI_SKILLS_DIR_NAME));
128-
}
114+
const filesDir = resolveTemplateFilesDir(framework);
115+
if (filesDir) {
116+
roots.push(path.join(filesDir, AI_SKILLS_DIR_NAME));
129117
}
130118
}
131119

@@ -137,14 +125,14 @@ function resolveSkillsRoots(): string[] {
137125
* skills directories for each of the given AI agents.
138126
* @param agents – list of AI agent targets to copy skills for
139127
*/
140-
export function copyAISkillsToProject(agents: AIAgentTarget[]): AISkillsCopyResult {
128+
export function copyAISkillsToProject(agents: AIAgentTarget[], framework: string): AISkillsCopyResult {
141129
const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0 };
142130
// Source reads (glob + readFile) always use physical FS - skill files can
143131
// come from sources outside the project virtual tree (external/global package):
144132
const srcFs = new FsFileSystem();
145133
// Destination writes respect the App FS (which may be virtual):
146134
const destFs = App.container.get<IFileSystem>(FS_TOKEN);
147-
const skillsRoots = resolveSkillsRoots();
135+
const skillsRoots = resolveSkillsRoots(framework);
148136

149137
if (!skillsRoots.length) {
150138
return result;
@@ -198,20 +186,8 @@ export function copyAISkillsToProject(agents: AIAgentTarget[]): AISkillsCopyResu
198186
* Resolves the AGENTS.md source file content from the bundled project template files.
199187
* AGENTS.md lives only in the template files/ directory, not in npm packages.
200188
*/
201-
function resolveAgentsContent(): string | null {
202-
let framework: string | null = null;
203-
try {
204-
if (ProjectConfig.hasLocalConfig()) {
205-
framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() ?? null;
206-
}
207-
} catch { /* fall through */ }
208-
framework ??= detectFrameworkFromPackageJson();
209-
210-
if (!framework) {
211-
return null;
212-
}
213-
214-
const filesDir = resolveTemplateFilesDir(framework);
189+
function resolveAgentsContent(framework: string): string | null {
190+
const filesDir = resolveTemplateFilesDir(framework.toLowerCase());
215191
if (!filesDir) {
216192
return null;
217193
}
@@ -228,8 +204,8 @@ function resolveAgentsContent(): string | null {
228204
* each of the given agents.
229205
* @param agents – list of AI agent targets to create instruction files for
230206
*/
231-
export function copyAgentInstructionFiles(agents: AIAgentTarget[]): void {
232-
const content = resolveAgentsContent();
207+
export function copyAgentInstructionFiles(agents: AIAgentTarget[], framework: string): void {
208+
const content = resolveAgentsContent(framework);
233209
if (!content) {
234210
return;
235211
}

packages/core/util/detect-framework.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import { App } from "./App";
22
import { IFileSystem, FS_TOKEN } from "../types/FileSystem";
3+
import { ProjectConfig } from "./ProjectConfig";
4+
5+
type Framework = "angular" | "react" | "webcomponents";
6+
7+
/**
8+
* Attempt to detect project framework by first checking for local cli-config,
9+
* then falling back to package.json analysis of `detectFrameworkFromPackageJson()`.
10+
* @returns The detected framework Id or `null` if no framework could be detected.
11+
*/
12+
export function detectFramework(): Framework | null {
13+
let framework: Framework | null = null;
14+
try {
15+
// try project config first:
16+
if (ProjectConfig.hasLocalConfig()) {
17+
framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() as Framework ?? null;
18+
}
19+
} catch { /* fall through */ }
20+
21+
framework ??= detectFrameworkFromPackageJson();
22+
23+
return framework;
24+
}
325

426
/**
527
* Attempts to detect the front-end framework by inspecting for well-known,
@@ -12,7 +34,7 @@ import { IFileSystem, FS_TOKEN } from "../types/FileSystem";
1234
* - "webcomponents"→ fallback when neither of the above is found
1335
* - `null` if `package.json` is absent or cannot be parsed.
1436
*/
15-
export function detectFrameworkFromPackageJson(): "angular" | "react" | "webcomponents" | null {
37+
export function detectFrameworkFromPackageJson(): Framework | null {
1638
const fs = App.container.get<IFileSystem>(FS_TOKEN);
1739
if (!fs.fileExists("./package.json")) {
1840
return null;

packages/ng-schematics/src/cli-config/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ function aiConfig({ init, agents, assistants }: { init: boolean; agents: AIAgent
132132
if (init) {
133133
appInit(tree);
134134
}
135-
copyAISkillsToProject(agents);
136-
copyAgentInstructionFiles(agents);
135+
copyAISkillsToProject(agents, "angular");
136+
copyAgentInstructionFiles(agents, "angular");
137137

138138
const angularCliServer: Record<string, McpServerEntry> = {
139139
"angular-cli": {

packages/ng-schematics/src/cli-config/index_spec.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,8 @@ export const appConfig: ApplicationConfig = {
332332
await runner.runSchematic("ai-config", {}, tree);
333333

334334
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1);
335-
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "generic"]);
336-
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "generic"]);
335+
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "generic"], "angular");
336+
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "generic"], "angular");
337337
});
338338

339339
it("should create .vscode/mcp.json with igniteui and angular-cli servers when file does not exist", async () => {
@@ -407,30 +407,30 @@ export const appConfig: ApplicationConfig = {
407407
it("should pass agents when agents option is provided", async () => {
408408
await runner.runSchematic("ai-config", { agents: ["cursor"] }, tree);
409409

410-
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["cursor"]);
411-
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["cursor"]);
410+
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["cursor"], "angular");
411+
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["cursor"], "angular");
412412
});
413413

414414
it("should pass agents for copilot agents", async () => {
415415
await runner.runSchematic("ai-config", { agents: ["copilot"] }, tree);
416416

417-
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["copilot"]);
418-
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["copilot"]);
417+
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["copilot"], "angular");
418+
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["copilot"], "angular");
419419
});
420420

421421
it("should pass agents for generic agents", async () => {
422422
await runner.runSchematic("ai-config", { agents: ["generic"] }, tree);
423423

424-
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["generic"]);
425-
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["generic"]);
424+
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["generic"], "angular");
425+
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["generic"], "angular");
426426
});
427427

428428
it("should configure multiple agents", async () => {
429429
await runner.runSchematic("ai-config", { agents: ["claude", "cursor"] }, tree);
430430

431431
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1);
432-
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "cursor"]);
433-
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "cursor"]);
432+
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "cursor"], "angular");
433+
expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "cursor"], "angular");
434434
});
435435

436436
it("should default MCP config to .vscode/mcp.json with servers key", async () => {

packages/ng-schematics/src/ng-new/index_spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe("Schematics ng-new", () => {
131131
expect(mockFunc[1]).toHaveBeenCalled();
132132
}
133133
expect(AppProjectSchematic.default).toHaveBeenCalled();
134-
expect(tree.files.length).toEqual(2);
134+
expect(tree.files.length).toBeGreaterThanOrEqual(2);
135135
expect(tree.exists(`${workingDirectory}/.gitignore`)).toBeTruthy();
136136
const taskOptions = runner.tasks.map(task => task.options);
137137
const expectedInstall: NodePackageTaskOptions = {
@@ -199,7 +199,7 @@ describe("Schematics ng-new", () => {
199199

200200
const tree = await runner.runSchematic("ng-new", { version: "8.0.3", name: workingDirectory }, myTree);
201201
expect(AppProjectSchematic.default).toHaveBeenCalled();
202-
expect(tree.files.length).toEqual(2);
202+
expect(tree.files.length).toBeGreaterThanOrEqual(2);
203203
expect(tree.exists(`${workingDirectory}/.gitignore`)).toBeTruthy();
204204
const taskOptions = runner.tasks.map(task => task.options);
205205
const expectedInstall: NodePackageTaskOptions = {

0 commit comments

Comments
 (0)