Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit ec8e86d

Browse files
brunobergherroomote[bot]roomote
authored andcommitted
ux: improve Skills and Slash Commands settings UI with multi-mode support (#11157)
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> Co-authored-by: Roo Code <roomote@roocode.com>
1 parent 2d87edd commit ec8e86d

59 files changed

Lines changed: 3389 additions & 838 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/types/src/skills.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Skill metadata for discovery (loaded at startup)
3+
* Only name and description are required for now
4+
*/
5+
export interface SkillMetadata {
6+
name: string // Required: skill identifier
7+
description: string // Required: when to use this skill
8+
path: string // Absolute path to SKILL.md (or "<built-in:name>" for built-in skills)
9+
source: "global" | "project" | "built-in" // Where the skill was discovered
10+
/**
11+
* @deprecated Use modeSlugs instead. Kept for backward compatibility.
12+
* If set, skill is only available in this mode.
13+
*/
14+
mode?: string
15+
/**
16+
* Mode slugs where this skill is available.
17+
* - undefined or empty array means the skill is available in all modes ("Any mode").
18+
* - An array with one or more mode slugs restricts the skill to those modes.
19+
*/
20+
modeSlugs?: string[]
21+
}
22+
23+
/**
24+
* Skill name validation constants per agentskills.io specification:
25+
* https://agentskills.io/specification
26+
*
27+
* Name constraints:
28+
* - 1-64 characters
29+
* - Lowercase letters, numbers, and hyphens only
30+
* - Must not start or end with a hyphen
31+
* - Must not contain consecutive hyphens
32+
*/
33+
export const SKILL_NAME_MIN_LENGTH = 1
34+
export const SKILL_NAME_MAX_LENGTH = 64
35+
36+
/**
37+
* Regex pattern for valid skill names.
38+
* Matches: lowercase letters/numbers, optionally followed by groups of hyphen + lowercase letters/numbers.
39+
* This ensures no leading/trailing hyphens and no consecutive hyphens.
40+
*/
41+
export const SKILL_NAME_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
42+
43+
/**
44+
* Error codes for skill name validation.
45+
* These can be mapped to translation keys in the frontend or error messages in the backend.
46+
*/
47+
export enum SkillNameValidationError {
48+
Empty = "empty",
49+
TooLong = "too_long",
50+
InvalidFormat = "invalid_format",
51+
}
52+
53+
/**
54+
* Result of skill name validation.
55+
*/
56+
export interface SkillNameValidationResult {
57+
valid: boolean
58+
error?: SkillNameValidationError
59+
}
60+
61+
/**
62+
* Validate a skill name according to agentskills.io specification.
63+
*
64+
* @param name - The skill name to validate
65+
* @returns Validation result with error code if invalid
66+
*/
67+
export function validateSkillName(name: string): SkillNameValidationResult {
68+
if (!name || name.length < SKILL_NAME_MIN_LENGTH) {
69+
return { valid: false, error: SkillNameValidationError.Empty }
70+
}
71+
72+
if (name.length > SKILL_NAME_MAX_LENGTH) {
73+
return { valid: false, error: SkillNameValidationError.TooLong }
74+
}
75+
76+
if (!SKILL_NAME_REGEX.test(name)) {
77+
return { valid: false, error: SkillNameValidationError.InvalidFormat }
78+
}
79+
80+
return { valid: true }
81+
}

packages/types/src/vscode-extension-host.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,7 @@ export interface WebviewMessage {
613613
| "createSkill"
614614
| "deleteSkill"
615615
| "moveSkill"
616+
| "updateSkillModes"
616617
| "openSkillFile"
617618
text?: string
618619
editedMessageContent?: string
@@ -649,9 +650,15 @@ export interface WebviewMessage {
649650
payload?: WebViewMessagePayload
650651
source?: "global" | "project" | "built-in"
651652
skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile)
653+
/** @deprecated Use skillModeSlugs instead */
652654
skillMode?: string // For skill operations (current mode restriction)
655+
/** @deprecated Use newSkillModeSlugs instead */
653656
newSkillMode?: string // For moveSkill (target mode)
654657
skillDescription?: string // For createSkill (skill description)
658+
/** Mode slugs for skill operations. undefined/empty = any mode */
659+
skillModeSlugs?: string[] // For skill operations (mode restrictions)
660+
/** Target mode slugs for updateSkillModes */
661+
newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions)
655662
requestId?: string
656663
ids?: string[]
657664
terminalOperation?: "continue" | "abort"

src/core/webview/__tests__/skillsMessageHandler.spec.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe("skillsMessageHandler", () => {
5252
const mockDeleteSkill = vi.fn()
5353
const mockMoveSkill = vi.fn()
5454
const mockGetSkill = vi.fn()
55+
const mockFindSkillByNameAndSource = vi.fn()
5556

5657
const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => {
5758
const skillsManager = hasSkillsManager
@@ -61,6 +62,7 @@ describe("skillsMessageHandler", () => {
6162
deleteSkill: mockDeleteSkill,
6263
moveSkill: mockMoveSkill,
6364
getSkill: mockGetSkill,
65+
findSkillByNameAndSource: mockFindSkillByNameAndSource,
6466
}
6567
: undefined
6668

@@ -158,7 +160,7 @@ describe("skillsMessageHandler", () => {
158160
} as WebviewMessage)
159161

160162
expect(result).toEqual(mockSkills)
161-
expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", "code")
163+
expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", ["code"])
162164
})
163165

164166
it("returns undefined when required fields are missing", async () => {
@@ -355,21 +357,21 @@ describe("skillsMessageHandler", () => {
355357
describe("handleOpenSkillFile", () => {
356358
it("opens a skill file successfully", async () => {
357359
const provider = createMockProvider(true)
358-
mockGetSkill.mockReturnValue(mockSkills[0])
360+
mockFindSkillByNameAndSource.mockReturnValue(mockSkills[0])
359361

360362
await handleOpenSkillFile(provider, {
361363
type: "openSkillFile",
362364
skillName: "test-skill",
363365
source: "global",
364366
} as WebviewMessage)
365367

366-
expect(mockGetSkill).toHaveBeenCalledWith("test-skill", "global", undefined)
368+
expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("test-skill", "global")
367369
expect(openFile).toHaveBeenCalledWith("/path/to/test-skill/SKILL.md")
368370
})
369371

370372
it("opens a skill file with mode restriction", async () => {
371373
const provider = createMockProvider(true)
372-
mockGetSkill.mockReturnValue(mockSkills[1])
374+
mockFindSkillByNameAndSource.mockReturnValue(mockSkills[1])
373375

374376
await handleOpenSkillFile(provider, {
375377
type: "openSkillFile",
@@ -378,7 +380,7 @@ describe("skillsMessageHandler", () => {
378380
skillMode: "code",
379381
} as WebviewMessage)
380382

381-
expect(mockGetSkill).toHaveBeenCalledWith("project-skill", "project", "code")
383+
expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("project-skill", "project")
382384
expect(openFile).toHaveBeenCalledWith("/project/.roo/skills/project-skill/SKILL.md")
383385
})
384386

@@ -416,7 +418,7 @@ describe("skillsMessageHandler", () => {
416418

417419
it("shows error when skill is not found", async () => {
418420
const provider = createMockProvider(true)
419-
mockGetSkill.mockReturnValue(undefined)
421+
mockFindSkillByNameAndSource.mockReturnValue(undefined)
420422

421423
await handleOpenSkillFile(provider, {
422424
type: "openSkillFile",

src/core/webview/skillsMessageHandler.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export async function handleCreateSkill(
3838
const skillName = message.skillName
3939
const source = message.source
4040
const skillDescription = message.skillDescription
41-
const skillMode = message.skillMode
41+
// Support new modeSlugs array or fall back to legacy skillMode
42+
const modeSlugs = message.skillModeSlugs ?? (message.skillMode ? [message.skillMode] : undefined)
4243

4344
if (!skillName || !source || !skillDescription) {
4445
throw new Error(t("skills:errors.missing_create_fields"))
@@ -54,7 +55,7 @@ export async function handleCreateSkill(
5455
throw new Error(t("skills:errors.manager_unavailable"))
5556
}
5657

57-
const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, skillMode)
58+
const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, modeSlugs)
5859

5960
// Open the created file in the editor
6061
openFile(createdPath)
@@ -81,7 +82,8 @@ export async function handleDeleteSkill(
8182
try {
8283
const skillName = message.skillName
8384
const source = message.source
84-
const skillMode = message.skillMode
85+
// Support new skillModeSlugs array or fall back to legacy skillMode
86+
const skillMode = message.skillModeSlugs?.[0] ?? message.skillMode
8587

8688
if (!skillName || !source) {
8789
throw new Error(t("skills:errors.missing_delete_fields"))
@@ -152,14 +154,53 @@ export async function handleMoveSkill(
152154
}
153155
}
154156

157+
/**
158+
* Handles the updateSkillModes message - updates the mode associations for a skill
159+
*/
160+
export async function handleUpdateSkillModes(
161+
provider: ClineProvider,
162+
message: WebviewMessage,
163+
): Promise<SkillMetadata[] | undefined> {
164+
try {
165+
const skillName = message.skillName
166+
const source = message.source
167+
const newModeSlugs = message.newSkillModeSlugs
168+
169+
if (!skillName || !source) {
170+
throw new Error(t("skills:errors.missing_update_modes_fields"))
171+
}
172+
173+
// Built-in skills cannot be modified
174+
if (source === "built-in") {
175+
throw new Error(t("skills:errors.cannot_modify_builtin"))
176+
}
177+
178+
const skillsManager = provider.getSkillsManager()
179+
if (!skillsManager) {
180+
throw new Error(t("skills:errors.manager_unavailable"))
181+
}
182+
183+
await skillsManager.updateSkillModes(skillName, source, newModeSlugs)
184+
185+
// Send updated skills list
186+
const skills = skillsManager.getSkillsMetadata()
187+
await provider.postMessageToWebview({ type: "skills", skills })
188+
return skills
189+
} catch (error) {
190+
const errorMessage = error instanceof Error ? error.message : String(error)
191+
provider.log(`Error updating skill modes: ${errorMessage}`)
192+
vscode.window.showErrorMessage(`Failed to update skill modes: ${errorMessage}`)
193+
return undefined
194+
}
195+
}
196+
155197
/**
156198
* Handles the openSkillFile message - opens a skill file in the editor
157199
*/
158200
export async function handleOpenSkillFile(provider: ClineProvider, message: WebviewMessage): Promise<void> {
159201
try {
160202
const skillName = message.skillName
161203
const source = message.source
162-
const skillMode = message.skillMode
163204

164205
if (!skillName || !source) {
165206
throw new Error(t("skills:errors.missing_delete_fields"))
@@ -175,7 +216,8 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv
175216
throw new Error(t("skills:errors.manager_unavailable"))
176217
}
177218

178-
const skill = skillsManager.getSkill(skillName, source, skillMode)
219+
// Find skill by name and source (skills may have modeSlugs arrays now)
220+
const skill = skillsManager.findSkillByNameAndSource(skillName, source)
179221
if (!skill) {
180222
throw new Error(t("skills:errors.skill_not_found", { name: skillName }))
181223
}

src/core/webview/webviewMessageHandler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
handleCreateSkill,
3838
handleDeleteSkill,
3939
handleMoveSkill,
40+
handleUpdateSkillModes,
4041
handleOpenSkillFile,
4142
} from "./skillsMessageHandler"
4243
import { changeLanguage, t } from "../../i18n"
@@ -3007,6 +3008,10 @@ export const webviewMessageHandler = async (
30073008
await handleMoveSkill(provider, message)
30083009
break
30093010
}
3011+
case "updateSkillModes": {
3012+
await handleUpdateSkillModes(provider, message)
3013+
break
3014+
}
30103015
case "openSkillFile": {
30113016
await handleOpenSkillFile(provider, message)
30123017
break

src/i18n/locales/ca/skills.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/de/skills.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/en/skills.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}",
99
"missing_create_fields": "Missing required fields: skillName, source, or skillDescription",
1010
"missing_move_fields": "Missing required fields: skillName or source",
11+
"missing_update_modes_fields": "Missing required fields: skillName or source",
1112
"manager_unavailable": "Skills manager not available",
1213
"missing_delete_fields": "Missing required fields: skillName or source",
1314
"skill_not_found": "Skill \"{{name}}\" not found",

src/i18n/locales/es/skills.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/fr/skills.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)