Skip to content
Open
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
1 change: 1 addition & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2088,6 +2088,7 @@ export class ClineProvider
}
})(),
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
skills: this.skillsManager?.getSkillsForUI() ?? [],
}
}

Expand Down
104 changes: 104 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3029,6 +3029,110 @@ export const webviewMessageHandler = async (
}
break
}
case "requestSkills": {
try {
const skills = provider.getSkillsManager()?.getSkillsForUI() ?? []

await provider.postMessageToWebview({
type: "skills",
skills: skills,
})
} catch (error) {
provider.log(`Error fetching skills: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
// Send empty array on error
await provider.postMessageToWebview({
type: "skills",
skills: [],
})
}
break
}
case "createSkill": {
try {
const skillName = message.text
const skillSource = message.values?.source as "global" | "project"

if (!skillName || !skillSource) {
provider.log("Missing skill name or source for createSkill")
break
}

// Create the skill
const filePath = await provider.getSkillsManager()?.createSkill(skillName, skillSource)

if (filePath) {
// Open the file in editor
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath))
await vscode.window.showTextDocument(doc)
}

// Refresh skills list
const skills = provider.getSkillsManager()?.getSkillsForUI() ?? []
await provider.postMessageToWebview({
type: "skills",
skills: skills,
})
} catch (error) {
provider.log(`Error creating skill: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
vscode.window.showErrorMessage(
`Failed to create skill: ${error instanceof Error ? error.message : String(error)}`,
)
}
break
}
case "deleteSkill": {
try {
const skillName = message.text
const skillSource = message.values?.source as "global" | "project"

if (!skillName || !skillSource) {
provider.log("Missing skill name or source for deleteSkill")
break
}

// Delete the skill
await provider.getSkillsManager()?.deleteSkill(skillName, skillSource)

// Refresh skills list
const skills = provider.getSkillsManager()?.getSkillsForUI() ?? []
await provider.postMessageToWebview({
type: "skills",
skills: skills,
})
} catch (error) {
provider.log(`Error deleting skill: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
vscode.window.showErrorMessage(
`Failed to delete skill: ${error instanceof Error ? error.message : String(error)}`,
)
}
break
}
case "openSkillFile": {
try {
const skillName = message.text
const skillSource = message.values?.source as "global" | "project"

if (!skillName || !skillSource) {
provider.log("Missing skill name or source for openSkillFile")
break
}

const filePath = provider.getSkillsManager()?.getSkillFilePath(skillName, skillSource)

if (filePath) {
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath))
await vscode.window.showTextDocument(doc)
} else {
vscode.window.showErrorMessage(`Skill file not found: ${skillName}`)
}
} catch (error) {
provider.log(`Error opening skill file: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
vscode.window.showErrorMessage(
`Failed to open skill file: ${error instanceof Error ? error.message : String(error)}`,
)
}
break
}

case "insertTextIntoTextarea": {
const text = message.text
Expand Down
163 changes: 162 additions & 1 deletion src/services/skills/SkillsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,45 @@ import { getGlobalRooDirectory } from "../roo-config"
import { directoryExists, fileExists } from "../roo-config"
import { SkillMetadata, SkillContent } from "../../shared/skills"
import { modes, getAllModes } from "../../shared/modes"
import type { SkillForUI } from "../../shared/ExtensionMessage"

// Re-export for convenience
export type { SkillMetadata, SkillContent }
export type { SkillMetadata, SkillContent, SkillForUI }

/**
* Validation result for skill names
*/
interface ValidationResult {
valid: boolean
error?: string
}

/**
* Validate skill name according to agentskills.io specification
* @param name - Skill name to validate
* @returns Validation result with error message if invalid
*/
function isValidSkillName(name: string): ValidationResult {
// Length: 1-64 characters
if (name.length < 1 || name.length > 64) {
return {
valid: false,
error: `Skill name must be 1-64 characters (got ${name.length})`,
}
}

// Pattern: lowercase letters, numbers, hyphens only
// No leading/trailing hyphens, no consecutive hyphens
const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
if (!nameFormat.test(name)) {
return {
valid: false,
error: "Skill name must contain only lowercase letters, numbers, and hyphens (no leading/trailing hyphens, no consecutive hyphens)",
}
}

return { valid: true }
}

export class SkillsManager {
private skills: Map<string, SkillMetadata> = new Map()
Expand Down Expand Up @@ -239,6 +275,131 @@ export class SkillsManager {
}
}

/**
* Create a new skill with the given name in the specified location.
* Creates the directory structure and SKILL.md file with template content.
*
* @param name - Skill name (must be valid: 1-64 chars, lowercase, hyphens only)
* @param source - Where to create: "global" or "project"
* @returns Path to created SKILL.md file
* @throws Error if validation fails or skill already exists
*/
async createSkill(name: string, source: "global" | "project"): Promise<string> {
// Validate skill name
const validation = isValidSkillName(name)
if (!validation.valid) {
throw new Error(validation.error)
}

// Check if skill already exists
const existingKey = this.getSkillKey(name, source)
if (this.skills.has(existingKey)) {
throw new Error(`Skill "${name}" already exists in ${source}`)
}

// Determine base directory
const baseDir =
source === "global"
? getGlobalRooDirectory()
: this.providerRef.deref()?.cwd
? path.join(this.providerRef.deref()!.cwd, ".roo")
: null

if (!baseDir) {
throw new Error("Cannot create project skill: no project directory available")
}

// Create skill directory and SKILL.md
const skillsDir = path.join(baseDir, "skills")
const skillDir = path.join(skillsDir, name)
const skillMdPath = path.join(skillDir, "SKILL.md")

// Create directory structure
await fs.mkdir(skillDir, { recursive: true })

// Create title case name for template
const titleCaseName = name
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")

// Create SKILL.md with template
const template = `---
name: "${name}"
description: "Description of what this skill does"
---

# ${titleCaseName}

## Instructions

Add your skill instructions here...
`

await fs.writeFile(skillMdPath, template, "utf-8")

// Re-discover skills to update the internal cache
await this.discoverSkills()

return skillMdPath
}

/**
* Delete an existing skill directory.
*
* @param name - Skill name to delete
* @param source - Where the skill is located
* @throws Error if skill doesn't exist
*/
async deleteSkill(name: string, source: "global" | "project"): Promise<void> {
// Check if skill exists
const skillKey = this.getSkillKey(name, source)
const skill = this.skills.get(skillKey)

if (!skill) {
throw new Error(`Skill "${name}" not found in ${source}`)
}

// Get the skill directory (parent of SKILL.md)
const skillDir = path.dirname(skill.path)

// Delete the entire skill directory
await fs.rm(skillDir, { recursive: true, force: true })

// Re-discover skills to update the internal cache
await this.discoverSkills()
}

/**
* Get all skills formatted for UI display.
* Converts internal SkillMetadata to SkillForUI interface.
*
* @returns Array of skills formatted for UI
*/
getSkillsForUI(): SkillForUI[] {
return Array.from(this.skills.values()).map((skill) => ({
name: skill.name,
description: skill.description,
source: skill.source,
filePath: skill.path,
mode: skill.mode,
}))
}

/**
* Get the file path for a skill's SKILL.md file.
* Used for opening in editor.
*
* @param name - Skill name
* @param source - Where the skill is located
* @returns Full path to SKILL.md or undefined if not found
*/
getSkillFilePath(name: string, source: "global" | "project"): string | undefined {
const skillKey = this.getSkillKey(name, source)
const skill = this.skills.get(skillKey)
return skill?.path
}

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