Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ export interface WebviewMessage {
| "requestSkills"
| "createSkill"
| "deleteSkill"
| "moveSkill"
| "openSkillFile"
text?: string
editedMessageContent?: string
Expand Down Expand Up @@ -639,8 +640,9 @@ export interface WebviewMessage {
timeout?: number
payload?: WebViewMessagePayload
source?: "global" | "project" | "built-in"
skillName?: string // For skill operations (createSkill, deleteSkill, openSkillFile)
skillMode?: string // For skill operations (mode restriction)
skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile)
skillMode?: string // For skill operations (current mode restriction)
newSkillMode?: string // For moveSkill (target mode)
skillDescription?: string // For createSkill (skill description)
requestId?: string
ids?: string[]
Expand Down
101 changes: 100 additions & 1 deletion src/core/webview/__tests__/skillsMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,31 @@ vi.mock("../../../i18n", () => ({
"skills:errors.missing_create_fields": "Missing required fields: skillName, source, or skillDescription",
"skills:errors.manager_unavailable": "Skills manager not available",
"skills:errors.missing_delete_fields": "Missing required fields: skillName or source",
"skills:errors.missing_move_fields": "Missing required fields: skillName or source",
"skills:errors.skill_not_found": `Skill "${params?.name}" not found`,
"skills:errors.cannot_modify_builtin": "Built-in skills cannot be created, deleted, or moved",
}
return translations[key] || key
},
}))

import * as vscode from "vscode"
import { openFile } from "../../../integrations/misc/open-file"
import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "../skillsMessageHandler"
import {
handleRequestSkills,
handleCreateSkill,
handleDeleteSkill,
handleMoveSkill,
handleOpenSkillFile,
} from "../skillsMessageHandler"

describe("skillsMessageHandler", () => {
const mockLog = vi.fn()
const mockPostMessageToWebview = vi.fn()
const mockGetSkillsMetadata = vi.fn()
const mockCreateSkill = vi.fn()
const mockDeleteSkill = vi.fn()
const mockMoveSkill = vi.fn()
const mockGetSkill = vi.fn()

const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => {
Expand All @@ -50,6 +59,7 @@ describe("skillsMessageHandler", () => {
getSkillsMetadata: mockGetSkillsMetadata,
createSkill: mockCreateSkill,
deleteSkill: mockDeleteSkill,
moveSkill: mockMoveSkill,
getSkill: mockGetSkill,
}
: undefined
Expand Down Expand Up @@ -253,6 +263,95 @@ describe("skillsMessageHandler", () => {
})
})

describe("handleMoveSkill", () => {
it("moves a skill successfully", async () => {
const provider = createMockProvider(true)
mockMoveSkill.mockResolvedValue(undefined)
mockGetSkillsMetadata.mockReturnValue([mockSkills[0]])

const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
source: "global",
skillMode: undefined,
newSkillMode: "code",
} as WebviewMessage)

expect(result).toEqual([mockSkills[0]])
expect(mockMoveSkill).toHaveBeenCalledWith("test-skill", "global", undefined, "code")
expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[0]] })
})

it("moves a skill from one mode to another", async () => {
const provider = createMockProvider(true)
mockMoveSkill.mockResolvedValue(undefined)
mockGetSkillsMetadata.mockReturnValue([mockSkills[1]])

const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "project-skill",
source: "project",
skillMode: "code",
newSkillMode: "architect",
} as WebviewMessage)

expect(result).toEqual([mockSkills[1]])
expect(mockMoveSkill).toHaveBeenCalledWith("project-skill", "project", "code", "architect")
})

it("returns undefined when required fields are missing", async () => {
const provider = createMockProvider(true)

const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
// missing source
} as WebviewMessage)

expect(result).toBeUndefined()
expect(mockLog).toHaveBeenCalledWith("Error moving skill: Missing required fields: skillName or source")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Failed to move skill: Missing required fields: skillName or source",
)
})

it("returns undefined when skills manager is not available", async () => {
const provider = createMockProvider(false)

const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
source: "global",
newSkillMode: "code",
} as WebviewMessage)

expect(result).toBeUndefined()
expect(mockLog).toHaveBeenCalledWith("Error moving skill: Skills manager not available")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Failed to move skill: Skills manager not available",
)
})

it("returns undefined when trying to move a built-in skill", async () => {
const provider = createMockProvider(true)

const result = await handleMoveSkill(provider, {
type: "moveSkill",
skillName: "test-skill",
source: "built-in",
newSkillMode: "code",
} as WebviewMessage)

expect(result).toBeUndefined()
expect(mockLog).toHaveBeenCalledWith(
"Error moving skill: Built-in skills cannot be created, deleted, or moved",
)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Failed to move skill: Built-in skills cannot be created, deleted, or moved",
)
})
})

