diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f917b0f..64c46411b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ * **Template package updates:** updated scaffolded package references for Angular, React, and Web Components templates — `igniteui-angular` to `~21.2.5`, `igniteui-react` / `igniteui-react-grids` / `igniteui-react-dockmanager` to `~19.7.0`, `igniteui-webcomponents` to `~7.2.0`, and `igniteui-webcomponents-grids` to `~7.1.0`. * **MCP — API reference links in docs:** Infragistics API documentation links embedded in the docs served by the MCP server are now rewritten to deterministic `get_api_reference` tool references (e.g. `mcp:get_api_reference?platform=webcomponents&component=IgcCheckboxComponent&member=checked`). AI assistants reading the docs resolve API links through the in-tool API lookup instead of fetching external HTML pages. * **MCP `get_api_reference` — member-level lookup:** The `get_api_reference` tool now accepts an optional `member` parameter to return a single property, method, or event entry instead of the full component (for example `member="checked"`). It takes precedence over `section`, tolerates `Component#member` fragment-style references, reports the canonical member-name casing in the response, and returns a clear error when the requested member does not exist on the component. +* Improved `ig ai-config` command output: + - Shared AI config utilities now follow a return-only pattern — callers handle all logging, eliminating duplicate output when invoked from Angular schematics. + - The command now reports how the project framework was detected (e.g., from `package.json`, `.csproj`, or project configuration). + - Skills source discovery feedback: logs the installed package name and version (e.g., `igniteui-angular@21.1.0`) or indicates bundled skills are being used. # 15.2.1 (2026-05-20) diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index 1b170ff25..0bf4d29de 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -39,7 +39,7 @@ export class PromptSession extends BasePromptSession { } protected override async configureAI(frameworkId: string): Promise { - await aiConfigure(frameworkId); + await aiConfigure(frameworkId, { verbose: false }); } protected override templateSelectedTask(type: "component" | "view" = "component"): Task { diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 0b94ed4ab..7e6d7335b 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -16,6 +16,7 @@ import { App, type BaseTemplateManager, TEMPLATE_MANAGER, + type AISkillsCopyResult, } from "@igniteui/cli-core"; import { ArgumentsCamelCase, CommandModule } from "yargs"; @@ -25,32 +26,77 @@ export function configureMCP(assistants: AiCodingAssistant[]): void { const modified = addMcpServers(assistant); if (!modified) { - Util.log(` Ignite UI MCP servers already configured in ${mcpFilePath}`); + Util.log(Util.greenCheck() + ` Ignite UI MCP servers already configured in ${mcpFilePath}`); } else { Util.log(Util.greenCheck() + ` MCP servers configured in ${mcpFilePath}`); } } } -export function configureSkills(agents: AIAgentTarget[], framework: string): void { +function logFileActions(result: AISkillsCopyResult): void { + for (const detail of result.details) { + if (detail.action === "created") { + Util.log(` Created ${detail.path}`); + } else if (detail.action === "updated") { + Util.log(` Updated ${detail.path}`); + } + } +} + +function logResultSummary(result: AISkillsCopyResult, label: string): void { + if (result.failed > 0) { + Util.warn(`Failed to write ${result.failed} ${label} file(s) out of ${result.found}.`, "yellow"); + } else if (result.found > 0 && result.skipped === result.found) { + Util.log(Util.greenCheck() + ` ${label} file(s) already up-to-date.`); + } else if (result.found > 0) { + const written = result.found - result.skipped; + Util.log(Util.greenCheck() + ` ${written} ${label} file(s) created or updated.`); + } +} + +export function configureSkills(agents: AIAgentTarget[], framework: string, verbose = true): void { const result = copyAISkillsToProject(agents, framework); if (result.found === 0) { Util.warn("No AI skill files found. Make sure packages are installed (npm install) " + "and your Ignite UI packages are up-to-date.", "yellow"); - } else if (result.failed > 0) { - Util.warn(`Failed to write ${result.failed} skill file(s) out of ${result.found}.`, "yellow"); - } else if (result.skipped === result.found) { - Util.log("Everything is already up-to-date."); - } else { - const written = result.found - result.skipped; - Util.log(Util.greenCheck() + ` ${written} AI skill file(s) created or updated.`); + return; } + + if (verbose) { + for (const source of result.sources) { + if (source.type === "package") { + Util.log(`Using skills from ${source.packageName}@${source.packageVersion}`); + } else { + Util.log("Using bundled Ignite UI skills"); + } + } + logFileActions(result); + } + + logResultSummary(result, "AI skill"); +} + +export function configureInstructions(agents: AIAgentTarget[], framework: string, verbose = true): void { + const result = copyAgentInstructionFiles(agents, framework); + if (verbose) { + logFileActions(result); + } + logResultSummary(result, "instruction"); } type AIAgentOption = AIAgentTarget | "none"; type AIAssistantOption = AiCodingAssistant | "none"; -export async function configure(framework: string, agents: AIAgentOption[] = [], assistants: AIAssistantOption[] = [], skills = true): Promise<{ agents: AIAgentTarget[], assistants: AiCodingAssistant[] }> { +interface ConfigureOptions { + agents?: AIAgentOption[]; + assistants?: AIAssistantOption[]; + skills?: boolean; + verbose?: boolean; +} + +export async function configure(framework: string, options: ConfigureOptions = {}): Promise<{ agents: AIAgentTarget[], assistants: AiCodingAssistant[] }> { + const { skills = true, verbose = true } = options; + let { agents = [], assistants = [] } = options; if (framework === "jquery") { // currently not supported return { agents: [], assistants: [] }; @@ -76,9 +122,9 @@ export async function configure(framework: string, agents: AIAgentOption[] = [], Util.log("No AI configuration selected. Skipping."); } else { if (skills) { - configureSkills(resolvedAgents, framework); + configureSkills(resolvedAgents, framework, verbose); } - copyAgentInstructionFiles(resolvedAgents, framework); + configureInstructions(resolvedAgents, framework, verbose); } return { agents: resolvedAgents, assistants: resolvedAssistants }; @@ -194,7 +240,7 @@ const command: CommandModule = { Util.log("AI Config currently not available for jQuery projects."); } - const result = await configure(framework, agents, assistants); + const result = await configure(framework, { agents, assistants }); GoogleAnalytics.post({ t: "event", diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index 852ed0ce2..29c7d18c7 100644 --- a/packages/cli/lib/commands/new.ts +++ b/packages/cli/lib/commands/new.ts @@ -162,7 +162,10 @@ const command: NewCommandType = { } process.chdir(argv.name); - await configure(argv.framework, argv.agents as (AIAgentTarget | "none")[], argv.assistants as (AiCodingAssistant | "none")[]); + await configure(argv.framework, { + agents: argv.agents as (AIAgentTarget | "none")[], + assistants: argv.assistants as (AiCodingAssistant | "none")[] + }); process.chdir(".."); Util.log(Util.greenCheck() + " Project Created"); diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 3f0d8615e..b9afde8fc 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -5,7 +5,7 @@ import { NPM_ANGULAR, NPM_REACT, NPM_WEBCOMPONENTS, resolvePackage, UPGRADEABLE_ import { App } from "./App"; import { FsFileSystem } from "./FileSystem"; import { TEMPLATE_MANAGER } from "./GlobalConstants"; -import { Util } from "./Util"; + export const AI_AGENT_CHOICES = ["generic", "claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie"] as const; export type AIAgentTarget = typeof AI_AGENT_CHOICES[number]; @@ -57,12 +57,27 @@ export function getInstructionFilePath(target: AIAgentTarget): string { return AI_AGENT_INSTRUCTION_FILES[target]; } +export type AIFileAction = "created" | "updated" | "skipped"; + +export interface AIFileActionDetail { + path: string; + action: AIFileAction; +} + export interface AISkillsCopyResult { found: number; skipped: number; failed: number; + details: AIFileActionDetail[]; + sources: SkillsSource[]; } +export type SkillsSourceType = "package" | "bundled"; + +export type SkillsSource = + | { type: "package"; packageName: string; packageVersion: string; path: string } + | { type: "bundled"; path: string }; + export const AGENTS_TEMPLATE_FILE = "AGENTS.md"; export const AI_CONFIG_PROJECT_ID = "ai-config"; export const AI_SKILLS_DIR_NAME = "skills"; @@ -80,13 +95,13 @@ function resolveTemplateFilesDir(framework: string): string | null { } /** - * Returns the list of 'skills/' directory paths found in installed + * Returns the list of skills sources found in installed * Ignite UI packages that are relevant to the project's detected framework. * Falls back to the bundled template skills when no npm package is installed. */ -function resolveSkillsRoots(framework: string): string[] { +function resolveSkillsRoots(framework: string): SkillsSource[] { const fs = App.container.get(FS_TOKEN); - const roots: string[] = []; + const roots: SkillsSource[] = []; const allPkgKeys = Object.keys(UPGRADEABLE_PACKAGES); let candidates = new Set(); @@ -101,11 +116,17 @@ function resolveSkillsRoots(framework: string): string[] { candidates = new Set([...allPkgKeys, NPM_REACT, NPM_WEBCOMPONENTS]); } + const srcFs = new FsFileSystem(); for (const pkg of candidates) { const resolved = resolvePackage(pkg as keyof typeof UPGRADEABLE_PACKAGES); const skillsRoot = `node_modules/${resolved}/${AI_SKILLS_DIR_NAME}`; - if (fs.directoryExists(skillsRoot) && !roots.includes(skillsRoot)) { - roots.push(skillsRoot); + if (fs.directoryExists(skillsRoot) && !roots.some(r => r.path === skillsRoot)) { + let version = "unknown"; + try { + const pkgJson = JSON.parse(srcFs.readFile(`node_modules/${resolved}/package.json`)); + version = pkgJson.version ?? "unknown"; + } catch { /* version stays unknown */ } + roots.push({ type: "package", packageName: resolved, packageVersion: version, path: skillsRoot }); } } @@ -113,7 +134,7 @@ function resolveSkillsRoots(framework: string): string[] { // if no root discovered, take the root from the appropriate project template files: const filesDir = resolveTemplateFilesDir(framework); if (filesDir) { - roots.push(path.join(filesDir, AI_SKILLS_DIR_NAME)); + roots.push({ type: "bundled", path: path.join(filesDir, AI_SKILLS_DIR_NAME) }); } } @@ -126,24 +147,26 @@ function resolveSkillsRoots(framework: string): string[] { * @param agents – list of AI agent targets to copy skills for */ export function copyAISkillsToProject(agents: AIAgentTarget[], framework: string): AISkillsCopyResult { - const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0 }; + const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0, details: [], sources: [] }; // Source reads (glob + readFile) always use physical FS - skill files can // come from sources outside the project virtual tree (external/global package): const srcFs = new FsFileSystem(); // Destination writes respect the App FS (which may be virtual): const destFs = App.container.get(FS_TOKEN); - const skillsRoots = resolveSkillsRoots(framework); + const skillsSources = resolveSkillsRoots(framework); - if (!skillsRoots.length) { + if (!skillsSources.length) { return result; } - const multiRoot = skillsRoots.length > 1; + result.sources = skillsSources; + const multiRoot = skillsSources.length > 1; for (const agent of agents) { const outputDir = getSkillsDir(agent); - for (const skillsRoot of skillsRoots) { + for (const source of skillsSources) { + const skillsRoot = source.path; const rawPaths = srcFs.glob(skillsRoot, "**/*"); const pkgDirName = multiRoot ? path.basename(path.dirname(skillsRoot)) : ""; @@ -164,13 +187,14 @@ export function copyAISkillsToProject(agents: AIAgentTarget[], framework: string const existingContent = destFs.readFile(dest); if (existingContent === newContent) { result.skipped++; + result.details.push({ path: dest, action: "skipped" }); continue; } destFs.writeFile(dest, newContent); - Util.log(`${Util.greenCheck()} Updated ${dest}`); + result.details.push({ path: dest, action: "updated" }); } else { destFs.writeFile(dest, newContent); - Util.log(`${Util.greenCheck()} Created ${dest}`); + result.details.push({ path: dest, action: "created" }); } } catch { result.failed++; @@ -204,15 +228,17 @@ function resolveAgentsContent(framework: string): string | null { * each of the given agents. * @param agents – list of AI agent targets to create instruction files for */ -export function copyAgentInstructionFiles(agents: AIAgentTarget[], framework: string): void { +export function copyAgentInstructionFiles(agents: AIAgentTarget[], framework: string): AISkillsCopyResult { + const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0, details: [], sources: [] }; const content = resolveAgentsContent(framework); if (!content) { - return; + return result; } const destFs = App.container.get(FS_TOKEN); for (const agent of agents) { + result.found++; const dest = getInstructionFilePath(agent); const fileContent = agent === "cursor" ? `---\ncontext: true\npriority: high\nscope: project\n---\n${content}` @@ -221,16 +247,20 @@ export function copyAgentInstructionFiles(agents: AIAgentTarget[], framework: st if (destFs.fileExists(dest)) { const existing = destFs.readFile(dest); if (existing === fileContent) { + result.skipped++; + result.details.push({ path: dest, action: "skipped" }); continue; } destFs.writeFile(dest, fileContent); - Util.log(`${Util.greenCheck()} Updated ${dest}`); + result.details.push({ path: dest, action: "updated" }); } else { destFs.writeFile(dest, fileContent); - Util.log(`${Util.greenCheck()} Created ${dest}`); + result.details.push({ path: dest, action: "created" }); } } catch { - /* skip on error */ + result.failed++; } } + + return result; } diff --git a/packages/core/util/detect-framework.ts b/packages/core/util/detect-framework.ts index dea57d8b7..cee0a827c 100644 --- a/packages/core/util/detect-framework.ts +++ b/packages/core/util/detect-framework.ts @@ -1,18 +1,19 @@ import { App } from "./App"; import { IFileSystem, FS_TOKEN } from "../types/FileSystem"; import { ProjectConfig } from "./ProjectConfig"; +import { Util } from "./Util"; type Framework = "angular" | "react" | "webcomponents" | "blazor" | "jquery"; /** * Attempt to detect project framework by first checking for local cli-config, - * then falling back to package.json analysis of `detectFrameworkFromPackageJson()`. + * then falling back to .csproj / package.json analysis. + * Logs the detection source when a framework is found. * @returns The detected framework Id or `null` if no framework could be detected. */ export function detectFramework(): Framework | null { let framework: Framework | null = null; try { - // try project config first: if (ProjectConfig.hasLocalConfig()) { framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() as Framework ?? null; } @@ -54,13 +55,16 @@ export function detectFrameworkFromPackageJson(): Exclude | ]); if (deps.has("@angular/core")) { + Util.log("Detected Angular project (from package.json)"); return "angular"; } if (deps.has("react")) { + Util.log("Detected React project (from package.json)"); return "react"; } // for now assume webcomponents as default fallback + Util.log("Assuming Web Components (no Angular/React deps found in package.json)"); return "webcomponents"; } @@ -135,6 +139,10 @@ export function detectBlazorFromCsproj(): boolean { } } - return csprojFiles.some(csproj => isBlazorProject(fs, csproj)); + const detected = csprojFiles.some(csproj => isBlazorProject(fs, csproj)); + if (detected) { + Util.log("Detected Blazor project (from .csproj)"); + } + return detected; } //#endregion Blazor detection diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index f6987d70f..b5f974f57 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -315,7 +315,7 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledWith(" Project structure generated."); expect(Util.gitInit).toHaveBeenCalled(); expect(mockSession.chooseActionLoop).toHaveBeenCalled(); - expect(aiConfig.configure).toHaveBeenCalledOnceWith("angular"); + expect(aiConfig.configure).toHaveBeenCalledOnceWith("angular", jasmine.objectContaining({ verbose: false })); }); it("start - New project - missing IFs", async () => { const mockProject = { diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 20db6b95d..857dcc2cd 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -1,7 +1,7 @@ import * as path from "path"; import { App, Config, FS_TOKEN, FsFileSystem, GoogleAnalytics, IFileSystem, InquirerWrapper, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; import * as coreDetect from "../../packages/core/util/detect-framework"; -import { configureMCP, configureSkills } from "../../packages/cli/lib/commands/ai-config"; +import { configureMCP, configureSkills, configureInstructions } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; import { addMcpServers } from "../../packages/core/util/mcp-config"; @@ -220,8 +220,10 @@ describe("Unit - ai-config command", () => { configureSkills(["claude"], "angular"); - expect(Util.warn).toHaveBeenCalledWith(jasmine.stringContaining("Failed to write 1 skill file(s) out of 1"), "yellow"); - expect(Util.log).not.toHaveBeenCalled(); + expect(Util.warn).toHaveBeenCalledWith(jasmine.stringContaining("Failed to write 1 AI skill file(s) out of 1"), "yellow"); + const logCalls = (Util.log as jasmine.Spy).calls.allArgs().map(a => a[0] as string); + expect(logCalls.some(m => m.includes("Created"))).toBe(false); + expect(logCalls.some(m => m.includes("Updated"))).toBe(false); }); it("logs up-to-date when all files are already current", () => { @@ -283,6 +285,308 @@ describe("Unit - ai-config command", () => { expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("1 AI skill file(s) created or updated")); expect(Util.warn).not.toHaveBeenCalled(); }); + + it("logs per-file Created messages for new skill files", () => { + const skillFile = `${angularSkillsDir}/angular.md`; + const mockFs: IFileSystem = { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ), + readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ) + } as unknown as IFileSystem; + + spyOn(App.container, "get").and.returnValue(mockFs); + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); + + configureSkills(["claude"], "angular"); + + expect(Util.log).toHaveBeenCalledWith(" Created .claude/skills/angular.md"); + }); + + it("logs per-file Updated messages for changed skill files", () => { + const skillFile = `${angularSkillsDir}/angular.md`; + const destFile = ".claude/skills/angular.md"; + const mockFs: IFileSystem = { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" || p === destFile + ), + readFile: jasmine.createSpy("readFile").and.returnValue("old content"), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ) + } as unknown as IFileSystem; + + spyOn(App.container, "get").and.returnValue(mockFs); + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("new content"); + + configureSkills(["claude"], "angular"); + + expect(Util.log).toHaveBeenCalledWith(" Updated .claude/skills/angular.md"); + }); + + it("does not log per-file messages for skipped (up-to-date) files", () => { + const skillFile = `${angularSkillsDir}/angular.md`; + const destFile = ".claude/skills/angular.md"; + const content = "# Ignite UI skills"; + const mockFs: IFileSystem = { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" || p === destFile + ), + readFile: jasmine.createSpy("readFile").and.returnValue(content), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ) + } as unknown as IFileSystem; + + spyOn(App.container, "get").and.returnValue(mockFs); + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content); + + configureSkills(["claude"], "angular"); + + const logCalls = (Util.log as jasmine.Spy).calls.allArgs().map(a => a[0] as string); + expect(logCalls.some(m => m.includes("Created"))).toBe(false); + expect(logCalls.some(m => m.includes("Updated"))).toBe(false); + }); + + it("logs package source with version when skills come from npm package", () => { + const skillFile = `${angularSkillsDir}/angular.md`; + const mockFs: IFileSystem = { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ), + readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ) + } as unknown as IFileSystem; + + spyOn(App.container, "get").and.returnValue(mockFs); + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === "node_modules/igniteui-angular/package.json") { + return JSON.stringify({ version: "18.2.0" }); + } + return "skill content"; + }); + + configureSkills(["claude"], "angular"); + + expect(Util.log).toHaveBeenCalledWith("Using skills from igniteui-angular@18.2.0"); + }); + + it("logs bundled source when skills come from templates", () => { + const mockFs: IFileSystem = { + 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([]) + } as unknown as IFileSystem; + + App.container.set(FS_TOKEN, mockFs); + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { + projectLibraries: [{ + getProject: (id: string) => id === "ai-config" ? { templatePaths: ["/fake/files"] } : null + }] + } + })); + + const fakeSkillsRoot = path.join("/fake/files", "skills"); + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === fakeSkillsRoot ? [path.join(fakeSkillsRoot, "angular.md")] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("# skill content"); + + configureSkills(["claude"], "angular"); + + expect(Util.log).toHaveBeenCalledWith("Using bundled Ignite UI skills"); + }); + + it("suppresses per-file and source messages when verbose is false", () => { + const skillFile = `${angularSkillsDir}/angular.md`; + const mockFs: IFileSystem = { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ), + readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ) + } as unknown as IFileSystem; + + spyOn(App.container, "get").and.returnValue(mockFs); + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === "node_modules/igniteui-angular/package.json") { + return JSON.stringify({ version: "18.2.0" }); + } + return "skill content"; + }); + + configureSkills(["claude"], "angular", false); + + const logCalls = (Util.log as jasmine.Spy).calls.allArgs().map(a => a[0] as string); + expect(logCalls.some(m => m.includes("Created"))).toBe(false); + expect(logCalls.some(m => m.includes("Using skills from"))).toBe(false); + expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("AI skill file(s) created or updated")); + }); + }); + + describe("configureInstructions", () => { + it("logs per-file Created messages for new instruction files", () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("# Instructions content"); + + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { + projectLibraries: [{ + getProject: (id: string) => id === "ai-config" ? { templatePaths: ["/fake/files"] } : null + }] + } + })); + + configureInstructions(["claude"], "angular"); + + expect(Util.log).toHaveBeenCalledWith(" Created .claude/CLAUDE.md"); + expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("1 instruction file(s) created or updated")); + }); + + it("logs per-file Updated messages for changed instruction files", () => { + const destFile = ".claude/CLAUDE.md"; + const mockFs: IFileSystem = { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === destFile + ), + readFile: jasmine.createSpy("readFile").and.returnValue("old instructions"), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists"), + glob: jasmine.createSpy("glob").and.returnValue([]) + }; + App.container.set(FS_TOKEN, mockFs); + + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("new instructions"); + + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { + projectLibraries: [{ + getProject: (id: string) => id === "ai-config" ? { templatePaths: ["/fake/files"] } : null + }] + } + })); + + configureInstructions(["claude"], "angular"); + + expect(Util.log).toHaveBeenCalledWith(" Updated .claude/CLAUDE.md"); + expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("1 instruction file(s) created or updated")); + }); + + it("logs up-to-date when all instruction files are current", () => { + const content = "# Instructions"; + const destFile = ".claude/CLAUDE.md"; + const mockFs: IFileSystem = { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + p === destFile + ), + readFile: jasmine.createSpy("readFile").and.returnValue(content), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists"), + glob: jasmine.createSpy("glob").and.returnValue([]) + }; + App.container.set(FS_TOKEN, mockFs); + + spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content); + + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { + projectLibraries: [{ + getProject: (id: string) => id === "ai-config" ? { templatePaths: ["/fake/files"] } : null + }] + } + })); + + configureInstructions(["claude"], "angular"); + + expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("instruction file(s) already up-to-date")); + }); + + it("does not log when source content is not found", () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + + spyOn(FsFileSystem.prototype, "readFile").and.throwError("ENOENT"); + + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { + projectLibraries: [{ + getProject: (id: string) => id === "ai-config" ? { templatePaths: ["/fake/files"] } : null + }] + } + })); + + configureInstructions(["claude"], "angular"); + + expect(Util.log).not.toHaveBeenCalled(); + }); + + it("suppresses per-file messages but keeps summary when verbose is false", () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("# Instructions content"); + + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { + projectLibraries: [{ + getProject: (id: string) => id === "ai-config" ? { templatePaths: ["/fake/files"] } : null + }] + } + })); + + configureInstructions(["claude"], "angular", false); + + const logCalls = (Util.log as jasmine.Spy).calls.allArgs().map(a => a[0] as string); + expect(logCalls.some(m => m.includes("Created"))).toBe(false); + expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("instruction file(s) created or updated")); + }); }); describe("handler", () => { diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index e2b428a64..1b17377c6 100644 --- a/spec/unit/ai-skills-spec.ts +++ b/spec/unit/ai-skills-spec.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { App, copyAgentInstructionFiles, copyAISkillsToProject, FS_TOKEN, FsFileSystem, getInstructionFilePath, IFileSystem, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { App, copyAgentInstructionFiles, copyAISkillsToProject, FS_TOKEN, FsFileSystem, getInstructionFilePath, IFileSystem, type SkillsSource, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; function skillsDir(pkgName: string) { return `node_modules/${pkgName}/skills`; @@ -88,9 +88,10 @@ describe("Unit - copyAISkillsToProject", () => { }); App.container.set(FS_TOKEN, destFs); - copyAISkillsToProject(["claude"], "angular"); + const result = copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); + expect(result.details).toEqual([{ path: ".claude/skills/angular.md", action: "created" }]); }); it("should prefer the licensed @infragistics/igniteui-angular package if installed", () => { @@ -146,10 +147,10 @@ describe("Unit - copyAISkillsToProject", () => { }); App.container.set(FS_TOKEN, destFs); - copyAISkillsToProject(["claude"], "angular"); + const result = copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", newContent); - expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("Updated .claude/skills/angular.md")); + expect(result.details).toContain(jasmine.objectContaining({ path: ".claude/skills/angular.md", action: "updated" })); }); it("should not write when destination content is already up-to-date", () => { @@ -181,6 +182,7 @@ describe("Unit - copyAISkillsToProject", () => { expect(result.found).toBe(1); expect(result.skipped).toBe(1); expect(result.failed).toBe(0); + expect(result.details).toEqual([{ path: ".claude/skills/angular.md", action: "skipped" }]); expect(Util.log).not.toHaveBeenCalled(); }); }); @@ -260,6 +262,7 @@ describe("Unit - copyAISkillsToProject", () => { const result = copyAISkillsToProject(["claude"], "angular"); expect(result.found).toBe(0); + expect(result.details).toEqual([]); expect(destFs.writeFile).not.toHaveBeenCalled(); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("angular"); }); @@ -310,6 +313,7 @@ describe("Unit - copyAISkillsToProject", () => { expect(result.found).toBe(1); expect(result.skipped).toBe(0); expect(result.failed).toBe(1); + expect(result.details).toEqual([]); }); it("should increment failed when writeFile throws updating an existing file", () => { @@ -342,6 +346,7 @@ describe("Unit - copyAISkillsToProject", () => { expect(result.found).toBe(1); expect(result.skipped).toBe(0); expect(result.failed).toBe(1); + expect(result.details).toEqual([]); }); it("should report correct counts when some writes fail and some succeed", () => { @@ -376,6 +381,9 @@ describe("Unit - copyAISkillsToProject", () => { expect(result.found).toBe(2); expect(result.skipped).toBe(0); expect(result.failed).toBe(1); + expect(result.details).toEqual([ + { path: ".claude/skills/angular.md", action: "created" } + ]); expect(destFs.writeFile).toHaveBeenCalledTimes(2); }); }); @@ -607,7 +615,119 @@ describe("Unit - copyAISkillsToProject", () => { expect(destFs.writeFile).toHaveBeenCalledWith(".agents/skills/angular.md", content); expect(destFs.writeFile).toHaveBeenCalledTimes(3); expect(result.found).toBe(3); + expect(result.details).toEqual([ + { path: ".claude/skills/angular.md", action: "created" }, + { path: ".cursor/skills/angular.md", action: "created" }, + { path: ".agents/skills/angular.md", action: "created" } + ]); + }); + }); +}); + +describe("Unit - copyAISkillsToProject sources metadata", () => { + beforeEach(() => { + spyOn(Util, "log"); + spyOn(Util, "greenCheck").and.returnValue("✓"); + }); + + it("should return package source with name and version when npm package is found", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === "node_modules/igniteui-angular/package.json") { + return JSON.stringify({ name: "igniteui-angular", version: "18.2.0" }); + } + return "# skill content"; + }) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + + const result = copyAISkillsToProject(["claude"], "angular"); + + expect(result.sources.length).toBe(1); + const source = result.sources[0] as SkillsSource & { type: "package" }; + expect(source.type).toBe("package"); + expect(source.packageName).toBe("igniteui-angular"); + expect(source.packageVersion).toBe("18.2.0"); + expect(source.path).toBe(angularSkillsDir); + }); + + it("should return bundled source when no npm package is found", () => { + const FAKE_TEMPLATE_PATH = "/fake/template"; + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => { + const expected = path.join(FAKE_TEMPLATE_PATH, "skills"); + return dir === expected ? [path.join(expected, "angular.md")] : []; + }), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("# skill") + }); + + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + mockTemplateManager([FAKE_TEMPLATE_PATH]); + + const result = copyAISkillsToProject(["claude"], "angular"); + + expect(result.sources.length).toBe(1); + expect(result.sources[0].type).toBe("bundled"); + expect(result.sources[0].path).toBe(path.join(FAKE_TEMPLATE_PATH, "skills")); + }); + + it("should default packageVersion to 'unknown' when package.json is unreadable", () => { + const angularSkillsDir = skillsDir("igniteui-angular"); + const skillFilePath = skillFile("igniteui-angular", "angular.md"); + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === "node_modules/igniteui-angular/package.json") { + throw new Error("ENOENT"); + } + return "# skill content"; + }) }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); + + const result = copyAISkillsToProject(["claude"], "angular"); + + expect(result.sources.length).toBe(1); + const source = result.sources[0] as SkillsSource & { type: "package" }; + expect(source.type).toBe("package"); + expect(source.packageVersion).toBe("unknown"); + }); + + it("should return empty sources when no skills are found anywhere", () => { + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.returnValue([]) + }); + + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + mockTemplateManager([]); + + const result = copyAISkillsToProject(["claude"], "angular"); + + expect(result.sources).toEqual([]); }); }); @@ -663,11 +783,18 @@ describe("Unit - copyAgentInstructionFiles", () => { App.container.set(FS_TOKEN, destFs); mockTemplateManager([FAKE_FILES_DIR]); - copyAgentInstructionFiles(["claude", "cursor"], "angular"); + const result = copyAgentInstructionFiles(["claude", "cursor"], "angular"); const cursorFrontmatter = "---\ncontext: true\npriority: high\nscope: project\n---\n"; expect(destFs.writeFile).toHaveBeenCalledWith(".claude/CLAUDE.md", agentsContent); expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/rules/cursor.mdc", cursorFrontmatter + agentsContent); + expect(result.found).toBe(2); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + expect(result.details).toEqual([ + { path: ".claude/CLAUDE.md", action: "created" }, + { path: ".cursor/rules/cursor.mdc", action: "created" } + ]); }); it("should skip writing when instruction file already has same content", () => { @@ -691,9 +818,13 @@ describe("Unit - copyAgentInstructionFiles", () => { App.container.set(FS_TOKEN, destFs); mockTemplateManager([FAKE_FILES_DIR]); - copyAgentInstructionFiles(["claude"], "angular"); + const result = copyAgentInstructionFiles(["claude"], "angular"); expect(destFs.writeFile).not.toHaveBeenCalled(); + expect(result.found).toBe(1); + expect(result.skipped).toBe(1); + expect(result.failed).toBe(0); + expect(result.details).toEqual([{ path: ".claude/CLAUDE.md", action: "skipped" }]); }); it("should not write anything when AGENTS.md source is not found", () => { @@ -705,9 +836,83 @@ describe("Unit - copyAgentInstructionFiles", () => { App.container.set(FS_TOKEN, destFs); mockTemplateManager(["/fake/files"]); - copyAgentInstructionFiles(["claude", "generic"], "angular"); + const result = copyAgentInstructionFiles(["claude", "generic"], "angular"); expect(destFs.writeFile).not.toHaveBeenCalled(); + expect(result.found).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + expect(result.details).toEqual([]); + }); + + it("should return updated details when overwriting an instruction file with new content", () => { + const oldContent = "# Old instructions"; + const newContent = "# Updated instructions"; + const FAKE_FILES_DIR = "/fake/template/files"; + const claudeDest = ".claude/CLAUDE.md"; + + spySrcFs({ + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(newContent) + }); + + const destFs = makeDestFs({ + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => + p === claudeDest + ), + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { + if (p === claudeDest) return oldContent; + return ""; + }) + }); + App.container.set(FS_TOKEN, destFs); + mockTemplateManager([FAKE_FILES_DIR]); + + const result = copyAgentInstructionFiles(["claude"], "angular"); + + expect(destFs.writeFile).toHaveBeenCalledWith(claudeDest, newContent); + expect(result.found).toBe(1); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + expect(result.details).toEqual([{ path: claudeDest, action: "updated" }]); + }); + + it("should increment failed and not add to details when writeFile throws", () => { + const agentsContent = "# Instructions"; + const FAKE_FILES_DIR = "/fake/template/files"; + + spySrcFs({ + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(agentsContent) + }); + + const destFs = makeDestFs({ + writeFile: jasmine.createSpy("destFs.writeFile").and.throwError("EACCES: permission denied") + }); + App.container.set(FS_TOKEN, destFs); + mockTemplateManager([FAKE_FILES_DIR]); + + const result = copyAgentInstructionFiles(["claude"], "angular"); + + expect(result.found).toBe(1); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(1); + expect(result.details).toEqual([]); + }); + + it("should not call Util.log (logging is caller responsibility)", () => { + const agentsContent = "# Instructions"; + const FAKE_FILES_DIR = "/fake/template/files"; + + spySrcFs({ + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(agentsContent) + }); + + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + mockTemplateManager([FAKE_FILES_DIR]); + + copyAgentInstructionFiles(["claude", "generic"], "angular"); + + expect(Util.log).not.toHaveBeenCalled(); }); }); diff --git a/spec/unit/detect-framework-spec.ts b/spec/unit/detect-framework-spec.ts index 636a7316e..5ed7859ad 100644 --- a/spec/unit/detect-framework-spec.ts +++ b/spec/unit/detect-framework-spec.ts @@ -1,4 +1,4 @@ -import { App, IFileSystem, ProjectConfig } from "@igniteui/cli-core"; +import { App, IFileSystem, ProjectConfig, Util } from "@igniteui/cli-core"; import { detectBlazorFromCsproj, detectFramework, @@ -19,9 +19,13 @@ function makeFs(pkgJson?: object): jasmine.SpyObj { } describe("Unit - detectFrameworkFromPackageJson", () => { + beforeEach(() => { + spyOn(Util, "log"); + }); it("returns null when package.json is absent", () => { spyOn(App.container, "get").and.returnValue(makeFs()); expect(detectFrameworkFromPackageJson()).toBeNull(); + expect(Util.log).not.toHaveBeenCalled(); }); it("returns null when package.json is malformed JSON", () => { @@ -38,6 +42,7 @@ describe("Unit - detectFrameworkFromPackageJson", () => { makeFs({ dependencies: { "@angular/core": "^17.0.0" } }) ); expect(detectFrameworkFromPackageJson()).toBe("angular"); + expect(Util.log).toHaveBeenCalledWith("Detected Angular project (from package.json)"); }); it("detects angular when @angular/core is in devDependencies", () => { @@ -54,6 +59,7 @@ describe("Unit - detectFrameworkFromPackageJson", () => { makeFs({ dependencies: { "react": "^19.0.0", "react-dom": "^19.0.0" } }) ); expect(detectFrameworkFromPackageJson()).toBe("react"); + expect(Util.log).toHaveBeenCalledWith("Detected React project (from package.json)"); }); it("detects react when react is in devDependencies", () => { @@ -68,6 +74,7 @@ describe("Unit - detectFrameworkFromPackageJson", () => { it("returns webcomponents for an empty package.json (no known framework)", () => { spyOn(App.container, "get").and.returnValue(makeFs({})); expect(detectFrameworkFromPackageJson()).toBe("webcomponents"); + expect(Util.log).toHaveBeenCalledWith("Assuming Web Components (no Angular/React deps found in package.json)"); }); it("returns webcomponents when only unrelated packages are present", () => { @@ -103,6 +110,10 @@ describe("Unit - detectFrameworkFromPackageJson", () => { }); describe("Unit - detectFramework", () => { + beforeEach(() => { + spyOn(Util, "log"); + }); + it("returns framework from ProjectConfig when local config is present", () => { spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ @@ -168,9 +179,21 @@ describe("Unit - detectFramework", () => { spyOn(App.container, "get").and.returnValue(fs); expect(detectFramework()).toBe("blazor"); }); + + it("does not log when detection fails", () => { + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + spyOn(App.container, "get").and.returnValue(makeFs()); + + detectFramework(); + + expect(Util.log).not.toHaveBeenCalled(); + }); }); describe("Unit - detectBlazorFromCsproj", () => { + beforeEach(() => { + spyOn(Util, "log"); + }); const makeCsProj = (sdk: string, ...refs: string[]) => ` @@ -208,6 +231,7 @@ describe("Unit - detectBlazorFromCsproj", () => { ); spyOn(App.container, "get").and.returnValue(fs); expect(detectBlazorFromCsproj()).toBe(true); + expect(Util.log).toHaveBeenCalledWith("Detected Blazor project (from .csproj)"); }); it("returns true for a Web SDK project in CWD", () => { @@ -259,6 +283,7 @@ describe("Unit - detectBlazorFromCsproj", () => { ); spyOn(App.container, "get").and.returnValue(fs); expect(detectBlazorFromCsproj()).toBe(false); + expect(Util.log).not.toHaveBeenCalled(); }); it("returns false when no .csproj or solution files exist", () => { diff --git a/spec/unit/new-spec.ts b/spec/unit/new-spec.ts index 22c3eb037..531f16b70 100644 --- a/spec/unit/new-spec.ts +++ b/spec/unit/new-spec.ts @@ -414,7 +414,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", agents: ["claude", "cursor"], _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith("jq", ["claude", "cursor"], undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", jasmine.objectContaining({ agents: ["claude", "cursor"] })); }); it("calls configure with undefined when --agents is not provided", async () => { @@ -422,7 +422,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith("jq", undefined, undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", jasmine.objectContaining({ agents: undefined })); }); it("calls configure with single agent", async () => { @@ -430,7 +430,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", agents: ["generic"], _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith("jq", ["generic"], undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", jasmine.objectContaining({ agents: ["generic"] })); }); it("calls configure before package install", async () => { @@ -475,7 +475,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", skipInstall: true, agents: ["claude"], _: ["new"], $0: "new" }); expect(PackageManager.installPackages).not.toHaveBeenCalled(); - expect(configureSpy).toHaveBeenCalledWith("jq", ["claude"], undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", jasmine.objectContaining({ agents: ["claude"] })); }); it("does not call configure when project creation fails (bad name)", async () => {