diff --git a/.gitignore b/.gitignore index 837c7d7e7..8c483f388 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # ignore compiled files in packages /packages/**/*.js.map /packages/**/*.js +/packages/**/*.d.ts /scripts/**/*.js.map /scripts/**/*.js /spec/**/*.js.map diff --git a/packages/cli/lib/ai-skills.ts b/packages/cli/lib/ai-skills.ts new file mode 100644 index 000000000..9f521f912 --- /dev/null +++ b/packages/cli/lib/ai-skills.ts @@ -0,0 +1,6 @@ +import { copyAISkillsToProject as _copyFromCore } from "@igniteui/cli-core"; + +export function copyAISkillsToProject(): "copied" | "up-to-date" | "no-source" { + return _copyFromCore(); +} + diff --git a/packages/cli/lib/cli.ts b/packages/cli/lib/cli.ts index 9ab0b7661..a7efd266b 100644 --- a/packages/cli/lib/cli.ts +++ b/packages/cli/lib/cli.ts @@ -3,6 +3,7 @@ import yargs from "yargs"; import { add, ADD_COMMAND_NAME, + addSkills, ALL_COMMANDS, build, config, @@ -47,6 +48,7 @@ export async function run(args = null) { .usage("") // do not show any usage instructions before the commands list .command(newCommand) .command(add) + .command(addSkills) .command(build) .command(start) .command(generate) diff --git a/packages/cli/lib/commands/add-skills.ts b/packages/cli/lib/commands/add-skills.ts new file mode 100644 index 000000000..c40c8b3ae --- /dev/null +++ b/packages/cli/lib/commands/add-skills.ts @@ -0,0 +1,37 @@ +import { GoogleAnalytics, ProjectConfig, Util } from "@igniteui/cli-core"; +import { copyAISkillsToProject } from "../ai-skills"; +import { CommandType } from "./types"; + +const command: CommandType = { + command: "add-skills", + describe: "Copies AI coding skills to the current project", + builder: {}, + async handler() { + GoogleAnalytics.post({ + t: "screenview", + cd: "Add Skills" + }); + + if (!ProjectConfig.hasLocalConfig()) { + Util.error("The add-skills command is supported only on existing project created with igniteui-cli", "red"); + return; + } + + GoogleAnalytics.post({ + t: "event", + ec: "$ig add-skills" + }); + + const result = copyAISkillsToProject(); + if (result === "copied") { + Util.log(Util.greenCheck() + " AI skills added to the project."); + } else if (result === "up-to-date") { + Util.log(Util.greenCheck() + " AI skills are already up to date."); + } else { + Util.warn("No AI skill files found. Make sure packages are installed (npm install) " + + "and your Ignite UI package includes a skills/ directory.", "yellow"); + } + } +}; + +export default command; diff --git a/packages/cli/lib/commands/index.ts b/packages/cli/lib/commands/index.ts index af5a4f3b0..6c6f6e55c 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 addSkills } from "./add-skills"; 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 21575f0a3..6aea0dc84 100644 --- a/packages/cli/lib/commands/types.ts +++ b/packages/cli/lib/commands/types.ts @@ -3,6 +3,7 @@ import { TemplateManager } from "../TemplateManager"; import { AddTemplateArgs, Template } from "@igniteui/cli-core"; export const ADD_COMMAND_NAME = "add"; +export const ADD_SKILLS_COMMAND_NAME = "add-skills"; export const NEW_COMMAND_NAME = "new"; export const BUILD_COMMAND_NAME = "build"; export const START_COMMAND_NAME = "start"; @@ -16,6 +17,7 @@ export const MCP_COMMAND_NAME = "mcp"; export const ALL_COMMANDS = new Set([ ADD_COMMAND_NAME, + ADD_SKILLS_COMMAND_NAME, NEW_COMMAND_NAME, BUILD_COMMAND_NAME, START_COMMAND_NAME, diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index e2c1975ab..18ebe2678 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -1,4 +1,4 @@ -import { checkbox, input, select, Separator } from '@inquirer/prompts'; +import { checkbox, confirm, input, select, Separator } from '@inquirer/prompts'; import { Context } from '@inquirer/type'; // ref - node_modules\@inquirer\input\dist\cjs\types\index.d.ts - bc for some reason this is not publicly exported @@ -32,4 +32,8 @@ export class InquirerWrapper { public static async checkbox(message: InputConfig & { choices: (string | Separator)[] }, context?: Context): Promise { return checkbox(message, context); } + + public static async confirm(message: { message: string; default?: boolean }, context?: Context): Promise { + return confirm(message, context); + } } diff --git a/packages/core/util/FileSystem.ts b/packages/core/util/FileSystem.ts index ddce66d24..b186c1a96 100644 --- a/packages/core/util/FileSystem.ts +++ b/packages/core/util/FileSystem.ts @@ -19,6 +19,7 @@ export class FsFileSystem implements IFileSystem { return fs.readFileSync(filePath).toString(); } public writeFile(filePath: string, text: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, text); } public directoryExists(dirPath: string): boolean { diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts new file mode 100644 index 000000000..9eba76674 --- /dev/null +++ b/packages/core/util/ai-skills.ts @@ -0,0 +1,85 @@ +import * as path from "path"; +import { App } from "./App"; +import { IFileSystem, FS_TOKEN } from "../types/FileSystem"; +import { ProjectConfig } from "./ProjectConfig"; +import { Util } from "./Util"; +import { NPM_ANGULAR, NPM_REACT, resolvePackage, UPGRADEABLE_PACKAGES } from "../update/package-resolve"; + +const CLAUDE_SKILLS_DIR = ".claude/skills"; + +/** + * Returns the list of 'skills/' directory paths found in installed + * Ignite UI packages that are relevant to the project's detected framework. + */ +function resolveSkillsRoots(): string[] { + const fs = App.container.get(FS_TOKEN); + const roots: string[] = []; + + let framework: string | null = null; + try { + if (ProjectConfig.hasLocalConfig()) { + framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() ?? null; + } + } catch { /* config not readable – fall through to scan all */ } + + const allPkgKeys = Object.keys(UPGRADEABLE_PACKAGES); + let candidates: string[]; + if (framework === "angular") { + candidates = [NPM_ANGULAR]; + } else if (framework === "react") { + candidates = [NPM_REACT]; + } else if (framework === "webcomponents") { + candidates = allPkgKeys.filter(k => k.startsWith("igniteui-webcomponents")); + } else { + candidates = allPkgKeys; + } + + for (const pkg of candidates) { + const resolved = resolvePackage(pkg as keyof typeof UPGRADEABLE_PACKAGES); + const skillsRoot = `node_modules/${resolved}/skills`; + if (fs.directoryExists(skillsRoot) && !roots.includes(skillsRoot)) { + roots.push(skillsRoot); + } + } + + return roots; +} + +/** + * Copies skill files from the installed Ignite UI package(s) into .claude/skills/. + * Works with both real FS (CLI) and virtual Tree FS (schematics) through IFileSystem. + */ +export function copyAISkillsToProject(): "copied" | "up-to-date" | "no-source" { + const fs = App.container.get(FS_TOKEN); + const skillsRoots = resolveSkillsRoots(); + + if (!skillsRoots.length) { + return "no-source"; + } + + const multiRoot = skillsRoots.length > 1; + let copied = false; + + for (const skillsRoot of skillsRoots) { + const rawPaths = fs.glob(skillsRoot, "**/*"); + const pkgDirName = multiRoot ? path.basename(path.dirname(skillsRoot)) : ""; + + for (const p of rawPaths) { + // Normalize to posix and strip leading '/' so path.posix.relative works + // across both FsFileSystem (relative paths) and NgTreeFileSystem (tree-rooted paths) + const normP = p.replace(/\\/g, "/").replace(/^\//, ""); + const normRoot = skillsRoot.replace(/\\/g, "/").replace(/^\//, ""); + const rel = path.posix.relative(normRoot, normP); + const dest = multiRoot + ? `${CLAUDE_SKILLS_DIR}/${pkgDirName}/${rel}` + : `${CLAUDE_SKILLS_DIR}/${rel}`; + + if (!fs.fileExists(dest)) { + fs.writeFile(dest, fs.readFile(p)); + Util.log(`${Util.greenCheck()} Created ${dest}`); + copied = true; + } + } + } + return copied ? "copied" : "up-to-date"; +} diff --git a/packages/core/util/index.ts b/packages/core/util/index.ts index b470957d4..b899a3c81 100644 --- a/packages/core/util/index.ts +++ b/packages/core/util/index.ts @@ -1,3 +1,4 @@ +export * from './ai-skills'; export * from './GoogleAnalytics'; export * from './Util'; export * from './ProjectConfig'; diff --git a/packages/ng-schematics/src/add-skills/index.ts b/packages/ng-schematics/src/add-skills/index.ts new file mode 100644 index 000000000..73d393938 --- /dev/null +++ b/packages/ng-schematics/src/add-skills/index.ts @@ -0,0 +1,11 @@ +import { Rule, Tree } from "@angular-devkit/schematics"; +import { copyAISkillsToProject } from "@igniteui/cli-core"; +import { setVirtual } from "../utils/NgFileSystem"; + +export default function (): Rule { + return (tree: Tree) => { + setVirtual(tree); + copyAISkillsToProject(); + return tree; + }; +} diff --git a/packages/ng-schematics/src/add-skills/schema.json b/packages/ng-schematics/src/add-skills/schema.json new file mode 100644 index 000000000..3a1a13c71 --- /dev/null +++ b/packages/ng-schematics/src/add-skills/schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "igniteui-angular-add-skills", + "title": "Add AI coding skills to the project", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 01296af62..c8cebea0b 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -1,12 +1,17 @@ import * as ts from "typescript"; import { DependencyNotFoundException } from "@angular-devkit/core"; import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics"; +import { ScopedTree } from "@angular-devkit/schematics/src/tree/scoped"; import { addClassToBody, FormatSettings, NPM_ANGULAR, resolvePackage, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core"; import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates"; import { createCliConfig } from "../utils/cli-config"; import { setVirtual } from "../utils/NgFileSystem"; import { addFontsToIndexHtml, getProjects, importDefaultTheme } from "../utils/theme-import"; +interface CliConfigOptions { + directory?: string; +} + function getDependencyVersion(pkg: string, tree: Tree): string { const targetFile = "/package.json"; if (tree.exists(targetFile)) { @@ -157,16 +162,18 @@ export function addAIConfig(): Rule { }; } -export default function (): Rule { - return (tree: Tree) => { +export default function (options: CliConfigOptions = {}): Rule { + return (originalTree: Tree, context: SchematicContext) => { + const tree = options.directory ? new ScopedTree(originalTree, options.directory) : originalTree; setVirtual(tree); - return chain([ + const rules: Rule[] = [ importStyles(), addTypographyToProj(), importBrowserAnimations(), createCliConfig(), displayVersionMismatch(), addAIConfig() - ]); + ]; + return chain(rules)(tree, context); }; } diff --git a/packages/ng-schematics/src/cli-config/schema.json b/packages/ng-schematics/src/cli-config/schema.json new file mode 100644 index 000000000..fa0718249 --- /dev/null +++ b/packages/ng-schematics/src/cli-config/schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "igniteui-angular-cli-config", + "title": "Ignite UI for Angular CLI Config Options Schema", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/packages/ng-schematics/src/collection.json b/packages/ng-schematics/src/collection.json index ac4f616c9..30e077a09 100644 --- a/packages/ng-schematics/src/collection.json +++ b/packages/ng-schematics/src/collection.json @@ -31,12 +31,18 @@ }, "cli-config": { "description": "Installs the needed dependencies onto the host application.", - "factory": "./cli-config/index" + "factory": "./cli-config/index", + "schema": "./cli-config/schema.json" }, "upgrade-packages": { "description": "Upgrades to the licensed Ignite UI for Angular packages", "factory": "./upgrade-packages/index", "schema": "./upgrade-packages/schema.json" + }, + "add-skills": { + "description": "Copies AI coding skills to the current project", + "factory": "./add-skills/index", + "schema": "./add-skills/schema.json" } } } diff --git a/packages/ng-schematics/src/component/index.ts b/packages/ng-schematics/src/component/index.ts index 870a1d54b..1f550df0c 100644 --- a/packages/ng-schematics/src/component/index.ts +++ b/packages/ng-schematics/src/component/index.ts @@ -87,6 +87,7 @@ export function component(options: ComponentOptions): Rule { component: projLib.components, custom: projLib.getCustomTemplates() }; + void properties; // cache templates for use inside chooseActionLoop let prompt: SchematicsPromptSession; if (!options.template || !options.name) { prompt = new SchematicsPromptSession(templateManager); diff --git a/packages/ng-schematics/src/ng-new/index.ts b/packages/ng-schematics/src/ng-new/index.ts index bb4deb766..5dc6f835c 100644 --- a/packages/ng-schematics/src/ng-new/index.ts +++ b/packages/ng-schematics/src/ng-new/index.ts @@ -97,11 +97,6 @@ export function newProject(options: OptionsSchema): Rule { const theme = projLibrary.themes[themeIndex]; Util.log(`Project Name: ${options.name}, theme ${theme}`); - // project options: - // cache available views and components, same as in component Schematic - const components = projLibrary.components; - const views = (projLibrary as any).customTemplates; - projectOptions = { projTemplate, theme, @@ -182,6 +177,13 @@ export function newProject(options: OptionsSchema): Rule { installChain.push(gitTask); } + if (!options.skipInstall) { + context.addTask( + new RunSchematicTask("cli-config", {}), + [...installChain] + ); + } + if (!options.skipInstall && !nameProvided) { context.addTask(new RunSchematicTask("start", { directory: options.name }), installChain); } diff --git a/packages/ng-schematics/src/ng-new/index_spec.ts b/packages/ng-schematics/src/ng-new/index_spec.ts index fca7b7216..d6b4fca2e 100644 --- a/packages/ng-schematics/src/ng-new/index_spec.ts +++ b/packages/ng-schematics/src/ng-new/index_spec.ts @@ -206,10 +206,11 @@ describe("Schematics ng-new", () => { commit: true, message: `Initial commit for project` }; - expect(taskOptions.length).toBe(2); + expect(taskOptions.length).toBe(3); expect(mockProject.upgradeIgniteUIPackages).toHaveBeenCalled(); expect(taskOptions).toContain(jasmine.objectContaining(expectedInstall)); expect(taskOptions).toContain(expectedInit); + expect(taskOptions).toContain(jasmine.objectContaining({ name: "cli-config" })); }); }); diff --git a/packages/ng-schematics/src/start/index.ts b/packages/ng-schematics/src/start/index.ts index 30f0e3aef..d12cfc206 100644 --- a/packages/ng-schematics/src/start/index.ts +++ b/packages/ng-schematics/src/start/index.ts @@ -7,7 +7,6 @@ import { setVirtual } from "../utils/NgFileSystem"; export default function(options: any): Rule { return (tree: Tree, _context: SchematicContext) => { return defer(async () => { - // TODO: ScopedTree iffy import setVirtual(options.directory ? new ScopedTree(tree, options.directory) : tree); const config = ProjectConfig.getConfig(); // TODO: call in ng serve directly somehow? diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index d92b28952..c3d8314b2 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -16,6 +16,7 @@ describe("Help command", () => { const originalHelpText: string = `Commands: new [name] creates a project add [template] [name] adds template by its ID + add-skills Copies AI coding skills to the current project build builds the project start starts the project generate generates custom template [aliases: g] diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index f79243762..e3bb1c1a1 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -468,6 +468,7 @@ describe("Unit - PromptSession", () => { spyOn(Util, "log"); spyOn(add, "addTemplate").and.returnValue(Promise.resolve(true)); spyOn(PackageManager, "flushQueue").and.returnValue(Promise.resolve()); + spyOn(PackageManager, "installPackages").and.returnValue(Promise.resolve()); spyOn(start, "start").and.returnValue(Promise.resolve()); spyOn(InquirerWrapper, "select").and.returnValues( @@ -660,6 +661,7 @@ describe("Unit - PromptSession", () => { spyOn(Util, "log"); spyOn(add, "addTemplate").and.returnValue(Promise.resolve(true)); spyOn(PackageManager, "flushQueue").and.returnValue(Promise.resolve()); + spyOn(PackageManager, "installPackages").and.returnValue(Promise.resolve()); //spyOn(start, "start").and.returnValue(Promise.resolve({port: 3333 })); spyOn(start, "start").and.returnValue(Promise.resolve()); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts new file mode 100644 index 000000000..453aa580b --- /dev/null +++ b/spec/unit/ai-skills-spec.ts @@ -0,0 +1,314 @@ +import { App, Config, IFileSystem, ProjectConfig } from "@igniteui/cli-core"; +import { copyAISkillsToProject } from "../../packages/cli/lib/ai-skills"; + +function skillsDir(pkgName: string) { + return `node_modules/${pkgName}/skills`; +} + +function skillFile(pkgName: string, file: string) { + return `${skillsDir(pkgName)}/${file}`; +} + +function makeFs(overrides: Partial = {}): IFileSystem { + return { + fileExists: jasmine.createSpy("fileExists").and.returnValue(false), + readFile: jasmine.createSpy("readFile").and.returnValue(""), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), + glob: jasmine.createSpy("glob").and.returnValue([]), + ...overrides + } as unknown as IFileSystem; +} + +describe("Unit - copyAISkillsToProject", () => { + describe("Angular framework", () => { + it("should copy skills from igniteui-angular into .claude/skills/", async () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const mockSkillContent = "# Ignite UI for Angular skills"; + + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { + if (p === "ignite-ui-cli.json") return true; + if (p === "./package.json") return false; + return false; // dest file does not exist yet + }), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + if (p === skillFilePath) return mockSkillContent; + return ""; + }), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); + }); + + it("should prefer the licensed @infragistics/igniteui-angular package if installed", async () => { + const licensedPkg = "@infragistics/igniteui-angular"; + const angularSkillsDir = skillsDir(licensedPkg); + const skillFilePath = skillFile(licensedPkg, "angular.md"); + + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { + if (p === "ignite-ui-cli.json") return true; + if (p === "./package.json") return true; + return false; + }), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + if (p === "./package.json") { + return JSON.stringify({ dependencies: { [licensedPkg]: "^18.0.0" } }); + } + return "skill content"; + }), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); + }); + + it("should not overwrite an existing skill file", async () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { + if (p === "ignite-ui-cli.json") return true; + if (p === ".claude/skills/angular.md") return true; // already exists + return false; + }), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.returnValue([skillFilePath]), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + }); + + describe("React framework", () => { + it("should copy skills from igniteui-react into .claude/skills/", async () => { + const reactPkg = "igniteui-react"; + const dir = skillsDir(reactPkg); + const file = skillFile(reactPkg, "overview.md"); + const content = "# React overview"; + + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { + if (p === "ignite-ui-cli.json") return true; + if (p === "./package.json") return false; + return false; + }), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + if (p === file) return content; + return ""; + }), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === dir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === dir ? [file] : [] + ), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "react" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", content); + }); + }); + + describe("WebComponents framework", () => { + it("should copy skills from igniteui-webcomponents into .claude/skills/", async () => { + const wcPkg = "igniteui-webcomponents-core"; + const dir = skillsDir(wcPkg); + const file = skillFile(wcPkg, "webcomponents.md"); + const content = "# Ignite UI WebComponents skills"; + + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { + if (p === "ignite-ui-cli.json") return true; + if (p === "./package.json") return false; + return false; + }), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + if (p === file) return content; + return ""; + }), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === dir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === dir ? [file] : [] + ), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "webcomponents" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", content); + }); + }); + + describe("No local config (fallback)", () => { + it("should scan all known packages when no ignite-ui-cli.json is present", async () => { + const angularPkg = "igniteui-angular"; + const dir = skillsDir(angularPkg); + const file = skillFile(angularPkg, "angular.md"); + + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { + // no local config, no package.json, no dest file + return false; + }), + readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === dir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === dir ? [file] : [] + ), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + + await copyAISkillsToProject(); + + // With multiple roots, the dest path is prefixed; angular is the only root found here + // but since we scan ALL packages and only one directory exists, roots.length === 1 → no prefix + expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); + }); + }); + + describe("No skills available", () => { + it("should silently return when no skills directories are found", async () => { + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ), + directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it("should silently return when skills directory exists but is empty", async () => { + const dir = skillsDir("igniteui-angular"); + + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === dir + ), + glob: jasmine.createSpy("glob").and.returnValue([]), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + }); + + describe("Nested skill files", () => { + it("should preserve directory structure when copying nested skill files", async () => { + const pkg = "igniteui-angular"; + const dir = skillsDir(pkg); + const nestedFile = skillFile(pkg, "grids/grid.md"); + const content = "# Grid skills"; + + const fs = makeFs({ + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { + if (p === "ignite-ui-cli.json") return true; + if (p === "./package.json") return false; + return false; + }), + readFile: jasmine.createSpy("readFile").and.returnValue(content), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === dir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === dir ? [nestedFile] : [] + ), + writeFile: jasmine.createSpy("writeFile") + }); + + spyOn(App.container, "get").and.returnValue(fs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + + await copyAISkillsToProject(); + + expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); + }); + }); +}); diff --git a/spec/unit/new-spec.ts b/spec/unit/new-spec.ts index 5860addaa..1fb461dee 100644 --- a/spec/unit/new-spec.ts +++ b/spec/unit/new-spec.ts @@ -74,6 +74,7 @@ describe("Unit - New command", () => { it("Logs error for wrong framework", async () => { spyOn(Util, "error"); + spyOn(Util, "directoryExists").and.returnValue(false); //spied getFrameworkById won't return anything, i.e. not found newCmd.templateManager = jasmine.createSpyObj("TemplateManager", ["getFrameworkById", "getProjectLibrary"]); @@ -88,6 +89,7 @@ describe("Unit - New command", () => { it("Logs error for wrong project type", async () => { spyOn(Util, "error"); + spyOn(Util, "directoryExists").and.returnValue(false); newCmd.templateManager = jasmine.createSpyObj("TemplateManager", { getFrameworkById: {}, // return nothing, i.e. not found @@ -105,6 +107,7 @@ describe("Unit - New command", () => { it("Logs error for wrong project theme", async () => { spyOn(Util, "error"); + spyOn(Util, "directoryExists").and.returnValue(false); const mockProjLib = { getProject: () => { }, @@ -146,6 +149,7 @@ describe("Unit - New command", () => { it("Logs error for unavailable project", async () => { spyOn(Util, "error"); + spyOn(Util, "directoryExists").and.returnValue(false); const mockProjLib = { getProject: () => { }, @@ -200,7 +204,9 @@ describe("Unit - New command", () => { const mockFileSystem = { fileExists: jasmine.createSpy().and.returnValue(false), - readFile: jasmine.createSpy().and.returnValue(JSON.stringify({ key: "value" })) + readFile: jasmine.createSpy().and.returnValue(JSON.stringify({ key: "value" })), + directoryExists: jasmine.createSpy().and.returnValue(false), + glob: jasmine.createSpy().and.returnValue([]) }; spyOn(App.container, 'get').and.returnValue(mockFileSystem); @@ -246,7 +252,9 @@ describe("Unit - New command", () => { const mockFileSystem = { fileExists: jasmine.createSpy().and.returnValue(false), - readFile: jasmine.createSpy().and.returnValue(JSON.stringify({ key: "value" })) + readFile: jasmine.createSpy().and.returnValue(JSON.stringify({ key: "value" })), + directoryExists: jasmine.createSpy().and.returnValue(false), + glob: jasmine.createSpy().and.returnValue([]) }; spyOn(App.container, 'get').and.returnValue(mockFileSystem);