describe("handleOpenSkillFile", () => {
it("opens a skill file successfully", async () => {
const provider = createMockProvider(true)
Expand Down
41 changes: 41 additions & 0 deletions src/core/webview/skillsMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,47 @@ export async function handleDeleteSkill(
}
}

/**
* Handles the moveSkill message - moves a skill to a different mode
*/
export async function handleMoveSkill(
provider: ClineProvider,
message: WebviewMessage,
): Promise<SkillMetadata[] | undefined> {
try {
const skillName = message.skillName
const source = message.source
const currentMode = message.skillMode
const newMode = message.newSkillMode

if (!skillName || !source) {
throw new Error(t("skills:errors.missing_move_fields"))
}

// Built-in skills cannot be moved
if (source === "built-in") {
throw new Error(t("skills:errors.cannot_modify_builtin"))
}

const skillsManager = provider.getSkillsManager()
if (!skillsManager) {
throw new Error(t("skills:errors.manager_unavailable"))
}

await skillsManager.moveSkill(skillName, source, currentMode, newMode)

// Send updated skills list
const skills = skillsManager.getSkillsMetadata()
await provider.postMessageToWebview({ type: "skills", skills })
return skills
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
provider.log(`Error moving skill: ${errorMessage}`)
vscode.window.showErrorMessage(`Failed to move skill: ${errorMessage}`)
return undefined
}
}

/**
* Handles the openSkillFile message - opens a skill file in the editor
*/
Expand Down
12 changes: 11 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ import { ClineProvider } from "./ClineProvider"
import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager"
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
import { generateErrorDiagnostics } from "./diagnosticsHandler"
import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "./skillsMessageHandler"
import {
handleRequestSkills,
handleCreateSkill,
handleDeleteSkill,
handleMoveSkill,
handleOpenSkillFile,
} from "./skillsMessageHandler"
import { changeLanguage, t } from "../../i18n"
import { Package } from "../../shared/package"
import { type RouterName, toRouterName } from "../../shared/api"
Expand Down Expand Up @@ -2982,6 +2988,10 @@ export const webviewMessageHandler = async (
await handleDeleteSkill(provider, message)
break
}
case "moveSkill": {
await handleMoveSkill(provider, message)
break
}
case "openSkillFile": {
await handleOpenSkillFile(provider, message)
break
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
"already_exists": "Skill \"{{name}}\" already exists at {{path}}",
"not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}",
"missing_create_fields": "Missing required fields: skillName, source, or skillDescription",
"missing_move_fields": "Missing required fields: skillName or source",
"manager_unavailable": "Skills manager not available",
"missing_delete_fields": "Missing required fields: skillName or source",
"skill_not_found": "Skill \"{{name}}\" not found",
"cannot_modify_builtin": "Built-in skills cannot be created or deleted",
"cannot_modify_builtin": "Built-in skills cannot be created, deleted, or moved",
"cannot_open_builtin": "Built-in skills cannot be opened as files"
}
}
72 changes: 72 additions & 0 deletions src/services/skills/SkillsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,78 @@ Add your skill instructions here.
await this.discoverSkills()
}

/**
* Move a skill to a different mode
* @param name - Skill name to move
* @param source - Where the skill is located ("global" or "project")
* @param currentMode - Current mode (undefined for generic skills)
* @param newMode - Target mode (undefined for generic skills)
*/
async moveSkill(
name: string,
source: "global" | "project",
currentMode: string | undefined,
newMode: string | undefined,
): Promise<void> {
// Don't move if source and destination are the same
if (currentMode === newMode) {
return
}

// Find the skill at its current location
const skill = this.getSkill(name, source, currentMode)
if (!skill) {
const modeInfo = currentMode ? ` (mode: ${currentMode})` : ""
throw new Error(t("skills:errors.not_found", { name, source, modeInfo }))
}

// Determine base directory
let baseDir: string
if (source === "global") {
baseDir = getGlobalRooDirectory()
} else {
const provider = this.providerRef.deref()
if (!provider?.cwd) {
throw new Error(t("skills:errors.no_workspace"))
}
baseDir = path.join(provider.cwd, ".roo")
}

// Determine source and destination directories
const sourceDirName = currentMode ? `skills-${currentMode}` : "skills"
const destDirName = newMode ? `skills-${newMode}` : "skills"
const sourceDir = path.join(baseDir, sourceDirName, name)
const destSkillsDir = path.join(baseDir, destDirName)
const destDir = path.join(destSkillsDir, name)
const destSkillMdPath = path.join(destDir, "SKILL.md")

// Check if skill already exists at destination
if (await fileExists(destSkillMdPath)) {
const newModeInfo = newMode ? ` (mode: ${newMode})` : ""
throw new Error(t("skills:errors.already_exists", { name, path: destSkillMdPath }))
}
Comment thread
SannidhyaSah marked this conversation as resolved.

// Ensure destination skills directory exists
await fs.mkdir(destSkillsDir, { recursive: true })

// Move the skill directory
await fs.rename(sourceDir, destDir)

// Clean up empty source skills directory
const sourceSkillsDir = path.join(baseDir, sourceDirName)
try {
const entries = await fs.readdir(sourceSkillsDir)
if (entries.length === 0) {
await fs.rmdir(sourceSkillsDir)
}
} catch {
// Ignore errors - directory might not exist or have permission issues
}

// Refresh skills list
await this.discoverSkills()
}

/**
* Get all skills directories to scan, including mode-specific directories.
*/
Expand Down
Loading
Loading