diff --git a/packages/types/src/__tests__/skills.test.ts b/packages/types/src/__tests__/skills.test.ts new file mode 100644 index 00000000000..c215f7cdbe9 --- /dev/null +++ b/packages/types/src/__tests__/skills.test.ts @@ -0,0 +1,144 @@ +import { + validateSkillName, + SkillNameValidationError, + SKILL_NAME_MIN_LENGTH, + SKILL_NAME_MAX_LENGTH, + SKILL_NAME_REGEX, +} from "../skills.js" + +describe("validateSkillName", () => { + describe("valid names", () => { + it("accepts single lowercase word", () => { + expect(validateSkillName("myskill")).toEqual({ valid: true }) + }) + + it("accepts lowercase letters and numbers", () => { + expect(validateSkillName("skill123")).toEqual({ valid: true }) + }) + + it("accepts hyphenated words", () => { + expect(validateSkillName("my-skill")).toEqual({ valid: true }) + }) + + it("accepts multiple hyphenated words", () => { + expect(validateSkillName("my-awesome-skill")).toEqual({ valid: true }) + }) + + it("accepts single character", () => { + expect(validateSkillName("a")).toEqual({ valid: true }) + }) + + it("accepts single digit", () => { + expect(validateSkillName("1")).toEqual({ valid: true }) + }) + + it("accepts maximum length name (64 characters)", () => { + const maxLengthName = "a".repeat(SKILL_NAME_MAX_LENGTH) + expect(validateSkillName(maxLengthName)).toEqual({ valid: true }) + }) + }) + + describe("empty or missing names", () => { + it("rejects empty string", () => { + expect(validateSkillName("")).toEqual({ + valid: false, + error: SkillNameValidationError.Empty, + }) + }) + }) + + describe("names that are too long", () => { + it("rejects names longer than 64 characters", () => { + const tooLongName = "a".repeat(SKILL_NAME_MAX_LENGTH + 1) + expect(validateSkillName(tooLongName)).toEqual({ + valid: false, + error: SkillNameValidationError.TooLong, + }) + }) + }) + + describe("invalid format", () => { + it("rejects uppercase letters", () => { + expect(validateSkillName("MySkill")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + + it("rejects leading hyphen", () => { + expect(validateSkillName("-myskill")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + + it("rejects trailing hyphen", () => { + expect(validateSkillName("myskill-")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + + it("rejects consecutive hyphens", () => { + expect(validateSkillName("my--skill")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + + it("rejects spaces", () => { + expect(validateSkillName("my skill")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + + it("rejects underscores", () => { + expect(validateSkillName("my_skill")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + + it("rejects special characters", () => { + expect(validateSkillName("my@skill")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + + it("rejects dots", () => { + expect(validateSkillName("my.skill")).toEqual({ + valid: false, + error: SkillNameValidationError.InvalidFormat, + }) + }) + }) +}) + +describe("SKILL_NAME_REGEX", () => { + it("matches valid names", () => { + expect(SKILL_NAME_REGEX.test("myskill")).toBe(true) + expect(SKILL_NAME_REGEX.test("my-skill")).toBe(true) + expect(SKILL_NAME_REGEX.test("skill123")).toBe(true) + expect(SKILL_NAME_REGEX.test("a1-b2-c3")).toBe(true) + }) + + it("does not match invalid names", () => { + expect(SKILL_NAME_REGEX.test("-start")).toBe(false) + expect(SKILL_NAME_REGEX.test("end-")).toBe(false) + expect(SKILL_NAME_REGEX.test("double--hyphen")).toBe(false) + expect(SKILL_NAME_REGEX.test("UPPER")).toBe(false) + expect(SKILL_NAME_REGEX.test("")).toBe(false) + }) +}) + +describe("constants", () => { + it("has correct min length", () => { + expect(SKILL_NAME_MIN_LENGTH).toBe(1) + }) + + it("has correct max length", () => { + expect(SKILL_NAME_MAX_LENGTH).toBe(64) + }) +}) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 996ee781b28..ad012b3761f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -19,6 +19,7 @@ export * from "./message.js" export * from "./mode.js" export * from "./model.js" export * from "./provider-settings.js" +export * from "./skills.js" export * from "./task.js" export * from "./todo.js" export * from "./telemetry.js" diff --git a/packages/types/src/skills.ts b/packages/types/src/skills.ts new file mode 100644 index 00000000000..2c4ac176b0a --- /dev/null +++ b/packages/types/src/skills.ts @@ -0,0 +1,71 @@ +/** + * Skill metadata for discovery (loaded at startup) + * Only name and description are required for now + */ +export interface SkillMetadata { + name: string // Required: skill identifier + description: string // Required: when to use this skill + path: string // Absolute path to SKILL.md + source: "global" | "project" // Where the skill was discovered + mode?: string // If set, skill is only available in this mode +} + +/** + * Skill name validation constants per agentskills.io specification: + * https://agentskills.io/specification + * + * Name constraints: + * - 1-64 characters + * - Lowercase letters, numbers, and hyphens only + * - Must not start or end with a hyphen + * - Must not contain consecutive hyphens + */ +export const SKILL_NAME_MIN_LENGTH = 1 +export const SKILL_NAME_MAX_LENGTH = 64 + +/** + * Regex pattern for valid skill names. + * Matches: lowercase letters/numbers, optionally followed by groups of hyphen + lowercase letters/numbers. + * This ensures no leading/trailing hyphens and no consecutive hyphens. + */ +export const SKILL_NAME_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** + * Error codes for skill name validation. + * These can be mapped to translation keys in the frontend or error messages in the backend. + */ +export enum SkillNameValidationError { + Empty = "empty", + TooLong = "too_long", + InvalidFormat = "invalid_format", +} + +/** + * Result of skill name validation. + */ +export interface SkillNameValidationResult { + valid: boolean + error?: SkillNameValidationError +} + +/** + * Validate a skill name according to agentskills.io specification. + * + * @param name - The skill name to validate + * @returns Validation result with error code if invalid + */ +export function validateSkillName(name: string): SkillNameValidationResult { + if (!name || name.length < SKILL_NAME_MIN_LENGTH) { + return { valid: false, error: SkillNameValidationError.Empty } + } + + if (name.length > SKILL_NAME_MAX_LENGTH) { + return { valid: false, error: SkillNameValidationError.TooLong } + } + + if (!SKILL_NAME_REGEX.test(name)) { + return { valid: false, error: SkillNameValidationError.InvalidFormat } + } + + return { valid: true } +} diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index cd36b081576..8369a6e8245 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -18,6 +18,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" +import type { SkillMetadata } from "./skills.js" import type { ModelRecord, RouterModels } from "./model.js" import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js" import type { WorktreeIncludeStatus } from "./worktree.js" @@ -108,6 +109,7 @@ export interface ExtensionMessage { | "worktreeIncludeStatus" | "branchWorktreeIncludeResult" | "mergeWorktreeResult" + | "skills" text?: string payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any checkpointWarning?: { @@ -203,6 +205,7 @@ export interface ExtensionMessage { stepIndex?: number // For browserSessionNavigate: the target step index to display tools?: SerializedCustomToolDefinition[] // For customToolsResult modes?: { slug: string; name: string }[] // For modes response + skills?: SkillMetadata[] // For skills response aggregatedCosts?: { // For taskWithAggregatedCosts response totalCost: number @@ -609,6 +612,11 @@ export interface WebviewMessage { | "createWorktreeInclude" | "checkoutBranch" | "mergeWorktree" + // Skills messages + | "requestSkills" + | "createSkill" + | "deleteSkill" + | "openSkillFile" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -643,6 +651,9 @@ export interface WebviewMessage { timeout?: number payload?: WebViewMessagePayload source?: "global" | "project" + skillName?: string // For skill operations (createSkill, deleteSkill, openSkillFile) + skillMode?: string // For skill operations (mode restriction) + skillDescription?: string // For createSkill (skill description) requestId?: string ids?: string[] hasSystemPromptOverride?: boolean diff --git a/src/core/webview/__tests__/skillsMessageHandler.spec.ts b/src/core/webview/__tests__/skillsMessageHandler.spec.ts new file mode 100644 index 00000000000..900796dd6c3 --- /dev/null +++ b/src/core/webview/__tests__/skillsMessageHandler.spec.ts @@ -0,0 +1,334 @@ +// npx vitest run src/core/webview/__tests__/skillsMessageHandler.spec.ts + +import type { SkillMetadata, WebviewMessage } from "@roo-code/types" +import type { ClineProvider } from "../ClineProvider" + +// Mock vscode first +vi.mock("vscode", () => { + const showErrorMessage = vi.fn() + + return { + window: { + showErrorMessage, + }, + } +}) + +// Mock open-file +vi.mock("../../../integrations/misc/open-file", () => ({ + openFile: vi.fn(), +})) + +// Mock i18n +vi.mock("../../../i18n", () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + "skills:errors.missing_create_fields": "Missing required fields: skillName, source, or skillDescription", + "skills:errors.manager_unavailable": "Skills manager not available", + "skills:errors.missing_delete_fields": "Missing required fields: skillName or source", + "skills:errors.skill_not_found": `Skill "${params?.name}" not found`, + } + return translations[key] || key + }, +})) + +import * as vscode from "vscode" +import { openFile } from "../../../integrations/misc/open-file" +import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "../skillsMessageHandler" + +describe("skillsMessageHandler", () => { + const mockLog = vi.fn() + const mockPostMessageToWebview = vi.fn() + const mockGetSkillsMetadata = vi.fn() + const mockCreateSkill = vi.fn() + const mockDeleteSkill = vi.fn() + const mockGetSkill = vi.fn() + + const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => { + const skillsManager = hasSkillsManager + ? { + getSkillsMetadata: mockGetSkillsMetadata, + createSkill: mockCreateSkill, + deleteSkill: mockDeleteSkill, + getSkill: mockGetSkill, + } + : undefined + + return { + log: mockLog, + postMessageToWebview: mockPostMessageToWebview, + getSkillsManager: () => skillsManager, + } as unknown as ClineProvider + } + + const mockSkills: SkillMetadata[] = [ + { + name: "test-skill", + description: "Test skill description", + path: "/path/to/test-skill/SKILL.md", + source: "global", + }, + { + name: "project-skill", + description: "Project skill description", + path: "/project/.roo/skills/project-skill/SKILL.md", + source: "project", + mode: "code", + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("handleRequestSkills", () => { + it("returns skills when skills manager is available", async () => { + const provider = createMockProvider(true) + mockGetSkillsMetadata.mockReturnValue(mockSkills) + + const result = await handleRequestSkills(provider) + + expect(result).toEqual(mockSkills) + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: mockSkills }) + }) + + it("returns empty skills when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleRequestSkills(provider) + + expect(result).toEqual([]) + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [] }) + }) + + it("handles errors and returns empty skills", async () => { + const provider = createMockProvider(true) + mockGetSkillsMetadata.mockImplementation(() => { + throw new Error("Test error") + }) + + const result = await handleRequestSkills(provider) + + expect(result).toEqual([]) + expect(mockLog).toHaveBeenCalled() + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [] }) + }) + }) + + describe("handleCreateSkill", () => { + it("creates a skill successfully", async () => { + const provider = createMockProvider(true) + mockCreateSkill.mockResolvedValue("/path/to/new-skill/SKILL.md") + mockGetSkillsMetadata.mockReturnValue(mockSkills) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + source: "global", + skillDescription: "New skill description", + } as WebviewMessage) + + expect(result).toEqual(mockSkills) + expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "global", "New skill description", undefined) + expect(openFile).toHaveBeenCalledWith("/path/to/new-skill/SKILL.md") + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: mockSkills }) + }) + + it("creates a skill with mode restriction", async () => { + const provider = createMockProvider(true) + mockCreateSkill.mockResolvedValue("/path/to/new-skill/SKILL.md") + mockGetSkillsMetadata.mockReturnValue(mockSkills) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + source: "project", + skillDescription: "New skill description", + skillMode: "code", + } as WebviewMessage) + + expect(result).toEqual(mockSkills) + expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", "code") + }) + + it("returns undefined when required fields are missing", async () => { + const provider = createMockProvider(true) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + // missing source and skillDescription + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith( + "Error creating skill: Missing required fields: skillName, source, or skillDescription", + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to create skill: Missing required fields: skillName, source, or skillDescription", + ) + }) + + it("returns undefined when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + source: "global", + skillDescription: "New skill description", + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error creating skill: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to create skill: Skills manager not available", + ) + }) + }) + + describe("handleDeleteSkill", () => { + it("deletes a skill successfully", async () => { + const provider = createMockProvider(true) + mockDeleteSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[1]]) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[1]]) + expect(mockDeleteSkill).toHaveBeenCalledWith("test-skill", "global", undefined) + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[1]] }) + }) + + it("deletes a skill with mode restriction", async () => { + const provider = createMockProvider(true) + mockDeleteSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[0]]) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "project-skill", + source: "project", + skillMode: "code", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[0]]) + expect(mockDeleteSkill).toHaveBeenCalledWith("project-skill", "project", "code") + }) + + it("returns undefined when required fields are missing", async () => { + const provider = createMockProvider(true) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "test-skill", + // missing source + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error deleting skill: Missing required fields: skillName or source") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to delete skill: Missing required fields: skillName or source", + ) + }) + + it("returns undefined when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error deleting skill: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to delete skill: Skills manager not available", + ) + }) + }) + + describe("handleOpenSkillFile", () => { + it("opens a skill file successfully", async () => { + const provider = createMockProvider(true) + mockGetSkill.mockReturnValue(mockSkills[0]) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(mockGetSkill).toHaveBeenCalledWith("test-skill", "global", undefined) + expect(openFile).toHaveBeenCalledWith("/path/to/test-skill/SKILL.md") + }) + + it("opens a skill file with mode restriction", async () => { + const provider = createMockProvider(true) + mockGetSkill.mockReturnValue(mockSkills[1]) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "project-skill", + source: "project", + skillMode: "code", + } as WebviewMessage) + + expect(mockGetSkill).toHaveBeenCalledWith("project-skill", "project", "code") + expect(openFile).toHaveBeenCalledWith("/project/.roo/skills/project-skill/SKILL.md") + }) + + it("shows error when required fields are missing", async () => { + const provider = createMockProvider(true) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "test-skill", + // missing source + } as WebviewMessage) + + expect(mockLog).toHaveBeenCalledWith( + "Error opening skill file: Missing required fields: skillName or source", + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open skill file: Missing required fields: skillName or source", + ) + }) + + it("shows error when skills manager is not available", async () => { + const provider = createMockProvider(false) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(mockLog).toHaveBeenCalledWith("Error opening skill file: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open skill file: Skills manager not available", + ) + }) + + it("shows error when skill is not found", async () => { + const provider = createMockProvider(true) + mockGetSkill.mockReturnValue(undefined) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "nonexistent-skill", + source: "global", + } as WebviewMessage) + + expect(mockLog).toHaveBeenCalledWith('Error opening skill file: Skill "nonexistent-skill" not found') + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'Failed to open skill file: Skill "nonexistent-skill" not found', + ) + }) + }) +}) diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts new file mode 100644 index 00000000000..649c036b4cc --- /dev/null +++ b/src/core/webview/skillsMessageHandler.ts @@ -0,0 +1,133 @@ +import * as vscode from "vscode" + +import type { SkillMetadata, WebviewMessage } from "@roo-code/types" + +import type { ClineProvider } from "./ClineProvider" +import { openFile } from "../../integrations/misc/open-file" +import { t } from "../../i18n" + +/** + * Handles the requestSkills message - returns all skills metadata + */ +export async function handleRequestSkills(provider: ClineProvider): Promise { + try { + const skillsManager = provider.getSkillsManager() + if (skillsManager) { + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } else { + await provider.postMessageToWebview({ type: "skills", skills: [] }) + return [] + } + } catch (error) { + provider.log(`Error fetching skills: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + await provider.postMessageToWebview({ type: "skills", skills: [] }) + return [] + } +} + +/** + * Handles the createSkill message - creates a new skill + */ +export async function handleCreateSkill( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source + const skillDescription = message.skillDescription + const skillMode = message.skillMode + + if (!skillName || !source || !skillDescription) { + throw new Error(t("skills:errors.missing_create_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, skillMode) + + // Open the created file in the editor + openFile(createdPath) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error creating skill: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to create skill: ${errorMessage}`) + return undefined + } +} + +/** + * Handles the deleteSkill message - deletes a skill + */ +export async function handleDeleteSkill( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source + const skillMode = message.skillMode + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_delete_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + await skillsManager.deleteSkill(skillName, source, skillMode) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error deleting skill: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to delete skill: ${errorMessage}`) + return undefined + } +} + +/** + * Handles the openSkillFile message - opens a skill file in the editor + */ +export async function handleOpenSkillFile(provider: ClineProvider, message: WebviewMessage): Promise { + try { + const skillName = message.skillName + const source = message.source + const skillMode = message.skillMode + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_delete_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + const skill = skillsManager.getSkill(skillName, source, skillMode) + if (!skill) { + throw new Error(t("skills:errors.skill_not_found", { name: skillName })) + } + + openFile(skill.path) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error opening skill file: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to open skill file: ${errorMessage}`) + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2af791b93ee..01969605296 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -32,6 +32,7 @@ import { ClineProvider } from "./ClineProvider" import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { generateErrorDiagnostics } from "./diagnosticsHandler" +import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "./skillsMessageHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" @@ -3012,6 +3013,22 @@ export const webviewMessageHandler = async ( } break } + case "requestSkills": { + await handleRequestSkills(provider) + break + } + case "createSkill": { + await handleCreateSkill(provider, message) + break + } + case "deleteSkill": { + await handleDeleteSkill(provider, message) + break + } + case "openSkillFile": { + await handleOpenSkillFile(provider, message) + break + } case "openCommandFile": { try { if (message.text) { diff --git a/src/i18n/locales/ca/skills.json b/src/i18n/locales/ca/skills.json new file mode 100644 index 00000000000..cce8bf45b0e --- /dev/null +++ b/src/i18n/locales/ca/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "El nom de l'habilitat ha de tenir entre 1 i {{maxLength}} caràcters (s'han rebut {{length}})", + "name_format": "El nom de l'habilitat només pot contenir lletres minúscules, números i guions (sense guions inicials o finals, sense guions consecutius)", + "description_length": "La descripció de l'habilitat ha de tenir entre 1 i 1024 caràcters (s'han rebut {{length}})", + "no_workspace": "No es pot crear l'habilitat del projecte: no hi ha cap carpeta d'espai de treball oberta", + "already_exists": "L'habilitat \"{{name}}\" ja existeix a {{path}}", + "not_found": "No s'ha trobat l'habilitat \"{{name}}\" a {{source}}{{modeInfo}}", + "missing_create_fields": "Falten camps obligatoris: skillName, source o skillDescription", + "manager_unavailable": "El gestor d'habilitats no està disponible", + "missing_delete_fields": "Falten camps obligatoris: skillName o source", + "skill_not_found": "No s'ha trobat l'habilitat \"{{name}}\"" + } +} diff --git a/src/i18n/locales/de/skills.json b/src/i18n/locales/de/skills.json new file mode 100644 index 00000000000..90356110408 --- /dev/null +++ b/src/i18n/locales/de/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Skill-Name muss 1-{{maxLength}} Zeichen lang sein (erhalten: {{length}})", + "name_format": "Skill-Name darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten (keine führenden oder nachgestellten Bindestriche, keine aufeinanderfolgenden Bindestriche)", + "description_length": "Skill-Beschreibung muss 1-1024 Zeichen lang sein (erhalten: {{length}})", + "no_workspace": "Projekt-Skill kann nicht erstellt werden: kein Workspace-Ordner ist geöffnet", + "already_exists": "Skill \"{{name}}\" existiert bereits unter {{path}}", + "not_found": "Skill \"{{name}}\" nicht gefunden in {{source}}{{modeInfo}}", + "missing_create_fields": "Erforderliche Felder fehlen: skillName, source oder skillDescription", + "manager_unavailable": "Skill-Manager nicht verfügbar", + "missing_delete_fields": "Erforderliche Felder fehlen: skillName oder source", + "skill_not_found": "Skill \"{{name}}\" nicht gefunden" + } +} diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json new file mode 100644 index 00000000000..9cf7369bc9b --- /dev/null +++ b/src/i18n/locales/en/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Skill name must be 1-{{maxLength}} characters (got {{length}})", + "name_format": "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", + "description_length": "Skill description must be 1-1024 characters (got {{length}})", + "no_workspace": "Cannot create project skill: no workspace folder is open", + "already_exists": "Skill \"{{name}}\" already exists at {{path}}", + "not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}", + "missing_create_fields": "Missing required fields: skillName, source, or skillDescription", + "manager_unavailable": "Skills manager not available", + "missing_delete_fields": "Missing required fields: skillName or source", + "skill_not_found": "Skill \"{{name}}\" not found" + } +} diff --git a/src/i18n/locales/es/skills.json b/src/i18n/locales/es/skills.json new file mode 100644 index 00000000000..d6d0727262f --- /dev/null +++ b/src/i18n/locales/es/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "El nombre de la habilidad debe tener entre 1 y {{maxLength}} caracteres (se recibieron {{length}})", + "name_format": "El nombre de la habilidad solo puede contener letras minúsculas, números y guiones (sin guiones al inicio o al final, sin guiones consecutivos)", + "description_length": "La descripción de la habilidad debe tener entre 1 y 1024 caracteres (se recibieron {{length}})", + "no_workspace": "No se puede crear la habilidad del proyecto: no hay ninguna carpeta de espacio de trabajo abierta", + "already_exists": "La habilidad \"{{name}}\" ya existe en {{path}}", + "not_found": "No se encontró la habilidad \"{{name}}\" en {{source}}{{modeInfo}}", + "missing_create_fields": "Faltan campos obligatorios: skillName, source o skillDescription", + "manager_unavailable": "El gestor de habilidades no está disponible", + "missing_delete_fields": "Faltan campos obligatorios: skillName o source", + "skill_not_found": "No se encontró la habilidad \"{{name}}\"" + } +} diff --git a/src/i18n/locales/fr/skills.json b/src/i18n/locales/fr/skills.json new file mode 100644 index 00000000000..1337cb49b8e --- /dev/null +++ b/src/i18n/locales/fr/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Le nom de la compétence doit contenir entre 1 et {{maxLength}} caractères ({{length}} reçu)", + "name_format": "Le nom de la compétence ne peut contenir que des lettres minuscules, des chiffres et des traits d'union (pas de trait d'union initial ou final, pas de traits d'union consécutifs)", + "description_length": "La description de la compétence doit contenir entre 1 et 1024 caractères ({{length}} reçu)", + "no_workspace": "Impossible de créer la compétence de projet : aucun dossier d'espace de travail n'est ouvert", + "already_exists": "La compétence \"{{name}}\" existe déjà à {{path}}", + "not_found": "Compétence \"{{name}}\" introuvable dans {{source}}{{modeInfo}}", + "missing_create_fields": "Champs obligatoires manquants : skillName, source ou skillDescription", + "manager_unavailable": "Le gestionnaire de compétences n'est pas disponible", + "missing_delete_fields": "Champs obligatoires manquants : skillName ou source", + "skill_not_found": "Compétence \"{{name}}\" introuvable" + } +} diff --git a/src/i18n/locales/hi/skills.json b/src/i18n/locales/hi/skills.json new file mode 100644 index 00000000000..bd5235c24db --- /dev/null +++ b/src/i18n/locales/hi/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "स्किल का नाम 1-{{maxLength}} वर्णों का होना चाहिए ({{length}} प्राप्त हुआ)", + "name_format": "स्किल के नाम में केवल छोटे अक्षर, संख्याएं और हाइफ़न हो सकते हैं (शुरुआत या अंत में हाइफ़न नहीं, लगातार हाइफ़न नहीं)", + "description_length": "स्किल का विवरण 1-1024 वर्णों का होना चाहिए ({{length}} प्राप्त हुआ)", + "no_workspace": "प्रोजेक्ट स्किल नहीं बनाया जा सकता: कोई वर्कस्पेस फ़ोल्डर खुला नहीं है", + "already_exists": "स्किल \"{{name}}\" पहले से {{path}} पर मौजूद है", + "not_found": "स्किल \"{{name}}\" {{source}}{{modeInfo}} में नहीं मिला", + "missing_create_fields": "आवश्यक फ़ील्ड गायब हैं: skillName, source, या skillDescription", + "manager_unavailable": "स्किल मैनेजर उपलब्ध नहीं है", + "missing_delete_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", + "skill_not_found": "स्किल \"{{name}}\" नहीं मिला" + } +} diff --git a/src/i18n/locales/id/skills.json b/src/i18n/locales/id/skills.json new file mode 100644 index 00000000000..0d9958a7744 --- /dev/null +++ b/src/i18n/locales/id/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Nama skill harus 1-{{maxLength}} karakter (diterima {{length}})", + "name_format": "Nama skill hanya boleh berisi huruf kecil, angka, dan tanda hubung (tanpa tanda hubung di awal atau akhir, tanpa tanda hubung berturut-turut)", + "description_length": "Deskripsi skill harus 1-1024 karakter (diterima {{length}})", + "no_workspace": "Tidak dapat membuat skill proyek: tidak ada folder workspace yang terbuka", + "already_exists": "Skill \"{{name}}\" sudah ada di {{path}}", + "not_found": "Skill \"{{name}}\" tidak ditemukan di {{source}}{{modeInfo}}", + "missing_create_fields": "Bidang wajib tidak ada: skillName, source, atau skillDescription", + "manager_unavailable": "Manajer skill tidak tersedia", + "missing_delete_fields": "Bidang wajib tidak ada: skillName atau source", + "skill_not_found": "Skill \"{{name}}\" tidak ditemukan" + } +} diff --git a/src/i18n/locales/it/skills.json b/src/i18n/locales/it/skills.json new file mode 100644 index 00000000000..fa0fe6559e8 --- /dev/null +++ b/src/i18n/locales/it/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Il nome della skill deve essere di 1-{{maxLength}} caratteri (ricevuti {{length}})", + "name_format": "Il nome della skill può contenere solo lettere minuscole, numeri e trattini (senza trattini iniziali o finali, senza trattini consecutivi)", + "description_length": "La descrizione della skill deve essere di 1-1024 caratteri (ricevuti {{length}})", + "no_workspace": "Impossibile creare la skill del progetto: nessuna cartella di workspace aperta", + "already_exists": "La skill \"{{name}}\" esiste già in {{path}}", + "not_found": "Skill \"{{name}}\" non trovata in {{source}}{{modeInfo}}", + "missing_create_fields": "Campi obbligatori mancanti: skillName, source o skillDescription", + "manager_unavailable": "Il gestore delle skill non è disponibile", + "missing_delete_fields": "Campi obbligatori mancanti: skillName o source", + "skill_not_found": "Skill \"{{name}}\" non trovata" + } +} diff --git a/src/i18n/locales/ja/skills.json b/src/i18n/locales/ja/skills.json new file mode 100644 index 00000000000..baef99a5012 --- /dev/null +++ b/src/i18n/locales/ja/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "スキル名は1-{{maxLength}}文字である必要があります({{length}}文字を受信)", + "name_format": "スキル名には小文字、数字、ハイフンのみ使用できます(先頭または末尾のハイフン、連続するハイフンは不可)", + "description_length": "スキルの説明は1-1024文字である必要があります({{length}}文字を受信)", + "no_workspace": "プロジェクトスキルを作成できません:ワークスペースフォルダが開かれていません", + "already_exists": "スキル「{{name}}」は既に{{path}}に存在します", + "not_found": "スキル「{{name}}」が{{source}}{{modeInfo}}に見つかりません", + "missing_create_fields": "必須フィールドが不足しています:skillName、source、またはskillDescription", + "manager_unavailable": "スキルマネージャーが利用できません", + "missing_delete_fields": "必須フィールドが不足しています:skillNameまたはsource", + "skill_not_found": "スキル「{{name}}」が見つかりません" + } +} diff --git a/src/i18n/locales/ko/skills.json b/src/i18n/locales/ko/skills.json new file mode 100644 index 00000000000..0561b10dbbb --- /dev/null +++ b/src/i18n/locales/ko/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "스킬 이름은 1-{{maxLength}}자여야 합니다({{length}}자 수신됨)", + "name_format": "스킬 이름은 소문자, 숫자, 하이픈만 포함할 수 있습니다(앞뒤 하이픈 없음, 연속 하이픈 없음)", + "description_length": "스킬 설명은 1-1024자여야 합니다({{length}}자 수신됨)", + "no_workspace": "프로젝트 스킬을 생성할 수 없습니다: 열린 작업 공간 폴더가 없습니다", + "already_exists": "스킬 \"{{name}}\"이(가) 이미 {{path}}에 존재합니다", + "not_found": "{{source}}{{modeInfo}}에서 스킬 \"{{name}}\"을(를) 찾을 수 없습니다", + "missing_create_fields": "필수 필드 누락: skillName, source 또는 skillDescription", + "manager_unavailable": "스킬 관리자를 사용할 수 없습니다", + "missing_delete_fields": "필수 필드 누락: skillName 또는 source", + "skill_not_found": "스킬 \"{{name}}\"을(를) 찾을 수 없습니다" + } +} diff --git a/src/i18n/locales/nl/skills.json b/src/i18n/locales/nl/skills.json new file mode 100644 index 00000000000..2a6fd2f733b --- /dev/null +++ b/src/i18n/locales/nl/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Vaardigheidsnaam moet 1-{{maxLength}} tekens lang zijn ({{length}} ontvangen)", + "name_format": "Vaardigheidsnaam mag alleen kleine letters, cijfers en koppeltekens bevatten (geen voorloop- of achterloop-koppeltekens, geen opeenvolgende koppeltekens)", + "description_length": "Vaardigheidsbeschrijving moet 1-1024 tekens lang zijn ({{length}} ontvangen)", + "no_workspace": "Kan projectvaardigheid niet aanmaken: geen werkruimtemap geopend", + "already_exists": "Vaardigheid \"{{name}}\" bestaat al op {{path}}", + "not_found": "Vaardigheid \"{{name}}\" niet gevonden in {{source}}{{modeInfo}}", + "missing_create_fields": "Vereiste velden ontbreken: skillName, source of skillDescription", + "manager_unavailable": "Vaardigheidenbeheerder niet beschikbaar", + "missing_delete_fields": "Vereiste velden ontbreken: skillName of source", + "skill_not_found": "Vaardigheid \"{{name}}\" niet gevonden" + } +} diff --git a/src/i18n/locales/pl/skills.json b/src/i18n/locales/pl/skills.json new file mode 100644 index 00000000000..6f0fb3a7d48 --- /dev/null +++ b/src/i18n/locales/pl/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Nazwa umiejętności musi mieć 1-{{maxLength}} znaków (otrzymano {{length}})", + "name_format": "Nazwa umiejętności może zawierać tylko małe litery, cyfry i myślniki (bez myślników na początku lub końcu, bez następujących po sobie myślników)", + "description_length": "Opis umiejętności musi mieć 1-1024 znaków (otrzymano {{length}})", + "no_workspace": "Nie można utworzyć umiejętności projektu: nie otwarto folderu obszaru roboczego", + "already_exists": "Umiejętność \"{{name}}\" już istnieje w {{path}}", + "not_found": "Nie znaleziono umiejętności \"{{name}}\" w {{source}}{{modeInfo}}", + "missing_create_fields": "Brakuje wymaganych pól: skillName, source lub skillDescription", + "manager_unavailable": "Menedżer umiejętności niedostępny", + "missing_delete_fields": "Brakuje wymaganych pól: skillName lub source", + "skill_not_found": "Nie znaleziono umiejętności \"{{name}}\"" + } +} diff --git a/src/i18n/locales/pt-BR/skills.json b/src/i18n/locales/pt-BR/skills.json new file mode 100644 index 00000000000..b54655f483c --- /dev/null +++ b/src/i18n/locales/pt-BR/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "O nome da habilidade deve ter de 1 a {{maxLength}} caracteres (recebido {{length}})", + "name_format": "O nome da habilidade só pode conter letras minúsculas, números e hifens (sem hifens iniciais ou finais, sem hifens consecutivos)", + "description_length": "A descrição da habilidade deve ter de 1 a 1024 caracteres (recebido {{length}})", + "no_workspace": "Não é possível criar habilidade do projeto: nenhuma pasta de espaço de trabalho está aberta", + "already_exists": "A habilidade \"{{name}}\" já existe em {{path}}", + "not_found": "Habilidade \"{{name}}\" não encontrada em {{source}}{{modeInfo}}", + "missing_create_fields": "Campos obrigatórios ausentes: skillName, source ou skillDescription", + "manager_unavailable": "Gerenciador de habilidades não disponível", + "missing_delete_fields": "Campos obrigatórios ausentes: skillName ou source", + "skill_not_found": "Habilidade \"{{name}}\" não encontrada" + } +} diff --git a/src/i18n/locales/ru/skills.json b/src/i18n/locales/ru/skills.json new file mode 100644 index 00000000000..7feee5d6c29 --- /dev/null +++ b/src/i18n/locales/ru/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Имя навыка должно быть от 1 до {{maxLength}} символов (получено {{length}})", + "name_format": "Имя навыка может содержать только строчные буквы, цифры и дефисы (без начальных или конечных дефисов, без последовательных дефисов)", + "description_length": "Описание навыка должно быть от 1 до 1024 символов (получено {{length}})", + "no_workspace": "Невозможно создать навык проекта: не открыта папка рабочего пространства", + "already_exists": "Навык \"{{name}}\" уже существует в {{path}}", + "not_found": "Навык \"{{name}}\" не найден в {{source}}{{modeInfo}}", + "missing_create_fields": "Отсутствуют обязательные поля: skillName, source или skillDescription", + "manager_unavailable": "Менеджер навыков недоступен", + "missing_delete_fields": "Отсутствуют обязательные поля: skillName или source", + "skill_not_found": "Навык \"{{name}}\" не найден" + } +} diff --git a/src/i18n/locales/tr/skills.json b/src/i18n/locales/tr/skills.json new file mode 100644 index 00000000000..2e01d37378a --- /dev/null +++ b/src/i18n/locales/tr/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Beceri adı 1-{{maxLength}} karakter olmalıdır ({{length}} alındı)", + "name_format": "Beceri adı yalnızca küçük harfler, rakamlar ve tire içerebilir (başta veya sonda tire yok, ardışık tire yok)", + "description_length": "Beceri açıklaması 1-1024 karakter olmalıdır ({{length}} alındı)", + "no_workspace": "Proje becerisi oluşturulamıyor: açık çalışma alanı klasörü yok", + "already_exists": "\"{{name}}\" becerisi zaten {{path}} konumunda mevcut", + "not_found": "\"{{name}}\" becerisi {{source}}{{modeInfo}} içinde bulunamadı", + "missing_create_fields": "Gerekli alanlar eksik: skillName, source veya skillDescription", + "manager_unavailable": "Beceri yöneticisi kullanılamıyor", + "missing_delete_fields": "Gerekli alanlar eksik: skillName veya source", + "skill_not_found": "\"{{name}}\" becerisi bulunamadı" + } +} diff --git a/src/i18n/locales/vi/skills.json b/src/i18n/locales/vi/skills.json new file mode 100644 index 00000000000..bc3074a1c84 --- /dev/null +++ b/src/i18n/locales/vi/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "Tên kỹ năng phải từ 1-{{maxLength}} ký tự (nhận được {{length}})", + "name_format": "Tên kỹ năng chỉ có thể chứa chữ cái thường, số và dấu gạch ngang (không có dấu gạch ngang đầu hoặc cuối, không có dấu gạch ngang liên tiếp)", + "description_length": "Mô tả kỹ năng phải từ 1-1024 ký tự (nhận được {{length}})", + "no_workspace": "Không thể tạo kỹ năng dự án: không có thư mục vùng làm việc nào được mở", + "already_exists": "Kỹ năng \"{{name}}\" đã tồn tại tại {{path}}", + "not_found": "Không tìm thấy kỹ năng \"{{name}}\" trong {{source}}{{modeInfo}}", + "missing_create_fields": "Thiếu các trường bắt buộc: skillName, source hoặc skillDescription", + "manager_unavailable": "Trình quản lý kỹ năng không khả dụng", + "missing_delete_fields": "Thiếu các trường bắt buộc: skillName hoặc source", + "skill_not_found": "Không tìm thấy kỹ năng \"{{name}}\"" + } +} diff --git a/src/i18n/locales/zh-CN/skills.json b/src/i18n/locales/zh-CN/skills.json new file mode 100644 index 00000000000..629aaeb6d74 --- /dev/null +++ b/src/i18n/locales/zh-CN/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "技能名称必须为 1-{{maxLength}} 个字符(收到 {{length}} 个)", + "name_format": "技能名称只能包含小写字母、数字和连字符(不能有前导或尾随连字符,不能有连续连字符)", + "description_length": "技能描述必须为 1-1024 个字符(收到 {{length}} 个)", + "no_workspace": "无法创建项目技能:未打开工作区文件夹", + "already_exists": "技能 \"{{name}}\" 已存在于 {{path}}", + "not_found": "在 {{source}}{{modeInfo}} 中未找到技能 \"{{name}}\"", + "missing_create_fields": "缺少必填字段:skillName、source 或 skillDescription", + "manager_unavailable": "技能管理器不可用", + "missing_delete_fields": "缺少必填字段:skillName 或 source", + "skill_not_found": "未找到技能 \"{{name}}\"" + } +} diff --git a/src/i18n/locales/zh-TW/skills.json b/src/i18n/locales/zh-TW/skills.json new file mode 100644 index 00000000000..10705f4cce8 --- /dev/null +++ b/src/i18n/locales/zh-TW/skills.json @@ -0,0 +1,14 @@ +{ + "errors": { + "name_length": "技能名稱必須為 1-{{maxLength}} 個字元(收到 {{length}} 個)", + "name_format": "技能名稱只能包含小寫字母、數字和連字號(不能有前導或尾隨連字號,不能有連續連字號)", + "description_length": "技能描述必須為 1-1024 個字元(收到 {{length}} 個)", + "no_workspace": "無法建立專案技能:未開啟工作區資料夾", + "already_exists": "技能「{{name}}」已存在於 {{path}}", + "not_found": "在 {{source}}{{modeInfo}} 中找不到技能「{{name}}」", + "missing_create_fields": "缺少必填欄位:skillName、source 或 skillDescription", + "manager_unavailable": "技能管理器無法使用", + "missing_delete_fields": "缺少必填欄位:skillName 或 source", + "skill_not_found": "找不到技能「{{name}}」" + } +} diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 59b50cf1713..f9d27255f81 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -1,5 +1,6 @@ import * as fs from "fs/promises" import * as path from "path" +import * as os from "os" import * as vscode from "vscode" import matter from "gray-matter" @@ -8,6 +9,12 @@ import { getGlobalRooDirectory } from "../roo-config" import { directoryExists, fileExists } from "../roo-config" import { SkillMetadata, SkillContent } from "../../shared/skills" import { modes, getAllModes } from "../../shared/modes" +import { + validateSkillName as validateSkillNameShared, + SkillNameValidationError, + SKILL_NAME_MAX_LENGTH, +} from "@roo-code/types" +import { t } from "../../i18n" // Re-export for convenience export type { SkillMetadata, SkillContent } @@ -116,23 +123,11 @@ export class SkillsManager { return } - // Strict spec validation (https://agentskills.io/specification) - // Name constraints: - // - 1-64 chars - // - lowercase letters/numbers/hyphens only - // - must not start/end with hyphen - // - must not contain consecutive hyphens - if (effectiveSkillName.length < 1 || effectiveSkillName.length > 64) { - console.error( - `Skill name "${effectiveSkillName}" is invalid: name must be 1-64 characters (got ${effectiveSkillName.length})`, - ) - return - } - const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - if (!nameFormat.test(effectiveSkillName)) { - console.error( - `Skill name "${effectiveSkillName}" is invalid: must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)`, - ) + // Validate skill name per agentskills.io spec using shared validation + const nameValidation = validateSkillNameShared(effectiveSkillName) + if (!nameValidation.valid) { + const errorMessage = this.getSkillNameErrorMessage(effectiveSkillName, nameValidation.error!) + console.error(`Skill name "${effectiveSkillName}" is invalid: ${errorMessage}`) return } @@ -239,6 +234,146 @@ export class SkillsManager { } } + /** + * Get all skills metadata (for UI display) + * Returns skills from all sources without content + */ + getSkillsMetadata(): SkillMetadata[] { + return this.getAllSkills() + } + + /** + * Get a skill by name, source, and optionally mode + */ + getSkill(name: string, source: "global" | "project", mode?: string): SkillMetadata | undefined { + const skillKey = this.getSkillKey(name, source, mode) + return this.skills.get(skillKey) + } + + /** + * Validate skill name per agentskills.io spec using shared validation. + * Converts error codes to user-friendly error messages. + */ + private validateSkillName(name: string): { valid: boolean; error?: string } { + const result = validateSkillNameShared(name) + if (!result.valid) { + return { valid: false, error: this.getSkillNameErrorMessage(name, result.error!) } + } + return { valid: true } + } + + /** + * Convert skill name validation error code to a user-friendly error message. + */ + private getSkillNameErrorMessage(name: string, error: SkillNameValidationError): string { + switch (error) { + case SkillNameValidationError.Empty: + return t("skills:errors.name_length", { maxLength: SKILL_NAME_MAX_LENGTH, length: name.length }) + case SkillNameValidationError.TooLong: + return t("skills:errors.name_length", { maxLength: SKILL_NAME_MAX_LENGTH, length: name.length }) + case SkillNameValidationError.InvalidFormat: + return t("skills:errors.name_format") + } + } + + /** + * Create a new skill + * @param name - Skill name (must be valid per agentskills.io spec) + * @param source - "global" or "project" + * @param description - Skill description + * @param mode - Optional mode restriction (creates in skills-{mode}/ directory) + * @returns Path to created SKILL.md file + */ + async createSkill(name: string, source: "global" | "project", description: string, mode?: string): Promise { + // Validate skill name + const validation = this.validateSkillName(name) + if (!validation.valid) { + throw new Error(validation.error) + } + + // Validate description + const trimmedDescription = description.trim() + if (trimmedDescription.length < 1 || trimmedDescription.length > 1024) { + throw new Error(t("skills:errors.description_length", { length: trimmedDescription.length })) + } + + // Determine base directory + let baseDir: string + if (source === "global") { + baseDir = getGlobalRooDirectory() + } else { + const provider = this.providerRef.deref() + if (!provider?.cwd) { + throw new Error(t("skills:errors.no_workspace")) + } + baseDir = path.join(provider.cwd, ".roo") + } + + // Determine skills directory (with optional mode suffix) + const skillsDirName = mode ? `skills-${mode}` : "skills" + const skillsDir = path.join(baseDir, skillsDirName) + const skillDir = path.join(skillsDir, name) + const skillMdPath = path.join(skillDir, "SKILL.md") + + // Check if skill already exists + if (await fileExists(skillMdPath)) { + throw new Error(t("skills:errors.already_exists", { name, path: skillMdPath })) + } + + // Create the skill directory + await fs.mkdir(skillDir, { recursive: true }) + + // Generate SKILL.md content with frontmatter + const titleName = name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + + const skillContent = `--- +name: ${name} +description: ${trimmedDescription} +--- + +# ${titleName} + +## Instructions + +Add your skill instructions here. +` + + // Write the SKILL.md file + await fs.writeFile(skillMdPath, skillContent, "utf-8") + + // Refresh skills list + await this.discoverSkills() + + return skillMdPath + } + + /** + * Delete a skill + * @param name - Skill name to delete + * @param source - Where the skill is located + * @param mode - Optional mode (to locate in skills-{mode}/ directory) + */ + async deleteSkill(name: string, source: "global" | "project", mode?: string): Promise { + // Find the skill + const skill = this.getSkill(name, source, mode) + if (!skill) { + const modeInfo = mode ? ` (mode: ${mode})` : "" + throw new Error(t("skills:errors.not_found", { name, source, modeInfo })) + } + + // 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 }) + + // Refresh skills list + await this.discoverSkills() + } + /** * Get all skills directories to scan, including mode-specific directories. */ diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index 4b6549108bb..858c74fb5d7 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -1,16 +1,29 @@ import * as path from "path" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockStat, mockReadFile, mockReaddir, mockHomedir, mockDirectoryExists, mockFileExists, mockRealpath } = - vi.hoisted(() => ({ - mockStat: vi.fn(), - mockReadFile: vi.fn(), - mockReaddir: vi.fn(), - mockHomedir: vi.fn(), - mockDirectoryExists: vi.fn(), - mockFileExists: vi.fn(), - mockRealpath: vi.fn(), - })) +const { + mockStat, + mockReadFile, + mockReaddir, + mockHomedir, + mockDirectoryExists, + mockFileExists, + mockRealpath, + mockMkdir, + mockWriteFile, + mockRm, +} = vi.hoisted(() => ({ + mockStat: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockHomedir: vi.fn(), + mockDirectoryExists: vi.fn(), + mockFileExists: vi.fn(), + mockRealpath: vi.fn(), + mockMkdir: vi.fn(), + mockWriteFile: vi.fn(), + mockRm: vi.fn(), +})) // Platform-agnostic test paths // Use forward slashes for consistency, then normalize with path.normalize @@ -28,11 +41,17 @@ vi.mock("fs/promises", () => ({ readFile: mockReadFile, readdir: mockReaddir, realpath: mockRealpath, + mkdir: mockMkdir, + writeFile: mockWriteFile, + rm: mockRm, }, stat: mockStat, readFile: mockReadFile, readdir: mockReaddir, realpath: mockRealpath, + mkdir: mockMkdir, + writeFile: mockWriteFile, + rm: mockRm, })) // Mock os module @@ -63,6 +82,22 @@ vi.mock("../../roo-config", () => ({ fileExists: mockFileExists, })) +// Mock i18n +vi.mock("../../../i18n", () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + "skills:errors.name_length": `Skill name must be 1-${params?.maxLength} characters (got ${params?.length})`, + "skills:errors.name_format": + "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", + "skills:errors.description_length": `Skill description must be 1-1024 characters (got ${params?.length})`, + "skills:errors.no_workspace": "Cannot create project skill: no workspace folder is open", + "skills:errors.already_exists": `Skill "${params?.name}" already exists at ${params?.path}`, + "skills:errors.not_found": `Skill "${params?.name}" not found in ${params?.source}${params?.modeInfo}`, + } + return translations[key] || key + }, +})) + import { SkillsManager } from "../SkillsManager" import { ClineProvider } from "../../../core/webview/ClineProvider" @@ -827,4 +862,268 @@ description: A test skill expect(skills).toHaveLength(0) }) }) + + describe("getSkillsMetadata", () => { + it("should return all skills metadata", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const metadata = skillsManager.getSkillsMetadata() + + expect(metadata).toHaveLength(1) + expect(metadata[0].name).toBe("test-skill") + expect(metadata[0].description).toBe("A test skill") + }) + }) + + describe("getSkill", () => { + it("should return a skill by name, source, and mode", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const skill = skillsManager.getSkill("test-skill", "global") + + expect(skill).toBeDefined() + expect(skill?.name).toBe("test-skill") + expect(skill?.source).toBe("global") + }) + + it("should return undefined for non-existent skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + const skill = skillsManager.getSkill("non-existent", "global") + + expect(skill).toBeUndefined() + }) + }) + + describe("createSkill", () => { + it("should create a new global skill", async () => { + // Setup: no existing skills + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + const createdPath = await skillsManager.createSkill("new-skill", "global", "A new skill description") + + expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills", "new-skill", "SKILL.md")) + expect(mockMkdir).toHaveBeenCalledWith(p(GLOBAL_ROO_DIR, "skills", "new-skill"), { recursive: true }) + expect(mockWriteFile).toHaveBeenCalled() + + // Verify the content written + const writeCall = mockWriteFile.mock.calls[0] + expect(writeCall[0]).toBe(p(GLOBAL_ROO_DIR, "skills", "new-skill", "SKILL.md")) + expect(writeCall[1]).toContain("name: new-skill") + expect(writeCall[1]).toContain("description: A new skill description") + }) + + it("should create a mode-specific skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + const createdPath = await skillsManager.createSkill("code-skill", "global", "A code skill", "code") + + expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills-code", "code-skill", "SKILL.md")) + }) + + it("should create a project skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + const createdPath = await skillsManager.createSkill("project-skill", "project", "A project skill") + + expect(createdPath).toBe(p(PROJECT_DIR, ".roo", "skills", "project-skill", "SKILL.md")) + }) + + it("should throw error for invalid skill name", async () => { + await expect(skillsManager.createSkill("Invalid-Name", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for skill name that is too long", async () => { + const longName = "a".repeat(65) + await expect(skillsManager.createSkill(longName, "global", "Description")).rejects.toThrow( + "Skill name must be 1-64 characters", + ) + }) + + it("should throw error for skill name starting with hyphen", async () => { + await expect(skillsManager.createSkill("-invalid", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for skill name ending with hyphen", async () => { + await expect(skillsManager.createSkill("invalid-", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for skill name with consecutive hyphens", async () => { + await expect(skillsManager.createSkill("invalid--name", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for empty description", async () => { + await expect(skillsManager.createSkill("valid-name", "global", " ")).rejects.toThrow( + "Skill description must be 1-1024 characters", + ) + }) + + it("should throw error for description that is too long", async () => { + const longDesc = "d".repeat(1025) + await expect(skillsManager.createSkill("valid-name", "global", longDesc)).rejects.toThrow( + "Skill description must be 1-1024 characters", + ) + }) + + it("should throw error if skill already exists", async () => { + mockFileExists.mockResolvedValue(true) + + await expect(skillsManager.createSkill("existing-skill", "global", "Description")).rejects.toThrow( + "already exists", + ) + }) + }) + + describe("deleteSkill", () => { + it("should delete an existing skill", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + // Setup: skill exists + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockRm.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists + expect(skillsManager.getSkill("test-skill", "global")).toBeDefined() + + // Delete the skill + await skillsManager.deleteSkill("test-skill", "global") + + expect(mockRm).toHaveBeenCalledWith(testSkillDir, { recursive: true, force: true }) + }) + + it("should throw error if skill does not exist", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + await expect(skillsManager.deleteSkill("non-existent", "global")).rejects.toThrow("not found") + }) + }) }) diff --git a/webview-ui/src/components/settings/CreateSkillDialog.tsx b/webview-ui/src/components/settings/CreateSkillDialog.tsx new file mode 100644 index 00000000000..a4daa9989c0 --- /dev/null +++ b/webview-ui/src/components/settings/CreateSkillDialog.tsx @@ -0,0 +1,254 @@ +import React, { useState, useCallback, useMemo } from "react" +import { validateSkillName as validateSkillNameShared, SkillNameValidationError } from "@roo-code/types" + +import { getAllModes } from "@roo/modes" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui" +import { vscode } from "@/utils/vscode" + +interface CreateSkillDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSkillCreated: () => void + hasWorkspace: boolean +} + +/** + * Map skill name validation error codes to translation keys. + */ +const getSkillNameErrorTranslationKey = (error: SkillNameValidationError): string => { + switch (error) { + case SkillNameValidationError.Empty: + return "settings:skills.validation.nameRequired" + case SkillNameValidationError.TooLong: + return "settings:skills.validation.nameTooLong" + case SkillNameValidationError.InvalidFormat: + return "settings:skills.validation.nameInvalid" + } +} + +/** + * Validate skill name using shared validation from @roo-code/types. + * Returns a translation key for the error, or null if valid. + */ +const validateSkillName = (name: string): string | null => { + const result = validateSkillNameShared(name) + if (!result.valid) { + return getSkillNameErrorTranslationKey(result.error!) + } + return null +} + +/** + * Validate description according to agentskills.io spec: + * - Required field + * - 1-1024 characters + */ +const validateDescription = (description: string): string | null => { + if (!description) return "settings:skills.validation.descriptionRequired" + if (description.length > 1024) return "settings:skills.validation.descriptionTooLong" + return null +} + +// Sentinel value for "Any mode" since Radix Select doesn't allow empty string values +const MODE_ANY = "__any__" + +export const CreateSkillDialog: React.FC = ({ + open, + onOpenChange, + onSkillCreated, + hasWorkspace, +}) => { + const { t } = useAppTranslation() + const { customModes } = useExtensionState() + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [source, setSource] = useState<"global" | "project">(hasWorkspace ? "project" : "global") + const [mode, setMode] = useState(MODE_ANY) + const [nameError, setNameError] = useState(null) + const [descriptionError, setDescriptionError] = useState(null) + + // Get available modes for the dropdown (built-in + custom modes) + const availableModes = useMemo(() => { + return getAllModes(customModes).map((m) => ({ slug: m.slug, name: m.name })) + }, [customModes]) + + const resetForm = useCallback(() => { + setName("") + setDescription("") + setSource(hasWorkspace ? "project" : "global") + setMode(MODE_ANY) + setNameError(null) + setDescriptionError(null) + }, [hasWorkspace]) + + const handleClose = useCallback(() => { + resetForm() + onOpenChange(false) + }, [resetForm, onOpenChange]) + + const handleNameChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") + setName(value) + setNameError(null) + }, []) + + const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { + setDescription(e.target.value) + setDescriptionError(null) + }, []) + + const handleCreate = useCallback(() => { + // Validate fields + const nameValidationError = validateSkillName(name) + const descValidationError = validateDescription(description) + + if (nameValidationError) { + setNameError(nameValidationError) + return + } + + if (descValidationError) { + setDescriptionError(descValidationError) + return + } + + // Send message to create skill + // Convert MODE_ANY sentinel value to undefined for the backend + vscode.postMessage({ + type: "createSkill", + skillName: name, + source, + skillDescription: description, + skillMode: mode === MODE_ANY ? undefined : mode, + }) + + // Close dialog and notify parent + handleClose() + onSkillCreated() + }, [name, description, source, mode, handleClose, onSkillCreated]) + + return ( + + + + {t("settings:skills.createDialog.title")} + {t("settings:skills.createDialog.description")} + + +
+ {/* Name Input */} +
+ + + + {t("settings:skills.createDialog.nameHint")} + + {nameError && {t(nameError)}} +
+ + {/* Description Input */} +
+ +