diff --git a/.changeset/cli-custom-modes-error-message.md b/.changeset/cli-custom-modes-error-message.md new file mode 100644 index 00000000000..2c95e40a3e1 --- /dev/null +++ b/.changeset/cli-custom-modes-error-message.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +fix(cli): improve error message for custom mode not found diff --git a/cli/src/commands/__tests__/mode.test.ts b/cli/src/commands/__tests__/mode.test.ts index 42c5c000c95..3b4743ffe1e 100644 --- a/cli/src/commands/__tests__/mode.test.ts +++ b/cli/src/commands/__tests__/mode.test.ts @@ -7,6 +7,14 @@ import { modeCommand } from "../mode.js" import type { CommandContext } from "../core/types.js" import type { ModeConfig } from "../../types/messages.js" +// Mock getSearchedPaths +vi.mock("../../config/customModes.js", () => ({ + getSearchedPaths: vi.fn().mockReturnValue([ + { type: "global", path: "/global/path/custom_modes.yaml", found: false }, + { type: "project", path: "/project/path/.kilocodemodes", found: true, modesCount: 1 }, + ]), +})) + describe("modeCommand", () => { let mockContext: CommandContext let mockAddMessage: ReturnType @@ -60,7 +68,12 @@ describe("modeCommand", () => { sendWebviewMessage: vi.fn().mockResolvedValue(undefined), refreshTerminal: vi.fn().mockResolvedValue(undefined), chatMessages: [], - } + currentTask: undefined, + modelListPageIndex: 0, + modelListFilters: { search: "" }, + updateModelListFilters: vi.fn(), + updateModelListPageIndex: vi.fn(), + } as unknown as CommandContext }) describe("command metadata", () => { @@ -184,6 +197,9 @@ describe("modeCommand", () => { const message = mockAddMessage.mock.calls[0][0] expect(message.type).toBe("error") expect(message.content).toContain('Invalid mode "invalid-mode"') + expect(message.content).toContain("The CLI searched for custom modes in:") + expect(message.content).toContain("/global/path/custom_modes.yaml") + expect(message.content).toContain("/project/path/.kilocodemodes") expect(message.content).toContain("Available modes:") }) @@ -217,6 +233,8 @@ describe("modeCommand", () => { name: "Custom Mode", description: "A custom mode", source: "project", + roleDefinition: "", + groups: ["read", "edit", "browser", "command", "mcp"], } mockContext.customModes = [customMode] mockContext.args = [] @@ -235,6 +253,8 @@ describe("modeCommand", () => { name: "Custom Mode", description: "A custom mode", source: "project", + roleDefinition: "", + groups: ["read", "edit", "browser", "command", "mcp"], } mockContext.customModes = [customMode] mockContext.args = ["custom"] @@ -250,6 +270,8 @@ describe("modeCommand", () => { name: "Custom Mode", description: "A custom mode", source: "project", + roleDefinition: "", + groups: ["read", "edit", "browser", "command", "mcp"], } mockContext.customModes = [customMode] mockContext.args = ["custom"] @@ -266,6 +288,8 @@ describe("modeCommand", () => { name: "Org Mode", description: "An org mode", source: "organization", + roleDefinition: "", + groups: ["read", "edit", "browser", "command", "mcp"], } mockContext.customModes = [orgMode] mockContext.args = [] diff --git a/cli/src/commands/mode.ts b/cli/src/commands/mode.ts index 302d09dac25..3fbea1dfd11 100644 --- a/cli/src/commands/mode.ts +++ b/cli/src/commands/mode.ts @@ -1,6 +1,7 @@ import type { ModeConfig } from "../types/messages.js" import type { Command, ArgumentProviderContext, CommandContext } from "./core/types.js" import { getAllModes } from "../constants/modes/defaults.js" +import { getSearchedPaths } from "../config/customModes.js" async function modeAutocompleteProvider(context: ArgumentProviderContext) { const customModes = context.commandContext?.customModes || [] @@ -46,10 +47,18 @@ function showInvalidModeError( availableSlugs: string[], addMessage: CommandContext["addMessage"], ) { + const searchedPaths = getSearchedPaths() + const pathsDetails = searchedPaths + .map((searched) => { + const status = searched.found ? `found, ${searched.modesCount} mode(s)` : "not found" + return ` • ${searched.type === "global" ? "Global" : "Project"}: ${searched.path} (${status})` + }) + .join("\n") + addMessage({ id: Date.now().toString(), type: "error", - content: `Invalid mode "${requestedMode}". Available modes: ${availableSlugs.join(", ")}`, + content: `Invalid mode "${requestedMode}".\n\nThe CLI searched for custom modes in:\n${pathsDetails}\n\nAvailable modes: ${availableSlugs.join(", ")}`, ts: Date.now(), }) } diff --git a/cli/src/config/__tests__/customModes.test.ts b/cli/src/config/__tests__/customModes.test.ts new file mode 100644 index 00000000000..0d478b336d8 --- /dev/null +++ b/cli/src/config/__tests__/customModes.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import { loadCustomModes, getSearchedPaths } from "../customModes.js" + +// Mock the logs service +vi.mock("../../services/logs.js", () => ({ + logs: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +// Mock fs modules +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), +})) + +vi.mock("fs", () => ({ + existsSync: vi.fn(), +})) + +// Import mocked modules +import { existsSync } from "fs" +import { readFile } from "fs/promises" + +const VALID_YAML_CONTENT = ` +customModes: + - slug: test-mode + name: Test Mode + roleDefinition: This is a test mode + groups: + - read + - edit + - slug: another-mode + name: Another Mode + roleDefinition: Another test mode +` + +const INVALID_YAML_CONTENT = ` +this is not valid yaml: [[[ +` + +const EMPTY_YAML_CONTENT = ` +customModes: [] +` + +describe("customModes", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("getSearchedPaths", () => { + it("should return empty array before loadCustomModes is called", async () => { + // Reset by loading with no files + ;(existsSync as Mock).mockReturnValue(false) + await loadCustomModes("/test/workspace") + + const paths = getSearchedPaths() + expect(paths).toHaveLength(2) + expect(paths[0].type).toBe("global") + expect(paths[1].type).toBe("project") + }) + + it("should return searched paths with correct structure", async () => { + ;(existsSync as Mock).mockReturnValue(false) + await loadCustomModes("/test/workspace") + + const paths = getSearchedPaths() + expect(paths).toHaveLength(2) + + // Check global path structure + expect(paths[0]).toMatchObject({ + type: "global", + found: false, + modesCount: 0, + }) + expect(paths[0].path).toContain("custom_modes.yaml") + + // Check project path structure + expect(paths[1]).toMatchObject({ + type: "project", + path: "/test/workspace/.kilocodemodes", + found: false, + modesCount: 0, + }) + }) + }) + + describe("loadCustomModes", () => { + it("should return empty array when no config files exist", async () => { + ;(existsSync as Mock).mockReturnValue(false) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toEqual([]) + expect(existsSync).toHaveBeenCalled() + }) + + it("should load modes from global config file", async () => { + ;(existsSync as Mock).mockImplementation((path: string) => { + return path.includes("custom_modes.yaml") + }) + ;(readFile as Mock).mockResolvedValue(VALID_YAML_CONTENT) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toHaveLength(2) + expect(modes[0].slug).toBe("test-mode") + expect(modes[0].name).toBe("Test Mode") + expect(modes[1].slug).toBe("another-mode") + }) + + it("should load modes from project config file", async () => { + ;(existsSync as Mock).mockImplementation((path: string) => { + return path.includes(".kilocodemodes") + }) + ;(readFile as Mock).mockResolvedValue(VALID_YAML_CONTENT) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toHaveLength(2) + expect(modes[0].slug).toBe("test-mode") + }) + + it("should merge global and project modes with project taking precedence", async () => { + const globalYaml = ` +customModes: + - slug: shared-mode + name: Global Shared Mode + roleDefinition: From global +` + const projectYaml = ` +customModes: + - slug: shared-mode + name: Project Shared Mode + roleDefinition: From project +` + ;(existsSync as Mock).mockReturnValue(true) + ;(readFile as Mock).mockImplementation(async (path: string) => { + if (path.includes("custom_modes.yaml")) { + return globalYaml + } + return projectYaml + }) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toHaveLength(1) + expect(modes[0].name).toBe("Project Shared Mode") + expect(modes[0].roleDefinition).toBe("From project") + }) + + it("should track found status correctly in searchedPaths", async () => { + ;(existsSync as Mock).mockImplementation((path: string) => { + return path.includes("custom_modes.yaml") // Only global exists + }) + ;(readFile as Mock).mockResolvedValue(VALID_YAML_CONTENT) + + await loadCustomModes("/test/workspace") + const paths = getSearchedPaths() + + expect(paths[0].found).toBe(true) + expect(paths[0].modesCount).toBe(2) + expect(paths[1].found).toBe(false) + expect(paths[1].modesCount).toBe(0) + }) + + it("should handle invalid YAML gracefully", async () => { + ;(existsSync as Mock).mockReturnValue(true) + ;(readFile as Mock).mockResolvedValue(INVALID_YAML_CONTENT) + + const modes = await loadCustomModes("/test/workspace") + + // Should return empty array on parse failure + expect(modes).toEqual([]) + }) + + it("should handle empty customModes array", async () => { + ;(existsSync as Mock).mockReturnValue(true) + ;(readFile as Mock).mockResolvedValue(EMPTY_YAML_CONTENT) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toEqual([]) + }) + + it("should handle file read errors gracefully", async () => { + ;(existsSync as Mock).mockReturnValue(true) + ;(readFile as Mock).mockRejectedValue(new Error("Permission denied")) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toEqual([]) + }) + + it("should filter out modes without required slug and name", async () => { + const incompleteYaml = ` +customModes: + - slug: valid-mode + name: Valid Mode + - slug: missing-name + - name: Missing Slug + - roleDefinition: No slug or name +` + ;(existsSync as Mock).mockReturnValue(true) + ;(readFile as Mock).mockResolvedValue(incompleteYaml) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toHaveLength(1) + expect(modes[0].slug).toBe("valid-mode") + }) + + it("should provide default values for optional mode properties", async () => { + const minimalYaml = ` +customModes: + - slug: minimal + name: Minimal Mode +` + ;(existsSync as Mock).mockReturnValue(true) + ;(readFile as Mock).mockResolvedValue(minimalYaml) + + const modes = await loadCustomModes("/test/workspace") + + expect(modes).toHaveLength(1) + expect(modes[0].roleDefinition).toBe("") + expect(modes[0].groups).toEqual(["read", "edit", "browser", "command", "mcp"]) + }) + }) + + describe("platform-specific paths", () => { + it("should include platform-appropriate global path", async () => { + ;(existsSync as Mock).mockReturnValue(false) + + await loadCustomModes("/test/workspace") + const paths = getSearchedPaths() + + const globalPath = paths[0].path + + // Check it includes expected path components + expect(globalPath).toContain("kilocode.kilo-code") + expect(globalPath).toContain("custom_modes.yaml") + + // Platform-specific checks + if (process.platform === "darwin") { + expect(globalPath).toContain("Library") + expect(globalPath).toContain("Application Support") + } else if (process.platform === "win32") { + expect(globalPath).toContain("AppData") + expect(globalPath).toContain("Roaming") + } else { + expect(globalPath).toContain(".config") + } + }) + + it("should construct project path correctly", async () => { + ;(existsSync as Mock).mockReturnValue(false) + + await loadCustomModes("/my/custom/workspace") + const paths = getSearchedPaths() + + expect(paths[1].path).toBe("/my/custom/workspace/.kilocodemodes") + }) + }) +}) diff --git a/cli/src/config/customModes.ts b/cli/src/config/customModes.ts index 7fae43e2d66..7332e66a44c 100644 --- a/cli/src/config/customModes.ts +++ b/cli/src/config/customModes.ts @@ -9,6 +9,28 @@ import { join } from "path" import { homedir } from "os" import { parse } from "yaml" import type { ModeConfig } from "../types/messages.js" +import { logs } from "../services/logs.js" + +/** + * Represents a path that was searched for custom modes + */ +export interface SearchedPath { + type: "global" | "project" + path: string + found: boolean + modesCount?: number +} + +// Track searched paths for error reporting +let lastSearchedPaths: SearchedPath[] = [] + +/** + * Get the paths that were searched in the last loadCustomModes call + * @returns Array of searched path info + */ +export function getSearchedPaths(): SearchedPath[] { + return lastSearchedPaths +} /** * Get the global custom modes file path @@ -118,42 +140,64 @@ function parseCustomModes(content: string, source: "global" | "project"): ModeCo /** * Load custom modes from global configuration - * @returns Array of global custom modes + * @returns Object with modes array and path info */ -async function loadGlobalCustomModes(): Promise { +async function loadGlobalCustomModes(): Promise<{ modes: ModeConfig[]; pathInfo: SearchedPath }> { const globalPath = getGlobalModesPath() + const pathInfo: SearchedPath = { + type: "global", + path: globalPath, + found: false, + modesCount: 0, + } if (!existsSync(globalPath)) { - return [] + logs.debug(`Global custom modes file not found: ${globalPath}`, "CustomModes") + return { modes: [], pathInfo } } try { const content = await readFile(globalPath, "utf-8") - return parseCustomModes(content, "global") - } catch (_error) { - // Silent fail - return empty array if reading fails - return [] + const modes = parseCustomModes(content, "global") + pathInfo.found = true + pathInfo.modesCount = modes.length + logs.debug(`Loaded ${modes.length} global custom mode(s) from: ${globalPath}`, "CustomModes") + return { modes, pathInfo } + } catch (error) { + logs.debug(`Failed to read global custom modes file: ${globalPath}`, "CustomModes", { error }) + return { modes: [], pathInfo } } } /** * Load custom modes from project configuration * @param workspace - Workspace directory path - * @returns Array of project custom modes + * @returns Object with modes array and path info */ -async function loadProjectCustomModes(workspace: string): Promise { +async function loadProjectCustomModes(workspace: string): Promise<{ modes: ModeConfig[]; pathInfo: SearchedPath }> { const projectPath = getProjectModesPath(workspace) + const pathInfo: SearchedPath = { + type: "project", + path: projectPath, + found: false, + modesCount: 0, + } if (!existsSync(projectPath)) { - return [] + logs.debug(`Project custom modes file not found: ${projectPath}`, "CustomModes") + return { modes: [], pathInfo } } try { const content = await readFile(projectPath, "utf-8") - return parseCustomModes(content, "project") - } catch (_error) { - // Silent fail - return empty array if reading fails - return [] + const modes = parseCustomModes(content, "project") + pathInfo.found = true + pathInfo.modesCount = modes.length + logs.debug(`Loaded ${modes.length} project custom mode(s) from: ${projectPath}`, "CustomModes") + return { modes, pathInfo } + } catch (error) { + logs.debug(`Failed to read project custom modes file: ${projectPath}`, "CustomModes", { error }) + return { modes: [], pathInfo } } } @@ -164,20 +208,31 @@ async function loadProjectCustomModes(workspace: string): Promise * @returns Array of all custom mode configurations */ export async function loadCustomModes(workspace: string): Promise { - const [globalModes, projectModes] = await Promise.all([loadGlobalCustomModes(), loadProjectCustomModes(workspace)]) + const [globalResult, projectResult] = await Promise.all([ + loadGlobalCustomModes(), + loadProjectCustomModes(workspace), + ]) + + // Store searched paths for error reporting + lastSearchedPaths = [globalResult.pathInfo, projectResult.pathInfo] // Merge modes, with project modes taking precedence over global modes const modesMap = new Map() // Add global modes first - for (const mode of globalModes) { + for (const mode of globalResult.modes) { modesMap.set(mode.slug, mode) } // Override with project modes - for (const mode of projectModes) { + for (const mode of projectResult.modes) { modesMap.set(mode.slug, mode) } + const totalModes = modesMap.size + if (totalModes > 0) { + logs.info(`Loaded ${totalModes} custom mode(s) total`, "CustomModes") + } + return Array.from(modesMap.values()) } diff --git a/cli/src/index.ts b/cli/src/index.ts index 969ab96f1c1..6ee4c39be04 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,7 +13,7 @@ import { Package } from "./constants/package.js" import openConfigFile from "./config/openConfig.js" import authWizard from "./auth/index.js" import { configExists } from "./config/persistence.js" -import { loadCustomModes } from "./config/customModes.js" +import { loadCustomModes, getSearchedPaths } from "./config/customModes.js" import { envConfigExists, getMissingEnvVars } from "./config/env-config.js" import { getParallelModeParams } from "./parallel/parallel.js" import { DEBUG_MODES, DEBUG_FUNCTIONS } from "./debug/index.js" @@ -80,7 +80,14 @@ program // Validate mode if provided if (options.mode && !allValidModes.includes(options.mode)) { - console.error(`Error: Invalid mode "${options.mode}". Valid modes are: ${allValidModes.join(", ")}`) + const searchedPaths = getSearchedPaths() + console.error(`Error: Mode "${options.mode}" not found.\n`) + console.error("The CLI searched for custom modes in:") + for (const searched of searchedPaths) { + const status = searched.found ? `found, ${searched.modesCount} mode(s)` : "not found" + console.error(` • ${searched.type === "global" ? "Global" : "Project"}: ${searched.path} (${status})`) + } + console.error(`\nAvailable modes: ${allValidModes.join(", ")}`) process.exit(1) }