Skip to content
Closed
12 changes: 12 additions & 0 deletions .changeset/cli-custom-modes-error-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@kilocode/cli": patch
---

fix(cli): improve error message for custom mode not found

- Changed error from "Invalid mode" to "Mode not found" with search path details
- Added `SearchedPath` interface and `getSearchedPaths()` export for error reporting
- Added debug logging to help diagnose custom modes loading issues
- Shows exactly where CLI searched for custom modes (global and project paths)

Fixes #4575, fixes #4600
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# kilo-code

## 4.143.21

- Fix JetBrains plugin build by handling missing gradle.properties in CI check script

## 4.143.2

### Patch Changes
Expand Down
26 changes: 25 additions & 1 deletion cli/src/commands/__tests__/mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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:")
})

Expand Down Expand Up @@ -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 = []
Expand All @@ -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"]
Expand All @@ -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"]
Expand All @@ -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 = []
Expand Down
17 changes: 15 additions & 2 deletions cli/src/commands/mode.ts
Original file line number Diff line number Diff line change
@@ -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 || []
Expand Down Expand Up @@ -41,11 +42,23 @@ function showAvailableModes(allModes: ModeConfig[], addMessage: CommandContext["
})
}

function showInvalidModeError(requestedMode: string, availableSlugs: string[], addMessage: CommandContext["addMessage"]) {
function showInvalidModeError(
requestedMode: string,
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(),
})
}
Expand Down
266 changes: 266 additions & 0 deletions cli/src/config/__tests__/customModes.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
Loading
Loading