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

Commit 1834888

Browse files
Sannidhyahannesrudolph
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 d7fa963 commit 1834888

18 files changed

Lines changed: 2197 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: 13 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
import type { WorktreeIncludeStatus } from "./worktree.js"
@@ -108,6 +109,8 @@ export interface ExtensionMessage {
108109
| "worktreeIncludeStatus"
109110
| "branchWorktreeIncludeResult"
110111
| "folderSelected"
112+
| "mergeWorktreeResult"
113+
| "skills"
111114
text?: string
112115
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
113116
checkpointWarning?: {
@@ -202,6 +205,7 @@ export interface ExtensionMessage {
202205
stepIndex?: number // For browserSessionNavigate: the target step index to display
203206
tools?: SerializedCustomToolDefinition[] // For customToolsResult
204207
modes?: { slug: string; name: string }[] // For modes response
208+
skills?: SkillMetadata[] // For skills response
205209
aggregatedCosts?: {
206210
// For taskWithAggregatedCosts response
207211
totalCost: number
@@ -602,6 +606,12 @@ export interface WebviewMessage {
602606
| "createWorktreeInclude"
603607
| "checkoutBranch"
604608
| "browseForWorktreePath"
609+
| "mergeWorktree"
610+
// Skills messages
611+
| "requestSkills"
612+
| "createSkill"
613+
| "deleteSkill"
614+
| "openSkillFile"
605615
text?: string
606616
editedMessageContent?: string
607617
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@@ -636,6 +646,9 @@ export interface WebviewMessage {
636646
timeout?: number
637647
payload?: WebViewMessagePayload
638648
source?: "global" | "project"
649+
skillName?: string // For skill operations (createSkill, deleteSkill, openSkillFile)
650+
skillMode?: string // For skill operations (mode restriction)
651+
skillDescription?: string // For createSkill (skill description)
639652
requestId?: string
640653
ids?: string[]
641654
hasSystemPromptOverride?: boolean

src/core/webview/webviewMessageHandler.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2974,6 +2974,105 @@ export const webviewMessageHandler = async (
29742974
}
29752975
break
29762976
}
2977+
case "requestSkills": {
2978+
try {
2979+
const skillsManager = provider.getSkillsManager()
2980+
if (skillsManager) {
2981+
const skills = skillsManager.getSkillsMetadata()
2982+
await provider.postMessageToWebview({ type: "skills", skills })
2983+
} else {
2984+
await provider.postMessageToWebview({ type: "skills", skills: [] })
2985+
}
2986+
} catch (error) {
2987+
provider.log(`Error fetching skills: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
2988+
await provider.postMessageToWebview({ type: "skills", skills: [] })
2989+
}
2990+
break
2991+
}
2992+
case "createSkill": {
2993+
try {
2994+
const skillName = message.skillName
2995+
const source = message.source
2996+
const skillDescription = message.skillDescription
2997+
const skillMode = message.skillMode
2998+
2999+
if (!skillName || !source || !skillDescription) {
3000+
throw new Error("Missing required fields: skillName, source, or skillDescription")
3001+
}
3002+
3003+
const skillsManager = provider.getSkillsManager()
3004+
if (!skillsManager) {
3005+
throw new Error("Skills manager not available")
3006+
}
3007+
3008+
const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, skillMode)
3009+
3010+
// Open the created file in the editor
3011+
openFile(createdPath)
3012+
3013+
// Send updated skills list
3014+
const skills = skillsManager.getSkillsMetadata()
3015+
await provider.postMessageToWebview({ type: "skills", skills })
3016+
} catch (error) {
3017+
const errorMessage = error instanceof Error ? error.message : String(error)
3018+
provider.log(`Error creating skill: ${errorMessage}`)
3019+
vscode.window.showErrorMessage(`Failed to create skill: ${errorMessage}`)
3020+
}
3021+
break
3022+
}
3023+
case "deleteSkill": {
3024+
try {
3025+
const skillName = message.skillName
3026+
const source = message.source
3027+
const skillMode = message.skillMode
3028+
3029+
if (!skillName || !source) {
3030+
throw new Error("Missing required fields: skillName or source")
3031+
}
3032+
3033+
const skillsManager = provider.getSkillsManager()
3034+
if (!skillsManager) {
3035+
throw new Error("Skills manager not available")
3036+
}
3037+
3038+
await skillsManager.deleteSkill(skillName, source, skillMode)
3039+
3040+
// UI will handle refresh via setTimeout
3041+
} catch (error) {
3042+
const errorMessage = error instanceof Error ? error.message : String(error)
3043+
provider.log(`Error deleting skill: ${errorMessage}`)
3044+
vscode.window.showErrorMessage(`Failed to delete skill: ${errorMessage}`)
3045+
}
3046+
break
3047+
}
3048+
case "openSkillFile": {
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+
const skill = skillsManager.getSkill(skillName, source, skillMode)
3064+
if (!skill) {
3065+
throw new Error(`Skill "${skillName}" not found`)
3066+
}
3067+
3068+
openFile(skill.path)
3069+
} catch (error) {
3070+
const errorMessage = error instanceof Error ? error.message : String(error)
3071+
provider.log(`Error opening skill file: ${errorMessage}`)
3072+
vscode.window.showErrorMessage(`Failed to open skill file: ${errorMessage}`)
3073+
}
3074+
break
3075+
}
29773076
case "openCommandFile": {
29783077
try {
29793078
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)