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

Commit 06ff5f4

Browse files
SannidhyaSannidhya
authored andcommitted
feat: add skills management UI to settings panel (#10513)
- Add SkillsSettings, SkillItem, and CreateSkillDialog components - Extend SkillsManager with CRUD operations - Add skills state to ExtensionStateContext - Implement message handlers for skills operations - Add comprehensive tests (82 new tests, all passing) - Add i18n translations for skills UI - Fix Dialog component mocks in SettingsView tests - Fix lint warnings for unused parameters
1 parent 1a1827d commit 06ff5f4

18 files changed

Lines changed: 2194 additions & 10 deletions

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from "./message.js"
1919
export * from "./mode.js"
2020
export * from "./model.js"
2121
export * from "./provider-settings.js"
22+
export * from "./skills.js"
2223
export * from "./task.js"
2324
export * from "./todo.js"
2425
export * from "./telemetry.js"

packages/types/src/skills.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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
9+
source: "global" | "project" // Where the skill was discovered
10+
mode?: string // If set, skill is only available in this mode
11+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList,
1818
import type { SerializedCustomToolDefinition } from "./custom-tool.js"
1919
import type { GitCommit } from "./git.js"
2020
import type { McpServer } from "./mcp.js"
21+
import type { SkillMetadata } from "./skills.js"
2122
import type { ModelRecord, RouterModels } from "./model.js"
2223
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
2324

@@ -97,6 +98,7 @@ export interface ExtensionMessage {
9798
| "modes"
9899
| "taskWithAggregatedCosts"
99100
| "openAiCodexRateLimits"
101+
| "skills"
100102
text?: string
101103
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
102104
checkpointWarning?: {
@@ -187,6 +189,7 @@ export interface ExtensionMessage {
187189
stepIndex?: number // For browserSessionNavigate: the target step index to display
188190
tools?: SerializedCustomToolDefinition[] // For customToolsResult
189191
modes?: { slug: string; name: string }[] // For modes response
192+
skills?: SkillMetadata[] // For skills response
190193
aggregatedCosts?: {
191194
// For taskWithAggregatedCosts response
192195
totalCost: number
@@ -533,6 +536,10 @@ export interface WebviewMessage {
533536
| "requestModes"
534537
| "switchMode"
535538
| "debugSetting"
539+
| "requestSkills"
540+
| "createSkill"
541+
| "deleteSkill"
542+
| "openSkillFile"
536543
text?: string
537544
editedMessageContent?: string
538545
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@@ -567,6 +574,9 @@ export interface WebviewMessage {
567574
timeout?: number
568575
payload?: WebViewMessagePayload
569576
source?: "global" | "project"
577+
skillName?: string // For skill operations (createSkill, deleteSkill, openSkillFile)
578+
skillMode?: string // For skill operations (mode restriction)
579+
skillDescription?: string // For createSkill (skill description)
570580
requestId?: string
571581
ids?: string[]
572582
hasSystemPromptOverride?: boolean

src/core/webview/webviewMessageHandler.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2999,6 +2999,105 @@ export const webviewMessageHandler = async (
29992999
}
30003000
break
30013001
}
3002+
case "requestSkills": {
3003+
try {
3004+
const skillsManager = provider.getSkillsManager()
3005+
if (skillsManager) {
3006+
const skills = skillsManager.getSkillsMetadata()
3007+
await provider.postMessageToWebview({ type: "skills", skills })
3008+
} else {
3009+
await provider.postMessageToWebview({ type: "skills", skills: [] })
3010+
}
3011+
} catch (error) {
3012+
provider.log(`Error fetching skills: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
3013+
await provider.postMessageToWebview({ type: "skills", skills: [] })
3014+
}
3015+
break
3016+
}
3017+
case "createSkill": {
3018+
try {
3019+
const skillName = message.skillName
3020+
const source = message.source
3021+
const skillDescription = message.skillDescription
3022+
const skillMode = message.skillMode
3023+
3024+
if (!skillName || !source || !skillDescription) {
3025+
throw new Error("Missing required fields: skillName, source, or skillDescription")
3026+
}
3027+
3028+
const skillsManager = provider.getSkillsManager()
3029+
if (!skillsManager) {
3030+
throw new Error("Skills manager not available")
3031+
}
3032+
3033+
const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, skillMode)
3034+
3035+
// Open the created file in the editor
3036+
openFile(createdPath)
3037+
3038+
// Send updated skills list
3039+
const skills = skillsManager.getSkillsMetadata()
3040+
await provider.postMessageToWebview({ type: "skills", skills })
3041+
} catch (error) {
3042+
const errorMessage = error instanceof Error ? error.message : String(error)
3043+
provider.log(`Error creating skill: ${errorMessage}`)
3044+
vscode.window.showErrorMessage(`Failed to create skill: ${errorMessage}`)
3045+
}
3046+
break
3047+
}
3048+
case "deleteSkill": {
3049+
try {
3050+
const skillName = message.skillName
3051+
const source = message.source
3052+
const skillMode = message.skillMode
3053+
3054+
if (!skillName || !source) {
3055+
throw new Error("Missing required fields: skillName or source")
3056+
}
3057+
3058+
const skillsManager = provider.getSkillsManager()
3059+
if (!skillsManager) {
3060+
throw new Error("Skills manager not available")
3061+
}
3062+
3063+
await skillsManager.deleteSkill(skillName, source, skillMode)
3064+
3065+
// UI will handle refresh via setTimeout
3066+
} catch (error) {
3067+
const errorMessage = error instanceof Error ? error.message : String(error)
3068+
provider.log(`Error deleting skill: ${errorMessage}`)
3069+
vscode.window.showErrorMessage(`Failed to delete skill: ${errorMessage}`)
3070+
}
3071+
break
3072+
}
3073+
case "openSkillFile": {
3074+
try {
3075+
const skillName = message.skillName
3076+
const source = message.source
3077+
const skillMode = message.skillMode
3078+
3079+
if (!skillName || !source) {
3080+
throw new Error("Missing required fields: skillName or source")
3081+
}
3082+
3083+
const skillsManager = provider.getSkillsManager()
3084+
if (!skillsManager) {
3085+
throw new Error("Skills manager not available")
3086+
}
3087+
3088+
const skill = skillsManager.getSkill(skillName, source, skillMode)
3089+
if (!skill) {
3090+
throw new Error(`Skill "${skillName}" not found`)
3091+
}
3092+
3093+
openFile(skill.path)
3094+
} catch (error) {
3095+
const errorMessage = error instanceof Error ? error.message : String(error)
3096+
provider.log(`Error opening skill file: ${errorMessage}`)
3097+
vscode.window.showErrorMessage(`Failed to open skill file: ${errorMessage}`)
3098+
}
3099+
break
3100+
}
30023101
case "openCommandFile": {
30033102
try {
30043103
if (message.text) {

src/services/skills/SkillsManager.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from "fs/promises"
22
import * as path from "path"
3+
import * as os from "os"
34
import * as vscode from "vscode"
45
import matter from "gray-matter"
56

@@ -239,6 +240,142 @@ export class SkillsManager {
239240
}
240241
}
241242

243+
/**
244+
* Get all skills metadata (for UI display)
245+
* Returns skills from all sources without content
246+
*/
247+
getSkillsMetadata(): SkillMetadata[] {
248+
return Array.from(this.skills.values())
249+
}
250+
251+
/**
252+
* Get a skill by name, source, and optionally mode
253+
*/
254+
getSkill(name: string, source: "global" | "project", mode?: string): SkillMetadata | undefined {
255+
const skillKey = this.getSkillKey(name, source, mode)
256+
return this.skills.get(skillKey)
257+
}
258+
259+
/**
260+
* Validate skill name per agentskills.io spec
261+
* - 1-64 chars
262+
* - lowercase letters/numbers/hyphens only
263+
* - must not start/end with hyphen
264+
* - must not contain consecutive hyphens
265+
*/
266+
private validateSkillName(name: string): { valid: boolean; error?: string } {
267+
if (name.length < 1 || name.length > 64) {
268+
return { valid: false, error: `Skill name must be 1-64 characters (got ${name.length})` }
269+
}
270+
271+
const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
272+
if (!nameFormat.test(name)) {
273+
return {
274+
valid: false,
275+
error: "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)",
276+
}
277+
}
278+
279+
return { valid: true }
280+
}
281+
282+
/**
283+
* Create a new skill
284+
* @param name - Skill name (must be valid per agentskills.io spec)
285+
* @param source - "global" or "project"
286+
* @param description - Skill description
287+
* @param mode - Optional mode restriction (creates in skills-{mode}/ directory)
288+
* @returns Path to created SKILL.md file
289+
*/
290+
async createSkill(name: string, source: "global" | "project", description: string, mode?: string): Promise<string> {
291+
// Validate skill name
292+
const validation = this.validateSkillName(name)
293+
if (!validation.valid) {
294+
throw new Error(validation.error)
295+
}
296+
297+
// Validate description
298+
const trimmedDescription = description.trim()
299+
if (trimmedDescription.length < 1 || trimmedDescription.length > 1024) {
300+
throw new Error(`Skill description must be 1-1024 characters (got ${trimmedDescription.length})`)
301+
}
302+
303+
// Determine base directory
304+
let baseDir: string
305+
if (source === "global") {
306+
baseDir = getGlobalRooDirectory()
307+
} else {
308+
const provider = this.providerRef.deref()
309+
if (!provider?.cwd) {
310+
throw new Error("Cannot create project skill: no workspace folder is open")
311+
}
312+
baseDir = path.join(provider.cwd, ".roo")
313+
}
314+
315+
// Determine skills directory (with optional mode suffix)
316+
const skillsDirName = mode ? `skills-${mode}` : "skills"
317+
const skillsDir = path.join(baseDir, skillsDirName)
318+
const skillDir = path.join(skillsDir, name)
319+
const skillMdPath = path.join(skillDir, "SKILL.md")
320+
321+
// Check if skill already exists
322+
if (await fileExists(skillMdPath)) {
323+
throw new Error(`Skill "${name}" already exists at ${skillMdPath}`)
324+
}
325+
326+
// Create the skill directory
327+
await fs.mkdir(skillDir, { recursive: true })
328+
329+
// Generate SKILL.md content with frontmatter
330+
const titleName = name
331+
.split("-")
332+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
333+
.join(" ")
334+
335+
const skillContent = `---
336+
name: ${name}
337+
description: ${trimmedDescription}
338+
---
339+
340+
# ${titleName}
341+
342+
## Instructions
343+
344+
Add your skill instructions here.
345+
`
346+
347+
// Write the SKILL.md file
348+
await fs.writeFile(skillMdPath, skillContent, "utf-8")
349+
350+
// Refresh skills list
351+
await this.discoverSkills()
352+
353+
return skillMdPath
354+
}
355+
356+
/**
357+
* Delete a skill
358+
* @param name - Skill name to delete
359+
* @param source - Where the skill is located
360+
* @param mode - Optional mode (to locate in skills-{mode}/ directory)
361+
*/
362+
async deleteSkill(name: string, source: "global" | "project", mode?: string): Promise<void> {
363+
// Find the skill
364+
const skill = this.getSkill(name, source, mode)
365+
if (!skill) {
366+
throw new Error(`Skill "${name}" not found in ${source} ${mode ? `(mode: ${mode})` : ""}`)
367+
}
368+
369+
// Get the skill directory (parent of SKILL.md)
370+
const skillDir = path.dirname(skill.path)
371+
372+
// Delete the entire skill directory
373+
await fs.rm(skillDir, { recursive: true, force: true })
374+
375+
// Refresh skills list
376+
await this.discoverSkills()
377+
}
378+
242379
/**
243380
* Get all skills directories to scan, including mode-specific directories.
244381
*/

0 commit comments

Comments
 (0)