Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-custom-modes-error-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

fix(cli): improve error message for custom mode not found
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
11 changes: 10 additions & 1 deletion 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 @@ -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(),
})
}
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