Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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