Skip to content

Commit c12c228

Browse files
damyanpetevkdinevCopilot
authored
feat(ai-config): skills fallback from project template (#1644)
Co-authored-by: Konstantin Dinev <kdinev@bellumgens.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 670c30d commit c12c228

12 files changed

Lines changed: 420 additions & 20 deletions

File tree

packages/cli/lib/cli.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { App, GoogleAnalytics, Util } from "@igniteui/cli-core";
1+
import { App, GoogleAnalytics, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core";
22
import yargs from "yargs";
33
import {
44
add,
@@ -32,6 +32,8 @@ export async function run(args = null) {
3232
App.initialize();
3333

3434
const templateManager = new TemplateManager();
35+
// TODO: Refactor all code to use TemplateManager from the App container:
36+
App.container.set(TEMPLATE_MANAGER, templateManager);
3537

3638
newCommand.addChoices(templateManager.getFrameworkIds());
3739
newCommand.templateManager = templateManager;

packages/core/templates/BaseTemplateManager.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ export abstract class BaseTemplateManager {
66
protected frameworks: Framework[] = [];
77

88
constructor(private templatesAbsPath: string) {
9+
this.loadFrameworks();
10+
this.loadExternalTemplates();
11+
}
912

13+
/** Populate `frameworks` prop with frameworks from templates */
14+
protected loadFrameworks() {
1015
// read dirs and push dir names into frameworks
1116
const frameworks = Util.getDirectoryNames(this.templatesAbsPath);
1217
// load and initialize templates
1318
for (const framework of frameworks) {
1419
this.frameworks.push(require(path.join(this.templatesAbsPath, framework)) as Framework);
1520
}
16-
17-
// load external templates
18-
this.loadExternalTemplates();
1921
}
2022

2123
public getFrameworkIds(): string[] {

packages/core/util/GlobalConstants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import * as ts from 'typescript';
22
import { EOL } from 'os';
33
import { PropertyAssignment } from '../types';
44

5+
// tokens:
6+
export const TEMPLATE_MANAGER = "TEMPLATE_MANAGER";
7+
58
// TypeScript
69
export const ROUTES_VARIABLE_NAME = 'routes';
710
export const THEN_IDENTIFIER_NAME = 'then';

packages/core/util/ai-skills.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import * as path from "path";
2+
import type { BaseTemplateManager } from "../templates";
3+
import { FS_TOKEN, IFileSystem } from "../types/FileSystem";
4+
import { NPM_ANGULAR, NPM_REACT, NPM_WEBCOMPONENTS, resolvePackage, UPGRADEABLE_PACKAGES } from "../update/package-resolve";
25
import { App } from "./App";
3-
import { IFileSystem, FS_TOKEN } from "../types/FileSystem";
6+
import { detectFrameworkFromPackageJson } from "./detect-framework";
7+
import { TEMPLATE_MANAGER } from "./GlobalConstants";
48
import { ProjectConfig } from "./ProjectConfig";
59
import { Util } from "./Util";
6-
import { NPM_ANGULAR, NPM_REACT, NPM_WEBCOMPONENTS, resolvePackage, UPGRADEABLE_PACKAGES } from "../update/package-resolve";
710

811
const CLAUDE_SKILLS_DIR = ".claude/skills";
12+
const CLAUDE_SKILLS_DIR_TEMPLATE = "__dot__claude/skills";
913

1014
export interface AISkillsCopyResult {
1115
found: number;
@@ -49,6 +53,21 @@ function resolveSkillsRoots(): string[] {
4953
}
5054
}
5155

56+
if (!roots.length) {
57+
// if no root discovered, take the root from the appropriate project template files:
58+
framework ??= detectFrameworkFromPackageJson();
59+
if (framework) {
60+
const templateManager = App.container.get<BaseTemplateManager>(TEMPLATE_MANAGER);
61+
const projectLib = templateManager?.getFrameworkById(framework)?.projectLibraries[0];
62+
const filePaths = projectLib?.getProject(projectLib.projectIds[0]).templatePaths ?? [];
63+
roots.push(
64+
...filePaths
65+
.map((p) => path.join(p, CLAUDE_SKILLS_DIR_TEMPLATE))
66+
.slice(0, 1),
67+
);
68+
}
69+
}
70+
5271
return roots;
5372
}
5473

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { App } from "./App";
2+
import { IFileSystem, FS_TOKEN } from "../types/FileSystem";
3+
4+
/**
5+
* Attempts to detect the front-end framework by inspecting for well-known,
6+
* always-present core framework packages in `dependencies`
7+
* and `devDependencies` of `./package.json`.
8+
*
9+
* Detection rules (evaluated in priority order):
10+
* - "angular" → `@angular/core` is present
11+
* - "react" → `react` is present
12+
* - "webcomponents"→ fallback when neither of the above is found
13+
* - `null` if `package.json` is absent or cannot be parsed.
14+
*/
15+
export function detectFrameworkFromPackageJson(): "angular" | "react" | "webcomponents" | null {
16+
const fs = App.container.get<IFileSystem>(FS_TOKEN);
17+
if (!fs.fileExists("./package.json")) {
18+
return null;
19+
}
20+
21+
let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
22+
try {
23+
pkg = JSON.parse(fs.readFile("./package.json"));
24+
} catch {
25+
return null;
26+
}
27+
28+
const deps = new Set([
29+
...Object.keys(pkg.dependencies ?? {}),
30+
...Object.keys(pkg.devDependencies ?? {})
31+
]);
32+
33+
if (deps.has("@angular/core")) {
34+
return "angular";
35+
}
36+
if (deps.has("react")) {
37+
return "react";
38+
}
39+
40+
// for now assume webcomponents as default fallback
41+
return "webcomponents";
42+
}

packages/core/util/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './ai-skills';
2+
export * from './detect-framework';
23
export * from './GoogleAnalytics';
34
export * from './Util';
45
export * from './ProjectConfig';

packages/ng-schematics/src/SchematicsTemplateManager.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ import * as path from "path";
55
export class SchematicsTemplateManager extends BaseTemplateManager {
66
constructor() {
77
super("");
8+
}
9+
10+
protected override loadFrameworks() {
11+
const projectLibrary = require("@igniteui/angular-templates").default as ProjectLibrary[];
812
this.frameworks.push({
913
id: "angular",
1014
name: "angular",
11-
projectLibraries: require("@igniteui/angular-templates").default as ProjectLibrary[]
15+
projectLibraries: projectLibrary
1216
});
17+
// cache projectIds before swapping to virtFs; TODO: Refactor away folder imports legacy;
18+
projectLibrary[0].projectIds;
1319
}
1420

1521
protected loadFromConfig(filePath: string): Template {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import * as ts from "typescript";
22
import { DependencyNotFoundException } from "@angular-devkit/core";
33
import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics";
44
import * as jsonc from "jsonc-parser";
5-
import { addClassToBody, copyAISkillsToProject, FormatSettings, NPM_ANGULAR, resolvePackage, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core";
5+
import { addClassToBody, App, copyAISkillsToProject, FormatSettings, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core";
66
import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates";
77
import { createCliConfig } from "../utils/cli-config";
88
import { setVirtual } from "../utils/NgFileSystem";
99
import { addFontsToIndexHtml, getProjects, importDefaultTheme } from "../utils/theme-import";
10+
import { SchematicsTemplateManager } from "../SchematicsTemplateManager";
1011

1112
function getDependencyVersion(pkg: string, tree: Tree): string {
1213
const targetFile = "/package.json";
@@ -173,6 +174,9 @@ export function addAIConfig(): Rule {
173174

174175
export default function (): Rule {
175176
return (tree: Tree) => {
177+
App.initialize("angular-cli");
178+
// must be initialized with physical fs first:
179+
App.container.set(TEMPLATE_MANAGER, new SchematicsTemplateManager());
176180
setVirtual(tree);
177181
return chain([
178182
importStyles(),

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@ import * as path from "path";
22

33
import { EmptyTree } from "@angular-devkit/schematics";
44
import { SchematicTestRunner, UnitTestTree } from "@angular-devkit/schematics/testing";
5-
import { FEED_ANGULAR, NPM_ANGULAR } from "@igniteui/cli-core";
5+
import { App, FEED_ANGULAR, NPM_ANGULAR, TEMPLATE_MANAGER } from "@igniteui/cli-core";
6+
import { SchematicsTemplateManager } from "../SchematicsTemplateManager";
67

78
describe("cli-config schematic", () => {
89
const collectionPath = path.join(__dirname, "../collection.json");
910
const runner: SchematicTestRunner = new SchematicTestRunner("cli-schematics", collectionPath);
1011
let tree: UnitTestTree;
1112
const sourceRoot = "src";
13+
// TODO:
14+
// TS compiles export * from "./util", __createBinding defines each re-exported name as an accessor descriptor (getter-only, no setter, writable is N/A on accessor descriptors)
15+
// Require the leaf module directly so spyOn works — cliCore re-exports via __createBinding getter chains
16+
// (index → util/index → ai-skills), making the property non-writable at the top level.
17+
// The leaf module's exports object uses plain assignments, which are writable and spyable.
18+
const aiSkillsModule = require("@igniteui/cli-core/util/ai-skills");
1219

1320
const ngJsonConfig = {
1421
projects: {
@@ -85,13 +92,12 @@ describe("cli-config schematic", () => {
8592
</body>`);
8693
createIgPkgJson();
8794
populatePkgJson();
95+
spyOn(aiSkillsModule, "copyAISkillsToProject");
8896
});
8997

90-
it("should create the needed files correctly", () => {
91-
expect(tree).toBeTruthy();
92-
expect(tree.exists("/angular.json")).toBeTruthy();
93-
expect(tree.exists("/package.json")).toBeTruthy();
94-
expect(tree.exists("/src/index.html"));
98+
it("should set the template manager correctly", async () => {
99+
await runner.runSchematic("cli-config", {}, tree);
100+
expect(App.container.get(TEMPLATE_MANAGER)).toBeInstanceOf(SchematicsTemplateManager);
95101
});
96102

97103
it("should create an ignite-ui-cli.json file correctly", async () => {
@@ -322,6 +328,11 @@ export const appConfig: ApplicationConfig = {
322328
expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] });
323329
});
324330

331+
it("should call copyAISkillsToProject", async () => {
332+
await runner.runSchematic("cli-config", {}, tree);
333+
expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1);
334+
});
335+
325336
it("should add both servers to existing .vscode/mcp.json that has no servers", async () => {
326337
tree.create(mcpFilePath, JSON.stringify({ servers: {} }));
327338

spec/unit/ai-config-spec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as path from "path";
2-
import { App, Config, FS_TOKEN, GoogleAnalytics, IFileSystem, ProjectConfig, Util } from "@igniteui/cli-core";
2+
import { App, Config, FS_TOKEN, GoogleAnalytics, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core";
33
import { configureMCP, configureSkills } from "../../packages/cli/lib/commands/ai-config";
44
import * as aiConfig from "../../packages/cli/lib/commands/ai-config";
55

@@ -141,7 +141,14 @@ describe("Unit - ai-config command", () => {
141141
glob: jasmine.createSpy("glob").and.returnValue([])
142142
} as unknown as IFileSystem;
143143

144-
spyOn(App.container, "get").and.returnValue(mockFs);
144+
spyOn(App.container, "get").and.callFake(token => {
145+
if (token === FS_TOKEN) {
146+
return mockFs;
147+
}
148+
if (token === TEMPLATE_MANAGER) {
149+
return { getFrameworkById: () => null } as any;
150+
}
151+
})
145152
setupAngularConfig();
146153

147154
configureSkills();

0 commit comments

Comments
 (0)