feat: add skills management UI to settings panel (#10513) (#10844)

Co-authored-by: Sannidhya <sann@Sannidhyas-MacBook-Pro.local>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
SannidhyaSah 2026-01-29 11:55:36 +05:30 committed by GitHub
parent c983e26280
commit 010aba24b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 3879 additions and 44 deletions

View file

@ -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)
})
})

View file

@ -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"

View file

@ -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 }
}

View file

@ -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"
| "folderSelected"
| "skills"
text?: string
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
@ -202,6 +204,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
@ -602,6 +605,11 @@ export interface WebviewMessage {
| "createWorktreeInclude"
| "checkoutBranch"
| "browseForWorktreePath"
// Skills messages
| "requestSkills"
| "createSkill"
| "deleteSkill"
| "openSkillFile"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@ -636,6 +644,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

View file

@ -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<string, any>) => {
const translations: Record<string, string> = {
"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',
)
})
})
})

View file

@ -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<SkillMetadata[]> {
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<SkillMetadata[] | undefined> {
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<SkillMetadata[] | undefined> {
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<void> {
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}`)
}
}

View file

@ -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"
@ -2974,6 +2975,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) {

14
src/i18n/locales/ca/skills.json generated Normal file
View file

@ -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}}\""
}
}

14
src/i18n/locales/de/skills.json generated Normal file
View file

@ -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"
}
}

View file

@ -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"
}
}

14
src/i18n/locales/es/skills.json generated Normal file
View file

@ -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}}\""
}
}

14
src/i18n/locales/fr/skills.json generated Normal file
View file

@ -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"
}
}

14
src/i18n/locales/hi/skills.json generated Normal file
View file

@ -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}}\" नहीं मिला"
}
}

14
src/i18n/locales/id/skills.json generated Normal file
View file

@ -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"
}
}

14
src/i18n/locales/it/skills.json generated Normal file
View file

@ -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"
}
}

14
src/i18n/locales/ja/skills.json generated Normal file
View file

@ -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}}」が見つかりません"
}
}

14
src/i18n/locales/ko/skills.json generated Normal file
View file

@ -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}}\"을(를) 찾을 수 없습니다"
}
}

14
src/i18n/locales/nl/skills.json generated Normal file
View file

@ -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"
}
}

14
src/i18n/locales/pl/skills.json generated Normal file
View file

@ -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}}\""
}
}

14
src/i18n/locales/pt-BR/skills.json generated Normal file
View file

@ -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"
}
}

14
src/i18n/locales/ru/skills.json generated Normal file
View file

@ -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}}\" не найден"
}
}

14
src/i18n/locales/tr/skills.json generated Normal file
View file

@ -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ı"
}
}

14
src/i18n/locales/vi/skills.json generated Normal file
View file

@ -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}}\""
}
}

14
src/i18n/locales/zh-CN/skills.json generated Normal file
View file

@ -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}}\""
}
}

14
src/i18n/locales/zh-TW/skills.json generated Normal file
View file

@ -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}}」"
}
}

View file

@ -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<string> {
// 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<void> {
// 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.
*/

View file

@ -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<string, any>) => {
const translations: Record<string, string> = {
"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")
})
})
})

View file

@ -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<CreateSkillDialogProps> = ({
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<string>(MODE_ANY)
const [nameError, setNameError] = useState<string | null>(null)
const [descriptionError, setDescriptionError] = useState<string | null>(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<HTMLInputElement>) => {
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")
setName(value)
setNameError(null)
}, [])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("settings:skills.createDialog.title")}</DialogTitle>
<DialogDescription>{t("settings:skills.createDialog.description")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
{/* Name Input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="skill-name" className="text-sm font-medium text-vscode-foreground">
{t("settings:skills.createDialog.nameLabel")} *
</label>
<input
id="skill-name"
type="text"
value={name}
onChange={handleNameChange}
placeholder={t("settings:skills.createDialog.namePlaceholder")}
maxLength={64}
className="w-full bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-3 py-2 text-sm focus:outline-none focus:border-vscode-focusBorder"
/>
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:skills.createDialog.nameHint")}
</span>
{nameError && <span className="text-xs text-vscode-errorForeground">{t(nameError)}</span>}
</div>
{/* Description Input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="skill-description" className="text-sm font-medium text-vscode-foreground">
{t("settings:skills.createDialog.descriptionLabel")} *
</label>
<textarea
id="skill-description"
value={description}
onChange={handleDescriptionChange}
placeholder={t("settings:skills.createDialog.descriptionPlaceholder")}
maxLength={1024}
rows={3}
className="w-full bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-3 py-2 text-sm focus:outline-none focus:border-vscode-focusBorder resize-none"
/>
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:skills.createDialog.descriptionHint")}
</span>
{descriptionError && (
<span className="text-xs text-vscode-errorForeground">{t(descriptionError)}</span>
)}
</div>
{/* Source Selection */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-vscode-foreground">
{t("settings:skills.createDialog.sourceLabel")}
</label>
<Select value={source} onValueChange={(value) => setSource(value as "global" | "project")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">{t("settings:skills.source.global")}</SelectItem>
{hasWorkspace && (
<SelectItem value="project">{t("settings:skills.source.project")}</SelectItem>
)}
</SelectContent>
</Select>
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:skills.createDialog.sourceHint")}
</span>
</div>
{/* Mode Selection (Optional) */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-vscode-foreground">
{t("settings:skills.createDialog.modeLabel")}
</label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:skills.createDialog.modePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={MODE_ANY}>{t("settings:skills.createDialog.modeAny")}</SelectItem>
{availableModes.map((m) => (
<SelectItem key={m.slug} value={m.slug}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:skills.createDialog.modeHint")}
</span>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={handleClose}>
{t("settings:skills.createDialog.cancel")}
</Button>
<Button variant="primary" onClick={handleCreate} disabled={!name || !description}>
{t("settings:skills.createDialog.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -29,6 +29,7 @@ import {
Users2,
ArrowLeft,
GitCommitVertical,
Zap,
} from "lucide-react"
import {
@ -77,6 +78,7 @@ import { About } from "./About"
import { Section } from "./Section"
import PromptsSettings from "./PromptsSettings"
import { SlashCommandsSettings } from "./SlashCommandsSettings"
import { SkillsSettings } from "./SkillsSettings"
import { UISettings } from "./UISettings"
import ModesView from "../modes/ModesView"
import McpView from "../mcp/McpView"
@ -99,6 +101,7 @@ export const sectionNames = [
"providers",
"autoApprove",
"slashCommands",
"skills",
"browser",
"checkpoints",
"notifications",
@ -516,6 +519,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{ id: "mcp", icon: Server },
{ id: "autoApprove", icon: CheckCheck },
{ id: "slashCommands", icon: SquareSlash },
{ id: "skills", icon: Zap },
{ id: "browser", icon: SquareMousePointer },
{ id: "checkpoints", icon: GitCommitVertical },
{ id: "notifications", icon: Bell },
@ -806,6 +810,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{/* Slash Commands Section */}
{renderTab === "slashCommands" && <SlashCommandsSettings />}
{/* Skills Section */}
{renderTab === "skills" && <SkillsSettings />}
{/* Browser Section */}
{renderTab === "browser" && (
<BrowserSettings

View file

@ -0,0 +1,61 @@
import React from "react"
import { Edit, Trash2 } from "lucide-react"
import type { SkillMetadata } from "@roo-code/types"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Button, StandardTooltip } from "@/components/ui"
interface SkillItemProps {
skill: SkillMetadata
onEdit: () => void
onDelete: () => void
}
export const SkillItem: React.FC<SkillItemProps> = ({ skill, onEdit, onDelete }) => {
const { t } = useAppTranslation()
return (
<div className="px-4 py-2 text-sm flex items-center group hover:bg-vscode-list-hoverBackground">
{/* Skill name and description */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={onEdit}>
<div className="flex items-center gap-2">
<span className="truncate text-vscode-foreground">{skill.name}</span>
{skill.mode && (
<span className="px-1.5 py-0.5 text-xs rounded bg-vscode-badge-background text-vscode-badge-foreground shrink-0">
{skill.mode}
</span>
)}
</div>
{skill.description && (
<div className="text-xs text-vscode-descriptionForeground truncate mt-0.5">{skill.description}</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-2">
<StandardTooltip content={t("settings:skills.editSkill")}>
<Button
variant="ghost"
size="icon"
tabIndex={-1}
onClick={onEdit}
className="size-6 flex items-center justify-center opacity-60 hover:opacity-100">
<Edit className="w-4 h-4" />
</Button>
</StandardTooltip>
<StandardTooltip content={t("settings:skills.deleteSkill")}>
<Button
variant="ghost"
size="icon"
tabIndex={-1}
onClick={onDelete}
className="size-6 flex items-center justify-center opacity-60 hover:opacity-100 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</Button>
</StandardTooltip>
</div>
</div>
)
}

View file

@ -0,0 +1,228 @@
import React, { useState, useEffect, useMemo, useCallback } from "react"
import { Plus, Globe, Folder } from "lucide-react"
import { Trans } from "react-i18next"
import type { SkillMetadata } from "@roo-code/types"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
} from "@/components/ui"
import { vscode } from "@/utils/vscode"
import { buildDocLink } from "@/utils/docLinks"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
import { SearchableSetting } from "./SearchableSetting"
import { SkillItem } from "./SkillItem"
import { CreateSkillDialog } from "./CreateSkillDialog"
import type { SectionName } from "./SettingsView"
export const SkillsSettings: React.FC = () => {
const { t } = useAppTranslation()
const { cwd, skills: rawSkills } = useExtensionState()
const skills = useMemo(() => rawSkills ?? [], [rawSkills])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [skillToDelete, setSkillToDelete] = useState<SkillMetadata | null>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
// Check if we're in a workspace/project
const hasWorkspace = Boolean(cwd)
const handleRefresh = useCallback(() => {
vscode.postMessage({ type: "requestSkills" })
}, [])
// Request skills when component mounts
useEffect(() => {
handleRefresh()
}, [handleRefresh])
const handleDeleteClick = useCallback((skill: SkillMetadata) => {
setSkillToDelete(skill)
setDeleteDialogOpen(true)
}, [])
const handleDeleteConfirm = useCallback(() => {
if (skillToDelete) {
vscode.postMessage({
type: "deleteSkill",
skillName: skillToDelete.name,
source: skillToDelete.source,
skillMode: skillToDelete.mode,
})
setDeleteDialogOpen(false)
setSkillToDelete(null)
}
}, [skillToDelete])
const handleDeleteCancel = useCallback(() => {
setDeleteDialogOpen(false)
setSkillToDelete(null)
}, [])
const handleEditClick = useCallback((skill: SkillMetadata) => {
vscode.postMessage({
type: "openSkillFile",
skillName: skill.name,
source: skill.source,
skillMode: skill.mode,
})
}, [])
// No-op callback - the backend sends updated skills list via ExtensionStateContext
const handleSkillCreated = useCallback(() => {}, [])
// Group skills by source
const projectSkills = useMemo(() => skills.filter((skill) => skill.source === "project"), [skills])
const globalSkills = useMemo(() => skills.filter((skill) => skill.source === "global"), [skills])
return (
<div>
<SectionHeader>{t("settings:sections.skills")}</SectionHeader>
<Section>
{/* Description section */}
<SearchableSetting
settingId="skills-description"
section={"skills" as SectionName}
label={t("settings:sections.skills")}
className="mb-4">
<p className="text-sm text-vscode-descriptionForeground mb-2">
<Trans
i18nKey="settings:skills.description"
components={{
DocsLink: (
<a
href={buildDocLink("features/skills", "skills_settings")}
target="_blank"
rel="noopener noreferrer"
className="text-vscode-textLink-foreground hover:underline">
Docs
</a>
),
}}
/>
</p>
</SearchableSetting>
{/* Project Skills Section - Only show if in a workspace */}
{hasWorkspace && (
<SearchableSetting
settingId="skills-project"
section={"skills" as SectionName}
label={t("settings:skills.projectSkills")}
className="mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Folder className="w-3 h-3" />
<h4 className="text-sm font-medium m-0">{t("settings:skills.projectSkills")}</h4>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="h-6 px-2 text-xs opacity-60 hover:opacity-100">
<Plus className="w-3 h-3 mr-1" />
{t("settings:skills.addSkill")}
</Button>
</div>
<div className="border border-vscode-panel-border rounded-md">
{projectSkills.length > 0 ? (
projectSkills.map((skill) => (
<SkillItem
key={`project-${skill.name}-${skill.mode || "any"}`}
skill={skill}
onEdit={() => handleEditClick(skill)}
onDelete={() => handleDeleteClick(skill)}
/>
))
) : (
<div className="px-4 py-6 text-sm text-vscode-descriptionForeground text-center">
{t("settings:skills.noProjectSkills")}
</div>
)}
</div>
</SearchableSetting>
)}
{/* Global Skills Section */}
<SearchableSetting
settingId="skills-global"
section={"skills" as SectionName}
label={t("settings:skills.globalSkills")}
className="mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Globe className="w-3 h-3" />
<h4 className="text-sm font-medium m-0">{t("settings:skills.globalSkills")}</h4>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="h-6 px-2 text-xs opacity-60 hover:opacity-100">
<Plus className="w-3 h-3 mr-1" />
{t("settings:skills.addSkill")}
</Button>
</div>
<div className="border border-vscode-panel-border rounded-md">
{globalSkills.length > 0 ? (
globalSkills.map((skill) => (
<SkillItem
key={`global-${skill.name}-${skill.mode || "any"}`}
skill={skill}
onEdit={() => handleEditClick(skill)}
onDelete={() => handleDeleteClick(skill)}
/>
))
) : (
<div className="px-4 py-6 text-sm text-vscode-descriptionForeground text-center">
{t("settings:skills.noGlobalSkills")}
</div>
)}
</div>
</SearchableSetting>
</Section>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("settings:skills.deleteDialog.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("settings:skills.deleteDialog.description", { name: skillToDelete?.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleDeleteCancel}>
{t("settings:skills.deleteDialog.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}>
{t("settings:skills.deleteDialog.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Create Skill Dialog */}
<CreateSkillDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSkillCreated={handleSkillCreated}
hasWorkspace={hasWorkspace}
/>
</div>
)
}

View file

@ -0,0 +1,404 @@
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
import { vscode } from "@/utils/vscode"
import { CreateSkillDialog } from "../CreateSkillDialog"
// Mock vscode
vi.mock("@/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Create a variable to hold the mock state
let mockExtensionState: any = {}
// Mock the useExtensionState hook
vi.mock("@/context/ExtensionStateContext", () => ({
ExtensionStateContextProvider: ({ children }: any) => children,
useExtensionState: () => mockExtensionState,
}))
// Mock UI components
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick, disabled, variant }: any) => (
<button onClick={onClick} disabled={disabled} data-variant={variant} data-testid="button">
{children}
</button>
),
Dialog: ({ children, open }: any) => (
<div data-testid="dialog" data-open={open}>
{open && children}
</div>
),
DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
DialogDescription: ({ children }: any) => <div data-testid="dialog-description">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
Select: ({ children, value, onValueChange }: any) => (
<div data-testid="select" data-value={value}>
{children}
<input
type="hidden"
data-testid="select-input"
value={value}
onChange={(e) => onValueChange(e.target.value)}
/>
</div>
),
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
SelectValue: ({ placeholder }: any) => <span data-testid="select-value">{placeholder}</span>,
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid={`select-item-${value}`} data-value={value}>
{children}
</div>
),
}))
describe("CreateSkillDialog", () => {
const mockOnOpenChange = vi.fn()
const mockOnSkillCreated = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockExtensionState = {
customModes: [{ slug: "custom-mode", name: "Custom Mode" }],
}
})
it("renders dialog when open is true", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
expect(screen.getByTestId("dialog")).toHaveAttribute("data-open", "true")
expect(screen.getByTestId("dialog-title")).toBeInTheDocument()
})
it("does not render dialog content when open is false", () => {
render(
<CreateSkillDialog
open={false}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
expect(screen.getByTestId("dialog")).toHaveAttribute("data-open", "false")
expect(screen.queryByTestId("dialog-title")).not.toBeInTheDocument()
})
it("renders name input field", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText("settings:skills.createDialog.namePlaceholder")
expect(nameInput).toBeInTheDocument()
})
it("renders description textarea", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const descInput = screen.getByPlaceholderText("settings:skills.createDialog.descriptionPlaceholder")
expect(descInput).toBeInTheDocument()
})
it("transforms name input to lowercase with only allowed characters", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText(
"settings:skills.createDialog.namePlaceholder",
) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: "Test-Skill_123!" } })
// Should be transformed to lowercase and remove invalid characters
expect(nameInput.value).toBe("test-skill123")
})
it("disables create button when name is empty", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const buttons = screen.getAllByTestId("button")
const createButton = buttons.find((btn) => btn.getAttribute("data-variant") === "primary")
expect(createButton).toBeDisabled()
})
it("disables create button when description is empty", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText("settings:skills.createDialog.namePlaceholder")
fireEvent.change(nameInput, { target: { value: "valid-name" } })
const buttons = screen.getAllByTestId("button")
const createButton = buttons.find((btn) => btn.getAttribute("data-variant") === "primary")
expect(createButton).toBeDisabled()
})
it("enables create button when both name and description are provided", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText("settings:skills.createDialog.namePlaceholder")
const descInput = screen.getByPlaceholderText("settings:skills.createDialog.descriptionPlaceholder")
fireEvent.change(nameInput, { target: { value: "valid-name" } })
fireEvent.change(descInput, { target: { value: "Valid description" } })
const buttons = screen.getAllByTestId("button")
const createButton = buttons.find((btn) => btn.getAttribute("data-variant") === "primary")
expect(createButton).not.toBeDisabled()
})
it("calls vscode.postMessage with correct data when creating skill", async () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText("settings:skills.createDialog.namePlaceholder")
const descInput = screen.getByPlaceholderText("settings:skills.createDialog.descriptionPlaceholder")
fireEvent.change(nameInput, { target: { value: "my-skill" } })
fireEvent.change(descInput, { target: { value: "My skill description" } })
const buttons = screen.getAllByTestId("button")
const createButton = buttons.find((btn) => btn.getAttribute("data-variant") === "primary")
fireEvent.click(createButton!)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "createSkill",
skillName: "my-skill",
source: "project",
skillDescription: "My skill description",
skillMode: undefined,
})
})
})
it("calls onSkillCreated after creating skill", async () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText("settings:skills.createDialog.namePlaceholder")
const descInput = screen.getByPlaceholderText("settings:skills.createDialog.descriptionPlaceholder")
fireEvent.change(nameInput, { target: { value: "my-skill" } })
fireEvent.change(descInput, { target: { value: "My skill description" } })
const buttons = screen.getAllByTestId("button")
const createButton = buttons.find((btn) => btn.getAttribute("data-variant") === "primary")
fireEvent.click(createButton!)
await waitFor(() => {
expect(mockOnSkillCreated).toHaveBeenCalled()
})
})
it("closes dialog after creating skill", async () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText("settings:skills.createDialog.namePlaceholder")
const descInput = screen.getByPlaceholderText("settings:skills.createDialog.descriptionPlaceholder")
fireEvent.change(nameInput, { target: { value: "my-skill" } })
fireEvent.change(descInput, { target: { value: "My skill description" } })
const buttons = screen.getAllByTestId("button")
const createButton = buttons.find((btn) => btn.getAttribute("data-variant") === "primary")
fireEvent.click(createButton!)
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
it("closes dialog when cancel button is clicked", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const buttons = screen.getAllByTestId("button")
const cancelButton = buttons.find((btn) => btn.getAttribute("data-variant") === "secondary")
fireEvent.click(cancelButton!)
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("defaults to project source when hasWorkspace is true", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const select = screen.getAllByTestId("select")[0]
expect(select).toHaveAttribute("data-value", "project")
})
it("defaults to global source when hasWorkspace is false", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={false}
/>,
)
const select = screen.getAllByTestId("select")[0]
expect(select).toHaveAttribute("data-value", "global")
})
it("renders source selection dropdown", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
expect(screen.getByTestId("select-item-global")).toBeInTheDocument()
expect(screen.getByTestId("select-item-project")).toBeInTheDocument()
})
it("renders mode selection dropdown", () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
// Should have "Any mode" option (uses __any__ sentinel value)
expect(screen.getByTestId("select-item-__any__")).toBeInTheDocument()
// Should have built-in modes
expect(screen.getByTestId("select-item-code")).toBeInTheDocument()
expect(screen.getByTestId("select-item-architect")).toBeInTheDocument()
// Should have custom modes from state
expect(screen.getByTestId("select-item-custom-mode")).toBeInTheDocument()
})
it("clears form after successful skill creation", async () => {
render(
<CreateSkillDialog
open={true}
onOpenChange={mockOnOpenChange}
onSkillCreated={mockOnSkillCreated}
hasWorkspace={true}
/>,
)
const nameInput = screen.getByPlaceholderText(
"settings:skills.createDialog.namePlaceholder",
) as HTMLInputElement
const descInput = screen.getByPlaceholderText(
"settings:skills.createDialog.descriptionPlaceholder",
) as HTMLTextAreaElement
fireEvent.change(nameInput, { target: { value: "test-skill" } })
fireEvent.change(descInput, { target: { value: "Test description" } })
const buttons = screen.getAllByTestId("button")
const createButton = buttons.find((btn) => btn.getAttribute("data-variant") === "primary")
fireEvent.click(createButton!)
// After clicking create, the dialog should close via onOpenChange
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
})

View file

@ -54,6 +54,31 @@ vi.mock("@src/components/ui", () => ({
TooltipProvider: ({ children }: any) => <>{children}</>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
// Add Dialog components (used by CreateSkillDialog)
Dialog: ({ children, open }: any) => (open ? <div data-testid="dialog">{children}</div> : null),
DialogContent: ({ children, className }: any) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
DialogDescription: ({ children }: any) => <div data-testid="dialog-description">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
// Add Select components (used by CreateSkillDialog)
Select: ({ children, value, onValueChange: _onValueChange }: any) => (
<div data-testid="select" data-value={value}>
{children}
</div>
),
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid={`select-item-${value}`} data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
}))
// Mock ModesView and McpView since they're rendered during indexing

View file

@ -212,6 +212,17 @@ vi.mock("@/components/ui", () => ({
CollapsibleContent: ({ children, className }: any) => (
<div className={`collapsible-content-mock ${className || ""}`}>{children}</div>
),
// Add Dialog components (used by CreateSkillDialog)
Dialog: ({ children, open }: any) => (open ? <div data-testid="dialog">{children}</div> : null),
DialogContent: ({ children, className }: any) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
DialogDescription: ({ children }: any) => <div data-testid="dialog-description">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}))
// Mock window.postMessage to trigger state hydration

View file

@ -55,6 +55,31 @@ vi.mock("@src/components/ui", () => ({
Popover: ({ children }: any) => <>{children}</>,
PopoverTrigger: ({ children }: any) => <>{children}</>,
PopoverContent: ({ children }: any) => <div>{children}</div>,
// Add Dialog components (used by CreateSkillDialog)
Dialog: ({ children, open }: any) => (open ? <div data-testid="dialog">{children}</div> : null),
DialogContent: ({ children, className }: any) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
DialogDescription: ({ children }: any) => <div data-testid="dialog-description">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
// Add Select components (used by CreateSkillDialog)
Select: ({ children, value, onValueChange: _onValueChange }: any) => (
<div data-testid="select" data-value={value}>
{children}
</div>
),
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid={`select-item-${value}`} data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
}))
// Mock ModesView and McpView since they're rendered during indexing

View file

@ -0,0 +1,141 @@
import { render, screen, fireEvent } from "@/utils/test-utils"
import type { SkillMetadata } from "@roo-code/types"
import { SkillItem } from "../SkillItem"
// Mock vscode
vi.mock("@/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock UI components
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick, className, tabIndex }: any) => (
<button onClick={onClick} className={className} tabIndex={tabIndex} data-testid="button">
{children}
</button>
),
StandardTooltip: ({ children, content }: any) => (
<div title={content} data-testid="tooltip">
{children}
</div>
),
}))
const mockSkill: SkillMetadata = {
name: "test-skill",
description: "A test skill description",
path: "/path/to/skill/SKILL.md",
source: "project",
}
const mockSkillWithMode: SkillMetadata = {
name: "mode-specific-skill",
description: "A mode-specific skill",
path: "/path/to/skill/SKILL.md",
source: "global",
mode: "architect",
}
describe("SkillItem", () => {
const mockOnEdit = vi.fn()
const mockOnDelete = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it("renders skill name", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
expect(screen.getByText("test-skill")).toBeInTheDocument()
})
it("renders skill description", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
expect(screen.getByText("A test skill description")).toBeInTheDocument()
})
it("renders mode badge when skill has mode", () => {
render(<SkillItem skill={mockSkillWithMode} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
expect(screen.getByText("architect")).toBeInTheDocument()
})
it("does not render mode badge when skill has no mode", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
// Should not have any mode badge
const container = screen.getByText("test-skill").parentElement
expect(container?.querySelector(".bg-vscode-badge-background")).toBeNull()
})
it("calls onEdit when edit button is clicked", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const buttons = screen.getAllByTestId("button")
// First button is edit
fireEvent.click(buttons[0])
expect(mockOnEdit).toHaveBeenCalledTimes(1)
})
it("calls onDelete when delete button is clicked", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const buttons = screen.getAllByTestId("button")
// Second button is delete
fireEvent.click(buttons[1])
expect(mockOnDelete).toHaveBeenCalledTimes(1)
})
it("calls onEdit when clicking on skill name area", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const nameElement = screen.getByText("test-skill")
fireEvent.click(nameElement)
expect(mockOnEdit).toHaveBeenCalledTimes(1)
})
it("renders without description when not provided", () => {
const skillWithoutDescription: SkillMetadata = {
name: "no-desc-skill",
description: "",
path: "/path/to/skill/SKILL.md",
source: "project",
}
render(<SkillItem skill={skillWithoutDescription} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
expect(screen.getByText("no-desc-skill")).toBeInTheDocument()
// Description div should not be rendered when empty
expect(screen.queryByText("A test skill description")).not.toBeInTheDocument()
})
it("renders with proper styling classes", () => {
const { container } = render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const itemDiv = container.firstChild
expect(itemDiv).toHaveClass("hover:bg-vscode-list-hoverBackground")
})
it("renders both edit and delete buttons", () => {
render(<SkillItem skill={mockSkill} onEdit={mockOnEdit} onDelete={mockOnDelete} />)
const buttons = screen.getAllByTestId("button")
expect(buttons).toHaveLength(2)
})
})

View file

@ -0,0 +1,436 @@
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { SkillMetadata } from "@roo-code/types"
import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { SkillsSettings } from "../SkillsSettings"
// Mock vscode
vi.mock("@/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, params?: any) => {
if (params?.name) {
return `${key} ${params.name}`
}
return key
},
}),
}))
// Mock the doc links utility
vi.mock("@/utils/docLinks", () => ({
buildDocLink: (path: string, anchor?: string) => `https://docs.example.com/${path}${anchor ? `#${anchor}` : ""}`,
}))
// Mock UI components
vi.mock("@/components/ui", () => ({
AlertDialog: ({ children, open }: any) => (
<div data-testid="alert-dialog" data-open={open}>
{open && children}
</div>
),
AlertDialogContent: ({ children }: any) => <div data-testid="alert-dialog-content">{children}</div>,
AlertDialogHeader: ({ children }: any) => <div data-testid="alert-dialog-header">{children}</div>,
AlertDialogTitle: ({ children }: any) => <div data-testid="alert-dialog-title">{children}</div>,
AlertDialogDescription: ({ children }: any) => <div data-testid="alert-dialog-description">{children}</div>,
AlertDialogFooter: ({ children }: any) => <div data-testid="alert-dialog-footer">{children}</div>,
AlertDialogAction: ({ children, onClick }: any) => (
<button data-testid="alert-dialog-action" onClick={onClick}>
{children}
</button>
),
AlertDialogCancel: ({ children, onClick }: any) => (
<button data-testid="alert-dialog-cancel" onClick={onClick}>
{children}
</button>
),
Button: ({ children, onClick, disabled, className, variant, size }: any) => (
<button
onClick={onClick}
disabled={disabled}
className={className}
data-variant={variant}
data-size={size}
data-testid="button">
{children}
</button>
),
}))
// Mock SkillItem component
vi.mock("../SkillItem", () => ({
SkillItem: ({ skill, onEdit, onDelete }: any) => (
<div data-testid={`skill-item-${skill.name}`}>
<span>{skill.name}</span>
{skill.description && <span>{skill.description}</span>}
{skill.mode && <span data-testid={`skill-mode-${skill.name}`}>{skill.mode}</span>}
<button onClick={onEdit} data-testid={`edit-${skill.name}`}>
Edit
</button>
<button onClick={onDelete} data-testid={`delete-${skill.name}`}>
Delete
</button>
</div>
),
}))
// Mock CreateSkillDialog component
vi.mock("../CreateSkillDialog", () => ({
CreateSkillDialog: ({ open, onOpenChange, onSkillCreated }: any) => (
<div data-testid="create-skill-dialog" data-open={open}>
{open && (
<>
<button onClick={() => onOpenChange(false)} data-testid="close-dialog">
Close
</button>
<button onClick={onSkillCreated} data-testid="create-skill-button">
Create
</button>
</>
)}
</div>
),
}))
// Mock SectionHeader and Section components
vi.mock("../SectionHeader", () => ({
SectionHeader: ({ children }: any) => <div data-testid="section-header">{children}</div>,
}))
vi.mock("../Section", () => ({
Section: ({ children }: any) => <div data-testid="section">{children}</div>,
}))
// Mock SearchableSetting
vi.mock("../SearchableSetting", () => ({
SearchableSetting: ({ children }: any) => <div data-testid="searchable-setting">{children}</div>,
}))
const mockSkills: SkillMetadata[] = [
{
name: "project-skill",
description: "A project skill",
path: "/workspace/.roo/skills/project-skill/SKILL.md",
source: "project",
},
{
name: "project-mode-skill",
description: "A project mode-specific skill",
path: "/workspace/.roo/skills-architect/project-mode-skill/SKILL.md",
source: "project",
mode: "architect",
},
{
name: "global-skill",
description: "A global skill",
path: "/home/.roo/skills/global-skill/SKILL.md",
source: "global",
},
]
// Create a variable to hold the mock state
let mockExtensionState: any = {}
// Mock the useExtensionState hook
vi.mock("@/context/ExtensionStateContext", () => ({
ExtensionStateContextProvider: ({ children }: any) => children,
useExtensionState: () => mockExtensionState,
}))
const renderSkillsSettings = (skills: SkillMetadata[] = mockSkills, cwd?: string) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
// Update the mock state before rendering
mockExtensionState = {
skills,
cwd: cwd !== undefined ? cwd : "/workspace",
customModes: [],
}
return render(
<QueryClientProvider client={queryClient}>
<ExtensionStateContextProvider>
<SkillsSettings />
</ExtensionStateContextProvider>
</QueryClientProvider>,
)
}
describe("SkillsSettings", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders section header", () => {
renderSkillsSettings()
expect(screen.getByTestId("section-header")).toBeInTheDocument()
expect(screen.getByText("settings:sections.skills")).toBeInTheDocument()
})
it("requests skills on mount", () => {
renderSkillsSettings()
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "requestSkills" })
})
it("displays project skills section when in a workspace", () => {
renderSkillsSettings()
expect(screen.getByText("settings:skills.projectSkills")).toBeInTheDocument()
expect(screen.getByTestId("skill-item-project-skill")).toBeInTheDocument()
})
it("displays global skills section", () => {
renderSkillsSettings()
expect(screen.getByText("settings:skills.globalSkills")).toBeInTheDocument()
expect(screen.getByTestId("skill-item-global-skill")).toBeInTheDocument()
})
it("does not display project skills section when not in a workspace", () => {
const globalOnlySkills = mockSkills.filter((s) => s.source === "global")
renderSkillsSettings(globalOnlySkills, "")
expect(screen.queryByText("settings:skills.projectSkills")).not.toBeInTheDocument()
})
it("shows empty state for project skills when none exist", () => {
const globalOnlySkills = mockSkills.filter((s) => s.source === "global")
renderSkillsSettings(globalOnlySkills)
expect(screen.getByText("settings:skills.noProjectSkills")).toBeInTheDocument()
})
it("shows empty state for global skills when none exist", () => {
const projectOnlySkills = mockSkills.filter((s) => s.source === "project")
renderSkillsSettings(projectOnlySkills)
expect(screen.getByText("settings:skills.noGlobalSkills")).toBeInTheDocument()
})
it("groups skills by source correctly", () => {
renderSkillsSettings()
// Project skills
expect(screen.getByTestId("skill-item-project-skill")).toBeInTheDocument()
expect(screen.getByTestId("skill-item-project-mode-skill")).toBeInTheDocument()
// Global skills
expect(screen.getByTestId("skill-item-global-skill")).toBeInTheDocument()
})
it("displays mode badge for mode-specific skills", () => {
renderSkillsSettings()
expect(screen.getByTestId("skill-mode-project-mode-skill")).toBeInTheDocument()
expect(screen.getByText("architect")).toBeInTheDocument()
})
it("opens create skill dialog when add button is clicked", () => {
renderSkillsSettings()
const addButtons = screen.getAllByTestId("button")
fireEvent.click(addButtons[0])
expect(screen.getByTestId("create-skill-dialog")).toHaveAttribute("data-open", "true")
})
it("opens delete confirmation dialog when delete button is clicked", () => {
renderSkillsSettings()
const deleteButton = screen.getByTestId("delete-project-skill")
fireEvent.click(deleteButton)
expect(screen.getByTestId("alert-dialog")).toHaveAttribute("data-open", "true")
expect(screen.getByText("settings:skills.deleteDialog.title")).toBeInTheDocument()
})
it("deletes skill when confirmation is clicked", async () => {
renderSkillsSettings()
const deleteButton = screen.getByTestId("delete-project-skill")
fireEvent.click(deleteButton)
const confirmButton = screen.getByTestId("alert-dialog-action")
fireEvent.click(confirmButton)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteSkill",
skillName: "project-skill",
source: "project",
skillMode: undefined,
})
})
})
it("cancels deletion when cancel is clicked", () => {
renderSkillsSettings()
const deleteButton = screen.getByTestId("delete-project-skill")
fireEvent.click(deleteButton)
const cancelButton = screen.getByTestId("alert-dialog-cancel")
fireEvent.click(cancelButton)
expect(screen.getByTestId("alert-dialog")).toHaveAttribute("data-open", "false")
})
it("opens skill file when edit button is clicked", () => {
renderSkillsSettings()
const editButton = screen.getByTestId("edit-project-skill")
fireEvent.click(editButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "openSkillFile",
skillName: "project-skill",
source: "project",
skillMode: undefined,
})
})
it("sends mode when editing mode-specific skill", () => {
renderSkillsSettings()
const editButton = screen.getByTestId("edit-project-mode-skill")
fireEvent.click(editButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "openSkillFile",
skillName: "project-mode-skill",
source: "project",
skillMode: "architect",
})
})
it("sends mode when deleting mode-specific skill", async () => {
renderSkillsSettings()
const deleteButton = screen.getByTestId("delete-project-mode-skill")
fireEvent.click(deleteButton)
const confirmButton = screen.getByTestId("alert-dialog-action")
fireEvent.click(confirmButton)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteSkill",
skillName: "project-mode-skill",
source: "project",
skillMode: "architect",
})
})
})
it("does not manually refresh after deletion (backend sends updated skills via context)", async () => {
renderSkillsSettings()
// Clear mock calls after initial mount
;(vscode.postMessage as any).mockClear()
const deleteButton = screen.getByTestId("delete-project-skill")
fireEvent.click(deleteButton)
const confirmButton = screen.getByTestId("alert-dialog-action")
fireEvent.click(confirmButton)
// Verify deleteSkill message was sent
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteSkill",
skillName: "project-skill",
source: "project",
skillMode: undefined,
})
})
// Verify that requestSkills was NOT called after deletion
// (the backend sends updated skills via ExtensionStateContext automatically)
const calls = (vscode.postMessage as any).mock.calls
const refreshCalls = calls.filter((call: any[]) => call[0].type === "requestSkills")
expect(refreshCalls.length).toBe(0)
})
it("does not manually refresh after creating new skill (backend sends updated skills via context)", async () => {
renderSkillsSettings()
// Clear mock calls after initial mount
;(vscode.postMessage as any).mockClear()
// Open create dialog
const addButtons = screen.getAllByTestId("button")
fireEvent.click(addButtons[0])
// Simulate skill creation
const createButton = screen.getByTestId("create-skill-button")
fireEvent.click(createButton)
// Verify that requestSkills was NOT called after creation
// (the backend sends updated skills via ExtensionStateContext automatically)
const calls = (vscode.postMessage as any).mock.calls
const refreshCalls = calls.filter((call: any[]) => call[0].type === "requestSkills")
expect(refreshCalls.length).toBe(0)
})
it("renders empty state when no skills exist", () => {
renderSkillsSettings([])
expect(screen.getByText("settings:skills.noProjectSkills")).toBeInTheDocument()
expect(screen.getByText("settings:skills.noGlobalSkills")).toBeInTheDocument()
})
it("handles multiple skills of the same source", () => {
const multipleSkills: SkillMetadata[] = [
{
name: "skill-1",
description: "First skill",
path: "/path/1",
source: "global",
},
{
name: "skill-2",
description: "Second skill",
path: "/path/2",
source: "global",
},
{
name: "skill-3",
description: "Third skill",
path: "/path/3",
source: "global",
},
]
renderSkillsSettings(multipleSkills)
expect(screen.getByTestId("skill-item-skill-1")).toBeInTheDocument()
expect(screen.getByTestId("skill-item-skill-2")).toBeInTheDocument()
expect(screen.getByTestId("skill-item-skill-3")).toBeInTheDocument()
})
it("renders add skill button in each section", () => {
renderSkillsSettings()
// Should have two "Add Skill" buttons - one for project, one for global
const buttons = screen.getAllByTestId("button")
const addButtons = buttons.filter((btn) => btn.textContent?.includes("settings:skills.addSkill"))
expect(addButtons.length).toBe(2)
})
})

View file

@ -15,6 +15,7 @@ import {
type MarketplaceInstalledMetadata,
type Command,
type McpServer,
type SkillMetadata,
RouterModels,
ORGANIZATION_ALLOW_ALL,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
@ -42,6 +43,7 @@ export interface ExtensionStateContextType extends ExtensionState {
filePaths: string[]
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
commands: Command[]
skills: SkillMetadata[]
organizationAllowList: OrganizationAllowList
organizationSettingsVersion: number
cloudIsAuthenticated: boolean
@ -278,6 +280,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [filePaths, setFilePaths] = useState<string[]>([])
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
const [commands, setCommands] = useState<Command[]>([])
const [skills, setSkills] = useState<SkillMetadata[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
const [extensionRouterModels, setExtensionRouterModels] = useState<RouterModels | undefined>(undefined)
@ -376,6 +379,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setCommands(message.commands ?? [])
break
}
case "skills": {
setSkills(message.skills ?? [])
break
}
case "messageUpdated": {
const clineMessage = message.clineMessage!
setState((prevState) => {
@ -488,6 +495,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
filePaths,
openedTabs,
commands,
skills,
soundVolume: state.soundVolume,
ttsSpeed: state.ttsSpeed,
writeDelayMs: state.writeDelayMs,

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Experimental",
"language": "Idioma",
"about": "Sobre Roo Code"
"about": "Sobre Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -996,5 +997,50 @@
"label": "Requereix {{primaryMod}}+Intro per enviar missatges",
"description": "Quan estigui activat, has de prémer {{primaryMod}}+Intro per enviar missatges en lloc de només Intro"
}
},
"skills": {
"description": "Gestiona les skills que proporcionen instruccions contextuals a l'agent. Les skills s'apliquen automàticament quan són rellevants per a les teves tasques. <DocsLink>Més informació</DocsLink>",
"projectSkills": "Skills del Projecte",
"globalSkills": "Skills Globals",
"noProjectSkills": "No hi ha skills de projecte configurades. Crea'n una per afegir capacitats específiques del projecte a l'agent.",
"noGlobalSkills": "No hi ha skills globals configurades. Crea'n una per afegir capacitats a l'agent disponibles en tots els projectes.",
"addSkill": "Afegir Skill",
"editSkill": "Editar skill",
"deleteSkill": "Eliminar skill",
"deleteDialog": {
"title": "Eliminar Skill",
"description": "Estàs segur que vols eliminar la skill \"{{name}}\"? Aquesta acció no es pot desfer.",
"confirm": "Eliminar",
"cancel": "Cancel·lar"
},
"createDialog": {
"title": "Crear Nova Skill",
"description": "Defineix una nova plantilla de skill que proporcioni instruccions contextuals a l'agent.",
"nameLabel": "Nom",
"namePlaceholder": "el-meu-nom-de-skill",
"nameHint": "Només lletres minúscules, números i guions (1-64 caràcters)",
"descriptionLabel": "Descripció",
"descriptionPlaceholder": "Descriu quan s'hauria d'utilitzar aquesta skill...",
"descriptionHint": "Explica què fa aquesta skill i quan l'agent hauria d'aplicar-la (1-1024 caràcters)",
"sourceLabel": "Ubicació",
"sourceHint": "Tria si aquesta skill està disponible globalment o només en aquest projecte",
"modeLabel": "Mode (opcional)",
"modePlaceholder": "Qualsevol mode",
"modeHint": "Restringeix aquesta skill a un mode específic",
"modeAny": "Qualsevol mode",
"create": "Crear",
"cancel": "Cancel·lar"
},
"source": {
"global": "Global (disponible en tots els projectes)",
"project": "Projecte (només aquest espai de treball)"
},
"validation": {
"nameRequired": "El nom és obligatori",
"nameTooLong": "El nom ha de tenir com a màxim 64 caràcters",
"nameInvalid": "El nom ha de tenir entre 1 i 64 caràcters, només lletres minúscules, números o guions",
"descriptionRequired": "La descripció és obligatòria",
"descriptionTooLong": "La descripció ha de tenir com a màxim 1024 caràcters"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Experimentell",
"language": "Sprache",
"about": "Über Roo Code"
"about": "Über Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -996,5 +997,50 @@
"label": "{{primaryMod}}+Enter zum Senden erfordern",
"description": "Wenn aktiviert, musst du {{primaryMod}}+Enter drücken, um Nachrichten zu senden, anstatt nur Enter"
}
},
"skills": {
"description": "Verwalten Sie Skills, die dem Agenten kontextbezogene Anweisungen bereitstellen. Skills werden automatisch angewendet, wenn sie für Ihre Aufgaben relevant sind. <DocsLink>Mehr erfahren</DocsLink>",
"projectSkills": "Projekt-Skills",
"globalSkills": "Globale Skills",
"noProjectSkills": "Keine Projekt-Skills konfiguriert. Erstellen Sie eine, um projektspezifische Agentenfähigkeiten hinzuzufügen.",
"noGlobalSkills": "Keine globalen Skills konfiguriert. Erstellen Sie eine, um Agentenfähigkeiten hinzuzufügen, die in allen Projekten verfügbar sind.",
"addSkill": "Skill hinzufügen",
"editSkill": "Skill bearbeiten",
"deleteSkill": "Skill löschen",
"deleteDialog": {
"title": "Skill löschen",
"description": "Sind Sie sicher, dass Sie die Skill \"{{name}}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"confirm": "Löschen",
"cancel": "Abbrechen"
},
"createDialog": {
"title": "Neue Skill erstellen",
"description": "Definieren Sie eine neue Skill-Vorlage, die dem Agenten kontextbezogene Anweisungen bereitstellt.",
"nameLabel": "Name",
"namePlaceholder": "mein-skill-name",
"nameHint": "Nur Kleinbuchstaben, Zahlen und Bindestriche (1-64 Zeichen)",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Beschreiben Sie, wann diese Skill verwendet werden sollte...",
"descriptionHint": "Erklären Sie, was diese Skill tut und wann der Agent sie anwenden sollte (1-1024 Zeichen)",
"sourceLabel": "Standort",
"sourceHint": "Wählen Sie, ob diese Skill global oder nur in diesem Projekt verfügbar ist",
"modeLabel": "Modus (optional)",
"modePlaceholder": "Beliebiger Modus",
"modeHint": "Beschränken Sie diese Skill auf einen bestimmten Modus",
"modeAny": "Beliebiger Modus",
"create": "Erstellen",
"cancel": "Abbrechen"
},
"source": {
"global": "Global (in allen Projekten verfügbar)",
"project": "Projekt (nur dieser Arbeitsbereich)"
},
"validation": {
"nameRequired": "Name ist erforderlich",
"nameTooLong": "Name darf höchstens 64 Zeichen lang sein",
"nameInvalid": "Name muss 1-64 Kleinbuchstaben, Zahlen oder Bindestriche enthalten",
"descriptionRequired": "Beschreibung ist erforderlich",
"descriptionTooLong": "Beschreibung darf höchstens 1024 Zeichen lang sein"
}
}
}

View file

@ -30,6 +30,7 @@
"modes": "Modes",
"mcp": "MCP Servers",
"worktrees": "Worktrees",
"skills": "Skills",
"autoApprove": "Auto-Approve",
"browser": "Browser",
"checkpoints": "Checkpoints",
@ -70,6 +71,51 @@
"slashCommands": {
"description": "Manage your slash commands to quickly execute custom workflows and actions. <DocsLink>Learn more</DocsLink>"
},
"skills": {
"description": "Manage skills that provide contextual instructions to the agent. Skills are automatically applied when relevant to your tasks. <DocsLink>Learn more</DocsLink>",
"projectSkills": "Project Skills",
"globalSkills": "Global Skills",
"noProjectSkills": "No project skills configured. Create one to add project-specific agent capabilities.",
"noGlobalSkills": "No global skills configured. Create one to add agent capabilities available across all projects.",
"addSkill": "Add Skill",
"editSkill": "Edit skill",
"deleteSkill": "Delete skill",
"deleteDialog": {
"title": "Delete Skill",
"description": "Are you sure you want to delete the skill \"{{name}}\"? This action cannot be undone.",
"confirm": "Delete",
"cancel": "Cancel"
},
"createDialog": {
"title": "Create New Skill",
"description": "Define a new skill template that provides contextual instructions to the agent.",
"nameLabel": "Name",
"namePlaceholder": "my-skill-name",
"nameHint": "Lowercase letters, numbers, and hyphens only (1-64 characters)",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Describe when this skill should be used...",
"descriptionHint": "Explain what this skill does and when the agent should apply it (1-1024 characters)",
"sourceLabel": "Location",
"sourceHint": "Choose whether this skill is available globally or only in this project",
"modeLabel": "Mode (optional)",
"modePlaceholder": "Any mode",
"modeHint": "Restrict this skill to a specific mode",
"modeAny": "Any mode",
"create": "Create",
"cancel": "Cancel"
},
"source": {
"global": "Global (available in all projects)",
"project": "Project (this workspace only)"
},
"validation": {
"nameRequired": "Name is required",
"nameTooLong": "Name must be 64 characters or less",
"nameInvalid": "Name must be 1-64 lowercase letters, numbers, or hyphens",
"descriptionRequired": "Description is required",
"descriptionTooLong": "Description must be 1024 characters or less"
}
},
"ui": {
"collapseThinking": {
"label": "Collapse Thinking messages by default",

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Experimental",
"language": "Idioma",
"about": "Acerca de Roo Code"
"about": "Acerca de Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -996,5 +997,50 @@
"label": "Requerir {{primaryMod}}+Enter para enviar mensajes",
"description": "Cuando está activado, debes presionar {{primaryMod}}+Enter para enviar mensajes en lugar de solo Enter"
}
},
"skills": {
"description": "Gestiona skills que proporcionan instrucciones contextuales al agente. Las skills se aplican automáticamente cuando son relevantes para tus tareas. <DocsLink>Más información</DocsLink>",
"projectSkills": "Skills del Proyecto",
"globalSkills": "Skills Globales",
"noProjectSkills": "No hay skills de proyecto configuradas. Crea una para añadir capacidades específicas del proyecto al agente.",
"noGlobalSkills": "No hay skills globales configuradas. Crea una para añadir capacidades al agente disponibles en todos los proyectos.",
"addSkill": "Añadir Skill",
"editSkill": "Editar skill",
"deleteSkill": "Eliminar skill",
"deleteDialog": {
"title": "Eliminar Skill",
"description": "¿Estás seguro de que quieres eliminar la skill \"{{name}}\"? Esta acción no se puede deshacer.",
"confirm": "Eliminar",
"cancel": "Cancelar"
},
"createDialog": {
"title": "Crear Nueva Skill",
"description": "Define una nueva plantilla de skill que proporcione instrucciones contextuales al agente.",
"nameLabel": "Nombre",
"namePlaceholder": "mi-nombre-de-skill",
"nameHint": "Solo letras minúsculas, números y guiones (1-64 caracteres)",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Describe cuándo debería usarse esta skill...",
"descriptionHint": "Explica qué hace esta skill y cuándo el agente debería aplicarla (1-1024 caracteres)",
"sourceLabel": "Ubicación",
"sourceHint": "Elige si esta skill está disponible globalmente o solo en este proyecto",
"modeLabel": "Modo (opcional)",
"modePlaceholder": "Cualquier modo",
"modeHint": "Restringe esta skill a un modo específico",
"modeAny": "Cualquier modo",
"create": "Crear",
"cancel": "Cancelar"
},
"source": {
"global": "Global (disponible en todos los proyectos)",
"project": "Proyecto (solo este espacio de trabajo)"
},
"validation": {
"nameRequired": "El nombre es obligatorio",
"nameTooLong": "El nombre debe tener como máximo 64 caracteres",
"nameInvalid": "El nombre debe tener entre 1 y 64 caracteres, solo letras minúsculas, números o guiones",
"descriptionRequired": "La descripción es obligatoria",
"descriptionTooLong": "La descripción debe tener como máximo 1024 caracteres"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Expérimental",
"language": "Langue",
"about": "À propos de Roo Code"
"about": "À propos de Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -996,5 +997,50 @@
"label": "Exiger {{primaryMod}}+Entrée pour envoyer les messages",
"description": "Lorsqu'activé, tu dois appuyer sur {{primaryMod}}+Entrée pour envoyer des messages au lieu de simplement Entrée"
}
},
"skills": {
"description": "Gérez les skills qui fournissent des instructions contextuelles à l'agent. Les skills sont automatiquement appliquées lorsqu'elles sont pertinentes pour vos tâches. <DocsLink>En savoir plus</DocsLink>",
"projectSkills": "Skills du Projet",
"globalSkills": "Skills Globales",
"noProjectSkills": "Aucune skill de projet configurée. Créez-en une pour ajouter des capacités spécifiques au projet à l'agent.",
"noGlobalSkills": "Aucune skill globale configurée. Créez-en une pour ajouter des capacités à l'agent disponibles dans tous les projets.",
"addSkill": "Ajouter une Skill",
"editSkill": "Modifier la skill",
"deleteSkill": "Supprimer la skill",
"deleteDialog": {
"title": "Supprimer la Skill",
"description": "Êtes-vous sûr de vouloir supprimer la skill \"{{name}}\" ? Cette action ne peut pas être annulée.",
"confirm": "Supprimer",
"cancel": "Annuler"
},
"createDialog": {
"title": "Créer une Nouvelle Skill",
"description": "Définissez un nouveau modèle de skill qui fournit des instructions contextuelles à l'agent.",
"nameLabel": "Nom",
"namePlaceholder": "mon-nom-de-skill",
"nameHint": "Lettres minuscules, chiffres et tirets uniquement (1-64 caractères)",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Décrivez quand cette skill devrait être utilisée...",
"descriptionHint": "Expliquez ce que fait cette skill et quand l'agent devrait l'appliquer (1-1024 caractères)",
"sourceLabel": "Emplacement",
"sourceHint": "Choisissez si cette skill est disponible globalement ou uniquement dans ce projet",
"modeLabel": "Mode (optionnel)",
"modePlaceholder": "N'importe quel mode",
"modeHint": "Restreindre cette skill à un mode spécifique",
"modeAny": "N'importe quel mode",
"create": "Créer",
"cancel": "Annuler"
},
"source": {
"global": "Globale (disponible dans tous les projets)",
"project": "Projet (cet espace de travail uniquement)"
},
"validation": {
"nameRequired": "Le nom est obligatoire",
"nameTooLong": "Le nom doit contenir au maximum 64 caractères",
"nameInvalid": "Le nom doit contenir entre 1 et 64 lettres minuscules, chiffres ou tirets",
"descriptionRequired": "La description est obligatoire",
"descriptionTooLong": "La description doit contenir au maximum 1024 caractères"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "प्रायोगिक",
"language": "भाषा",
"about": "परिचय"
"about": "परिचय",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "संदेश भेजने के लिए {{primaryMod}}+Enter की आवश्यकता है",
"description": "जब सक्षम हो, तो आपको केवल Enter के बजाय संदेश भेजने के लिए {{primaryMod}}+Enter दबाना होगा"
}
},
"skills": {
"description": "Skills का प्रबंधन करें जो एजेंट को संदर्भात्मक निर्देश प्रदान करते हैं। जब आपके कार्यों के लिए प्रासंगिक हों तो Skills स्वचालित रूप से लागू होते हैं। <DocsLink>और जानें</DocsLink>",
"projectSkills": "प्रोजेक्ट Skills",
"globalSkills": "ग्लोबल Skills",
"noProjectSkills": "कोई प्रोजेक्ट skills कॉन्फ़िगर नहीं किया गया। प्रोजेक्ट-विशिष्ट एजेंट क्षमताएं जोड़ने के लिए एक बनाएं।",
"noGlobalSkills": "कोई ग्लोबल skills कॉन्फ़िगर नहीं किया गया। सभी प्रोजेक्ट्स में उपलब्ध एजेंट क्षमताएं जोड़ने के लिए एक बनाएं।",
"addSkill": "Skill जोड़ें",
"editSkill": "Skill संपादित करें",
"deleteSkill": "Skill हटाएं",
"deleteDialog": {
"title": "Skill हटाएं",
"description": "क्या आप वाकई skill \"{{name}}\" को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"confirm": "हटाएं",
"cancel": "रद्द करें"
},
"createDialog": {
"title": "नया Skill बनाएं",
"description": "एक नया skill टेम्पलेट परिभाषित करें जो एजेंट को संदर्भात्मक निर्देश प्रदान करता है।",
"nameLabel": "नाम",
"namePlaceholder": "my-skill-name",
"nameHint": "केवल छोटे अक्षर, संख्याएं और हाइफ़न (1-64 वर्ण)",
"descriptionLabel": "विवरण",
"descriptionPlaceholder": "वर्णन करें कि इस skill का उपयोग कब किया जाना चाहिए...",
"descriptionHint": "समझाएं कि यह skill क्या करता है और एजेंट को इसे कब लागू करना चाहिए (1-1024 वर्ण)",
"sourceLabel": "स्थान",
"sourceHint": "चुनें कि यह skill ग्लोबल रूप से उपलब्ध है या केवल इस प्रोजेक्ट में",
"modeLabel": "मोड (वैकल्पिक)",
"modePlaceholder": "कोई भी मोड",
"modeHint": "इस skill को किसी विशिष्ट मोड तक सीमित करें",
"modeAny": "कोई भी मोड",
"create": "बनाएं",
"cancel": "रद्द करें"
},
"source": {
"global": "ग्लोबल (सभी प्रोजेक्ट्स में उपलब्ध)",
"project": "प्रोजेक्ट (केवल यह वर्कस्पेस)"
},
"validation": {
"nameRequired": "नाम आवश्यक है",
"nameTooLong": "नाम 64 वर्णों से अधिक नहीं होना चाहिए",
"nameInvalid": "नाम 1-64 छोटे अक्षर, संख्याएं या हाइफ़न होना चाहिए",
"descriptionRequired": "विवरण आवश्यक है",
"descriptionTooLong": "विवरण 1024 वर्णों से अधिक नहीं होना चाहिए"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Eksperimental",
"language": "Bahasa",
"about": "Tentang Roo Code"
"about": "Tentang Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -1026,5 +1027,50 @@
"label": "Memerlukan {{primaryMod}}+Enter untuk mengirim pesan",
"description": "Ketika diaktifkan, kamu harus menekan {{primaryMod}}+Enter untuk mengirim pesan alih-alih hanya Enter"
}
},
"skills": {
"description": "Kelola skills yang memberikan instruksi kontekstual kepada agen. Skills diterapkan secara otomatis saat relevan dengan tugas Anda. <DocsLink>Pelajari lebih lanjut</DocsLink>",
"projectSkills": "Skills Proyek",
"globalSkills": "Skills Global",
"noProjectSkills": "Tidak ada skills proyek yang dikonfigurasi. Buat satu untuk menambahkan kemampuan agen khusus proyek.",
"noGlobalSkills": "Tidak ada skills global yang dikonfigurasi. Buat satu untuk menambahkan kemampuan agen yang tersedia di semua proyek.",
"addSkill": "Tambahkan Skill",
"editSkill": "Edit skill",
"deleteSkill": "Hapus skill",
"deleteDialog": {
"title": "Hapus Skill",
"description": "Apakah Anda yakin ingin menghapus skill \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.",
"confirm": "Hapus",
"cancel": "Batal"
},
"createDialog": {
"title": "Buat Skill Baru",
"description": "Tentukan template skill baru yang memberikan instruksi kontekstual kepada agen.",
"nameLabel": "Nama",
"namePlaceholder": "nama-skill-saya",
"nameHint": "Hanya huruf kecil, angka, dan tanda hubung (1-64 karakter)",
"descriptionLabel": "Deskripsi",
"descriptionPlaceholder": "Jelaskan kapan skill ini harus digunakan...",
"descriptionHint": "Jelaskan apa yang dilakukan skill ini dan kapan agen harus menerapkannya (1-1024 karakter)",
"sourceLabel": "Lokasi",
"sourceHint": "Pilih apakah skill ini tersedia secara global atau hanya di proyek ini",
"modeLabel": "Mode (opsional)",
"modePlaceholder": "Mode apa saja",
"modeHint": "Batasi skill ini ke mode tertentu",
"modeAny": "Mode apa saja",
"create": "Buat",
"cancel": "Batal"
},
"source": {
"global": "Global (tersedia di semua proyek)",
"project": "Proyek (workspace ini saja)"
},
"validation": {
"nameRequired": "Nama diperlukan",
"nameTooLong": "Nama harus 64 karakter atau kurang",
"nameInvalid": "Nama harus 1-64 huruf kecil, angka, atau tanda hubung",
"descriptionRequired": "Deskripsi diperlukan",
"descriptionTooLong": "Deskripsi harus 1024 karakter atau kurang"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Sperimentale",
"language": "Lingua",
"about": "Informazioni su Roo Code"
"about": "Informazioni su Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "Richiedi {{primaryMod}}+Invio per inviare messaggi",
"description": "Quando abilitato, devi premere {{primaryMod}}+Invio per inviare messaggi invece di solo Invio"
}
},
"skills": {
"description": "Gestisci le skills che forniscono istruzioni contestuali all'agente. Le skills vengono applicate automaticamente quando rilevanti per le tue attività. <DocsLink>Scopri di più</DocsLink>",
"projectSkills": "Skills del Progetto",
"globalSkills": "Skills Globali",
"noProjectSkills": "Nessuna skill di progetto configurata. Creane una per aggiungere capacità specifiche del progetto all'agente.",
"noGlobalSkills": "Nessuna skill globale configurata. Creane una per aggiungere capacità all'agente disponibili in tutti i progetti.",
"addSkill": "Aggiungi Skill",
"editSkill": "Modifica skill",
"deleteSkill": "Elimina skill",
"deleteDialog": {
"title": "Elimina Skill",
"description": "Sei sicuro di voler eliminare la skill \"{{name}}\"? Questa azione non può essere annullata.",
"confirm": "Elimina",
"cancel": "Annulla"
},
"createDialog": {
"title": "Crea Nuova Skill",
"description": "Definisci un nuovo modello di skill che fornisce istruzioni contestuali all'agente.",
"nameLabel": "Nome",
"namePlaceholder": "il-mio-nome-skill",
"nameHint": "Solo lettere minuscole, numeri e trattini (1-64 caratteri)",
"descriptionLabel": "Descrizione",
"descriptionPlaceholder": "Descrivi quando questa skill dovrebbe essere utilizzata...",
"descriptionHint": "Spiega cosa fa questa skill e quando l'agente dovrebbe applicarla (1-1024 caratteri)",
"sourceLabel": "Posizione",
"sourceHint": "Scegli se questa skill è disponibile globalmente o solo in questo progetto",
"modeLabel": "Modalità (opzionale)",
"modePlaceholder": "Qualsiasi modalità",
"modeHint": "Limita questa skill a una modalità specifica",
"modeAny": "Qualsiasi modalità",
"create": "Crea",
"cancel": "Annulla"
},
"source": {
"global": "Globale (disponibile in tutti i progetti)",
"project": "Progetto (solo questo workspace)"
},
"validation": {
"nameRequired": "Il nome è obbligatorio",
"nameTooLong": "Il nome deve essere di massimo 64 caratteri",
"nameInvalid": "Il nome deve contenere 1-64 lettere minuscole, numeri o trattini",
"descriptionRequired": "La descrizione è obbligatoria",
"descriptionTooLong": "La descrizione deve essere di massimo 1024 caratteri"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "実験的",
"language": "言語",
"about": "Roo Codeについて"
"about": "Roo Codeについて",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "メッセージを送信するには{{primaryMod}}+Enterが必要",
"description": "有効にすると、Enterだけでなく{{primaryMod}}+Enterを押してメッセージを送信する必要があります"
}
},
"skills": {
"description": "エージェントにコンテキスト指示を提供するスキルを管理します。スキルはタスクに関連する場合に自動的に適用されます。<DocsLink>詳細を見る</DocsLink>",
"projectSkills": "プロジェクトスキル",
"globalSkills": "グローバルスキル",
"noProjectSkills": "プロジェクトスキルが設定されていません。プロジェクト固有のエージェント機能を追加するには、作成してください。",
"noGlobalSkills": "グローバルスキルが設定されていません。すべてのプロジェクトで利用可能なエージェント機能を追加するには、作成してください。",
"addSkill": "スキルを追加",
"editSkill": "スキルを編集",
"deleteSkill": "スキルを削除",
"deleteDialog": {
"title": "スキルを削除",
"description": "スキル「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"confirm": "削除",
"cancel": "キャンセル"
},
"createDialog": {
"title": "新しいスキルを作成",
"description": "エージェントにコンテキスト指示を提供する新しいスキルテンプレートを定義します。",
"nameLabel": "名前",
"namePlaceholder": "my-skill-name",
"nameHint": "小文字、数字、ハイフンのみ1〜64文字",
"descriptionLabel": "説明",
"descriptionPlaceholder": "このスキルをいつ使用するか説明してください...",
"descriptionHint": "このスキルが何をするか、エージェントがいつ適用すべきかを説明してください1〜1024文字",
"sourceLabel": "場所",
"sourceHint": "このスキルがグローバルに利用可能か、このプロジェクトのみかを選択してください",
"modeLabel": "モード(オプション)",
"modePlaceholder": "全てのモード",
"modeHint": "このスキルを特定のモードに制限する",
"modeAny": "全てのモード",
"create": "作成",
"cancel": "キャンセル"
},
"source": {
"global": "グローバル(すべてのプロジェクトで利用可能)",
"project": "プロジェクト(このワークスペースのみ)"
},
"validation": {
"nameRequired": "名前は必須です",
"nameTooLong": "名前は64文字以内である必要があります",
"nameInvalid": "名前は1〜64文字の小文字、数字、またはハイフンである必要があります",
"descriptionRequired": "説明は必須です",
"descriptionTooLong": "説明は1024文字以内である必要があります"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "실험적",
"language": "언어",
"about": "Roo Code 정보"
"about": "Roo Code 정보",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "메시지를 보내려면 {{primaryMod}}+Enter가 필요",
"description": "활성화하면 Enter만으로는 안 되고 {{primaryMod}}+Enter를 눌러야 메시지를 보낼 수 있습니다"
}
},
"skills": {
"description": "에이전트에 컨텍스트 지침을 제공하는 스킬을 관리합니다. 스킬은 작업과 관련이 있을 때 자동으로 적용됩니다. <DocsLink>자세히 알아보기</DocsLink>",
"projectSkills": "프로젝트 스킬",
"globalSkills": "전역 스킬",
"noProjectSkills": "구성된 프로젝트 스킬이 없습니다. 프로젝트별 에이전트 기능을 추가하려면 하나를 만드세요.",
"noGlobalSkills": "구성된 전역 스킬이 없습니다. 모든 프로젝트에서 사용할 수 있는 에이전트 기능을 추가하려면 하나를 만드세요.",
"addSkill": "스킬 추가",
"editSkill": "스킬 편집",
"deleteSkill": "스킬 삭제",
"deleteDialog": {
"title": "스킬 삭제",
"description": "스킬 \"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"confirm": "삭제",
"cancel": "취소"
},
"createDialog": {
"title": "새 스킬 만들기",
"description": "에이전트에 컨텍스트 지침을 제공하는 새 스킬 템플릿을 정의합니다.",
"nameLabel": "이름",
"namePlaceholder": "my-skill-name",
"nameHint": "소문자, 숫자 및 하이픈만 사용(1-64자)",
"descriptionLabel": "설명",
"descriptionPlaceholder": "이 스킬을 언제 사용해야 하는지 설명하세요...",
"descriptionHint": "이 스킬이 무엇을 하는지, 에이전트가 언제 적용해야 하는지 설명하세요(1-1024자)",
"sourceLabel": "위치",
"sourceHint": "이 스킬을 전역으로 사용할지 이 프로젝트에만 사용할지 선택하세요",
"modeLabel": "모드 (선택사항)",
"modePlaceholder": "모든 모드",
"modeHint": "이 스킬을 특정 모드로 제한",
"modeAny": "모든 모드",
"create": "만들기",
"cancel": "취소"
},
"source": {
"global": "전역 (모든 프로젝트에서 사용 가능)",
"project": "프로젝트 (이 작업공간만)"
},
"validation": {
"nameRequired": "이름은 필수입니다",
"nameTooLong": "이름은 64자 이하여야 합니다",
"nameInvalid": "이름은 1-64자의 소문자, 숫자 또는 하이픈이어야 합니다",
"descriptionRequired": "설명은 필수입니다",
"descriptionTooLong": "설명은 1024자 이하여야 합니다"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Experimenteel",
"language": "Taal",
"about": "Over Roo Code"
"about": "Over Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "Vereist {{primaryMod}}+Enter om berichten te versturen",
"description": "Wanneer ingeschakeld, moet je {{primaryMod}}+Enter indrukken om berichten te versturen in plaats van alleen Enter"
}
},
"skills": {
"description": "Beheer skills die contextuele instructies aan de agent verstrekken. Skills worden automatisch toegepast wanneer ze relevant zijn voor uw taken. <DocsLink>Meer informatie</DocsLink>",
"projectSkills": "Projectskills",
"globalSkills": "Globale Skills",
"noProjectSkills": "Geen projectskills geconfigureerd. Maak er een om projectspecifieke agentmogelijkheden toe te voegen.",
"noGlobalSkills": "Geen globale skills geconfigureerd. Maak er een om agentmogelijkheden toe te voegen die beschikbaar zijn in alle projecten.",
"addSkill": "Skill toevoegen",
"editSkill": "Skill bewerken",
"deleteSkill": "Skill verwijderen",
"deleteDialog": {
"title": "Skill verwijderen",
"description": "Weet u zeker dat u de skill \"{{name}}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"confirm": "Verwijderen",
"cancel": "Annuleren"
},
"createDialog": {
"title": "Nieuwe Skill maken",
"description": "Definieer een nieuwe skillsjabloon die contextuele instructies aan de agent verstrekt.",
"nameLabel": "Naam",
"namePlaceholder": "mijn-skill-naam",
"nameHint": "Alleen kleine letters, cijfers en streepjes (1-64 tekens)",
"descriptionLabel": "Beschrijving",
"descriptionPlaceholder": "Beschrijf wanneer deze skill moet worden gebruikt...",
"descriptionHint": "Leg uit wat deze skill doet en wanneer de agent deze moet toepassen (1-1024 tekens)",
"sourceLabel": "Locatie",
"sourceHint": "Kies of deze skill globaal beschikbaar is of alleen in dit project",
"modeLabel": "Modus (optioneel)",
"modePlaceholder": "Elke modus",
"modeHint": "Beperk deze skill tot een specifieke modus",
"modeAny": "Elke modus",
"create": "Maken",
"cancel": "Annuleren"
},
"source": {
"global": "Globaal (beschikbaar in alle projecten)",
"project": "Project (alleen deze workspace)"
},
"validation": {
"nameRequired": "Naam is verplicht",
"nameTooLong": "Naam moet maximaal 64 tekens zijn",
"nameInvalid": "Naam moet 1-64 kleine letters, cijfers of streepjes zijn",
"descriptionRequired": "Beschrijving is verplicht",
"descriptionTooLong": "Beschrijving moet maximaal 1024 tekens zijn"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Eksperymentalne",
"language": "Język",
"about": "O Roo Code"
"about": "O Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "Wymagaj {{primaryMod}}+Enter do wysyłania wiadomości",
"description": "Po włączeniu musisz nacisnąć {{primaryMod}}+Enter, aby wysłać wiadomości, zamiast tylko Enter"
}
},
"skills": {
"description": "Zarządzaj umiejętnościami, które dostarczają kontekstowe instrukcje dla agenta. Umiejętności są automatycznie stosowane, gdy są istotne dla Twoich zadań. <DocsLink>Dowiedz się więcej</DocsLink>",
"projectSkills": "Umiejętności Projektu",
"globalSkills": "Umiejętności Globalne",
"noProjectSkills": "Brak skonfigurowanych umiejętności projektu. Utwórz jedną, aby dodać możliwości agenta specyficzne dla projektu.",
"noGlobalSkills": "Brak skonfigurowanych umiejętności globalnych. Utwórz jedną, aby dodać możliwości agenta dostępne we wszystkich projektach.",
"addSkill": "Dodaj Umiejętność",
"editSkill": "Edytuj umiejętność",
"deleteSkill": "Usuń umiejętność",
"deleteDialog": {
"title": "Usuń Umiejętność",
"description": "Czy na pewno chcesz usunąć umiejętność \"{{name}}\"? Tej akcji nie można cofnąć.",
"confirm": "Usuń",
"cancel": "Anuluj"
},
"createDialog": {
"title": "Utwórz Nową Umiejętność",
"description": "Zdefiniuj nowy szablon umiejętności, który dostarcza kontekstowe instrukcje dla agenta.",
"nameLabel": "Nazwa",
"namePlaceholder": "moja-nazwa-umiejetnosci",
"nameHint": "Tylko małe litery, cyfry i myślniki (1-64 znaki)",
"descriptionLabel": "Opis",
"descriptionPlaceholder": "Opisz, kiedy ta umiejętność powinna być użyta...",
"descriptionHint": "Wyjaśnij, co robi ta umiejętność i kiedy agent powinien ją zastosować (1-1024 znaki)",
"sourceLabel": "Lokalizacja",
"sourceHint": "Wybierz, czy ta umiejętność jest dostępna globalnie, czy tylko w tym projekcie",
"modeLabel": "Tryb (opcjonalnie)",
"modePlaceholder": "Dowolny tryb",
"modeHint": "Ogranicz tę umiejętność do określonego trybu",
"modeAny": "Dowolny tryb",
"create": "Utwórz",
"cancel": "Anuluj"
},
"source": {
"global": "Globalnie (dostępne we wszystkich projektach)",
"project": "Projekt (tylko ten obszar roboczy)"
},
"validation": {
"nameRequired": "Nazwa jest wymagana",
"nameTooLong": "Nazwa musi mieć maksymalnie 64 znaki",
"nameInvalid": "Nazwa musi zawierać 1-64 małe litery, cyfry lub myślniki",
"descriptionRequired": "Opis jest wymagany",
"descriptionTooLong": "Opis musi mieć maksymalnie 1024 znaki"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Experimental",
"language": "Idioma",
"about": "Sobre"
"about": "Sobre",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "Requer {{primaryMod}}+Enter para enviar mensagens",
"description": "Quando ativado, você deve pressionar {{primaryMod}}+Enter para enviar mensagens em vez de apenas Enter"
}
},
"skills": {
"description": "Gerencie skills que fornecem instruções contextuais ao agente. As skills são aplicadas automaticamente quando relevantes para suas tarefas. <DocsLink>Saiba mais</DocsLink>",
"projectSkills": "Skills do Projeto",
"globalSkills": "Skills Globais",
"noProjectSkills": "Nenhuma skill de projeto configurada. Crie uma para adicionar capacidades específicas do projeto ao agente.",
"noGlobalSkills": "Nenhuma skill global configurada. Crie uma para adicionar capacidades ao agente disponíveis em todos os projetos.",
"addSkill": "Adicionar Skill",
"editSkill": "Editar skill",
"deleteSkill": "Excluir skill",
"deleteDialog": {
"title": "Excluir Skill",
"description": "Tem certeza de que deseja excluir a skill \"{{name}}\"? Esta ação não pode ser desfeita.",
"confirm": "Excluir",
"cancel": "Cancelar"
},
"createDialog": {
"title": "Criar Nova Skill",
"description": "Defina um novo modelo de skill que fornece instruções contextuais ao agente.",
"nameLabel": "Nome",
"namePlaceholder": "meu-nome-de-skill",
"nameHint": "Apenas letras minúsculas, números e hífens (1-64 caracteres)",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Descreva quando esta skill deve ser usada...",
"descriptionHint": "Explique o que esta skill faz e quando o agente deve aplicá-la (1-1024 caracteres)",
"sourceLabel": "Localização",
"sourceHint": "Escolha se esta skill está disponível globalmente ou apenas neste projeto",
"modeLabel": "Modo (opcional)",
"modePlaceholder": "Qualquer modo",
"modeHint": "Restrinja esta skill a um modo específico",
"modeAny": "Qualquer modo",
"create": "Criar",
"cancel": "Cancelar"
},
"source": {
"global": "Global (disponível em todos os projetos)",
"project": "Projeto (apenas este workspace)"
},
"validation": {
"nameRequired": "O nome é obrigatório",
"nameTooLong": "O nome deve ter no máximo 64 caracteres",
"nameInvalid": "O nome deve ter 1-64 letras minúsculas, números ou hífens",
"descriptionRequired": "A descrição é obrigatória",
"descriptionTooLong": "A descrição deve ter no máximo 1024 caracteres"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Экспериментальное",
"language": "Язык",
"about": "О Roo Code"
"about": "О Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "Требовать {{primaryMod}}+Enter для отправки сообщений",
"description": "Если включено, необходимо нажать {{primaryMod}}+Enter для отправки сообщений вместо простого Enter"
}
},
"skills": {
"description": "Управляйте навыками, которые предоставляют контекстные инструкции агенту. Навыки автоматически применяются, когда они релевантны вашим задачам. <DocsLink>Узнать больше</DocsLink>",
"projectSkills": "Навыки Проекта",
"globalSkills": "Глобальные Навыки",
"noProjectSkills": "Навыки проекта не настроены. Создайте навык, чтобы добавить возможности агента для конкретного проекта.",
"noGlobalSkills": "Глобальные навыки не настроены. Создайте навык, чтобы добавить возможности агента, доступные во всех проектах.",
"addSkill": "Добавить Навык",
"editSkill": "Редактировать навык",
"deleteSkill": "Удалить навык",
"deleteDialog": {
"title": "Удалить Навык",
"description": "Вы уверены, что хотите удалить навык \"{{name}}\"? Это действие нельзя отменить.",
"confirm": "Удалить",
"cancel": "Отмена"
},
"createDialog": {
"title": "Создать Новый Навык",
"description": "Определите новый шаблон навыка, который предоставляет контекстные инструкции агенту.",
"nameLabel": "Имя",
"namePlaceholder": "my-skill-name",
"nameHint": "Только строчные буквы, цифры и дефисы (1-64 символа)",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Опишите, когда следует использовать этот навык...",
"descriptionHint": "Объясните, что делает этот навык и когда агент должен его применять (1-1024 символа)",
"sourceLabel": "Расположение",
"sourceHint": "Выберите, доступен ли этот навык глобально или только в этом проекте",
"modeLabel": "Режим (необязательно)",
"modePlaceholder": "Любой режим",
"modeHint": "Ограничьте этот навык определенным режимом",
"modeAny": "Любой режим",
"create": "Создать",
"cancel": "Отмена"
},
"source": {
"global": "Глобальный (доступен во всех проектах)",
"project": "Проект (только эта рабочая область)"
},
"validation": {
"nameRequired": "Имя обязательно",
"nameTooLong": "Имя должно быть не более 64 символов",
"nameInvalid": "Имя должно содержать 1-64 строчные буквы, цифры или дефисы",
"descriptionRequired": "Описание обязательно",
"descriptionTooLong": "Описание должно быть не более 1024 символов"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Deneysel",
"language": "Dil",
"about": "Roo Code Hakkında"
"about": "Roo Code Hakkında",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "Mesaj göndermek için {{primaryMod}}+Enter gerekli",
"description": "Etkinleştirildiğinde, sadece Enter yerine mesaj göndermek için {{primaryMod}}+Enter'a basmalısınız"
}
},
"skills": {
"description": "Ajana bağlamsal talimatlar sağlayan becerileri yönetin. Beceriler, görevlerinizle ilgili olduklarında otomatik olarak uygulanır. <DocsLink>Daha fazla bilgi</DocsLink>",
"projectSkills": "Proje Becerileri",
"globalSkills": "Genel Beceriler",
"noProjectSkills": "Yapılandırılmış proje becerisi yok. Projeye özgü ajan yetenekleri eklemek için bir tane oluşturun.",
"noGlobalSkills": "Yapılandırılmış genel beceri yok. Tüm projelerde kullanılabilir ajan yetenekleri eklemek için bir tane oluşturun.",
"addSkill": "Beceri Ekle",
"editSkill": "Beceriyi düzenle",
"deleteSkill": "Beceriyi sil",
"deleteDialog": {
"title": "Beceriyi Sil",
"description": "\"{{name}}\" becerisini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"confirm": "Sil",
"cancel": "İptal"
},
"createDialog": {
"title": "Yeni Beceri Oluştur",
"description": "Ajana bağlamsal talimatlar sağlayan yeni bir beceri şablonu tanımlayın.",
"nameLabel": "Ad",
"namePlaceholder": "benim-beceri-adim",
"nameHint": "Yalnızca küçük harfler, rakamlar ve kısa çizgiler (1-64 karakter)",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Bu becerinin ne zaman kullanılması gerektiğini açıklayın...",
"descriptionHint": "Bu becerinin ne yaptığını ve ajanın ne zaman uygulaması gerektiğini açıklayın (1-1024 karakter)",
"sourceLabel": "Konum",
"sourceHint": "Bu becerinin genel olarak mı yoksa yalnızca bu projede mi kullanılabilir olduğunu seçin",
"modeLabel": "Mod (isteğe bağlı)",
"modePlaceholder": "Herhangi bir mod",
"modeHint": "Bu beceriyi belirli bir modla sınırlayın",
"modeAny": "Herhangi bir mod",
"create": "Oluştur",
"cancel": "İptal"
},
"source": {
"global": "Genel (tüm projelerde kullanılabilir)",
"project": "Proje (yalnızca bu çalışma alanı)"
},
"validation": {
"nameRequired": "Ad gereklidir",
"nameTooLong": "Ad en fazla 64 karakter olmalıdır",
"nameInvalid": "Ad 1-64 küçük harf, rakam veya kısa çizgi olmalıdır",
"descriptionRequired": "Açıklama gereklidir",
"descriptionTooLong": "Açıklama en fazla 1024 karakter olmalıdır"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "Thử nghiệm",
"language": "Ngôn ngữ",
"about": "Giới thiệu"
"about": "Giới thiệu",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "Yêu cầu {{primaryMod}}+Enter để gửi tin nhắn",
"description": "Khi được bật, bạn phải nhấn {{primaryMod}}+Enter để gửi tin nhắn thay vì chỉ nhấn Enter"
}
},
"skills": {
"description": "Quản lý các skill cung cấp hướng dẫn theo ngữ cảnh cho agent. Các skill được áp dụng tự động khi chúng liên quan đến nhiệm vụ của bạn. <DocsLink>Tìm hiểu thêm</DocsLink>",
"projectSkills": "Skills Dự Án",
"globalSkills": "Skills Toàn Cục",
"noProjectSkills": "Không có skill dự án nào được cấu hình. Tạo một skill để thêm khả năng agent cụ thể cho dự án.",
"noGlobalSkills": "Không có skill toàn cục nào được cấu hình. Tạo một skill để thêm khả năng agent có sẵn trong tất cả các dự án.",
"addSkill": "Thêm Skill",
"editSkill": "Chỉnh sửa skill",
"deleteSkill": "Xóa skill",
"deleteDialog": {
"title": "Xóa Skill",
"description": "Bạn có chắc chắn muốn xóa skill \"{{name}}\" không? Hành động này không thể hoàn tác.",
"confirm": "Xóa",
"cancel": "Hủy"
},
"createDialog": {
"title": "Tạo Skill Mới",
"description": "Xác định một mẫu skill mới cung cấp hướng dẫn theo ngữ cảnh cho agent.",
"nameLabel": "Tên",
"namePlaceholder": "ten-skill-cua-toi",
"nameHint": "Chỉ chữ thường, số và dấu gạch ngang (1-64 ký tự)",
"descriptionLabel": "Mô tả",
"descriptionPlaceholder": "Mô tả khi nào nên sử dụng skill này...",
"descriptionHint": "Giải thích skill này làm gì và khi nào agent nên áp dụng nó (1-1024 ký tự)",
"sourceLabel": "Vị trí",
"sourceHint": "Chọn xem skill này có sẵn toàn cục hay chỉ trong dự án này",
"modeLabel": "Chế độ (tùy chọn)",
"modePlaceholder": "Bất kỳ chế độ nào",
"modeHint": "Hạn chế skill này cho một chế độ cụ thể",
"modeAny": "Bất kỳ chế độ nào",
"create": "Tạo",
"cancel": "Hủy"
},
"source": {
"global": "Toàn cục (có sẵn trong tất cả các dự án)",
"project": "Dự án (chỉ workspace này)"
},
"validation": {
"nameRequired": "Tên là bắt buộc",
"nameTooLong": "Tên phải có tối đa 64 ký tự",
"nameInvalid": "Tên phải là 1-64 chữ thường, số hoặc dấu gạch ngang",
"descriptionRequired": "Mô tả là bắt buộc",
"descriptionTooLong": "Mô tả phải có tối đa 1024 ký tự"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "实验性",
"language": "语言",
"about": "关于 Roo Code"
"about": "关于 Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -997,5 +998,50 @@
"label": "需要 {{primaryMod}}+Enter 发送消息",
"description": "启用后,必须按 {{primaryMod}}+Enter 发送消息,而不仅仅是 Enter"
}
},
"skills": {
"description": "管理为代理提供上下文指令的技能。技能会在与您的任务相关时自动应用。<DocsLink>了解更多</DocsLink>",
"projectSkills": "项目技能",
"globalSkills": "全局技能",
"noProjectSkills": "未配置项目技能。创建一个以添加特定于项目的代理功能。",
"noGlobalSkills": "未配置全局技能。创建一个以添加在所有项目中可用的代理功能。",
"addSkill": "添加技能",
"editSkill": "编辑技能",
"deleteSkill": "删除技能",
"deleteDialog": {
"title": "删除技能",
"description": "您确定要删除技能\"{{name}}\"吗?此操作无法撤销。",
"confirm": "删除",
"cancel": "取消"
},
"createDialog": {
"title": "创建新技能",
"description": "定义一个新的技能模板,为代理提供上下文指令。",
"nameLabel": "名称",
"namePlaceholder": "my-skill-name",
"nameHint": "仅小写字母、数字和连字符1-64个字符",
"descriptionLabel": "描述",
"descriptionPlaceholder": "描述何时应使用此技能...",
"descriptionHint": "解释此技能的作用以及代理应何时应用它1-1024个字符",
"sourceLabel": "位置",
"sourceHint": "选择此技能是全局可用还是仅在此项目中可用",
"modeLabel": "模式(可选)",
"modePlaceholder": "任何模式",
"modeHint": "将此技能限制为特定模式",
"modeAny": "任何模式",
"create": "创建",
"cancel": "取消"
},
"source": {
"global": "全局(在所有项目中可用)",
"project": "项目(仅此工作区)"
},
"validation": {
"nameRequired": "名称为必填项",
"nameTooLong": "名称不得超过64个字符",
"nameInvalid": "名称必须为1-64个小写字母、数字或连字符",
"descriptionRequired": "描述为必填项",
"descriptionTooLong": "描述不得超过1024个字符"
}
}
}

View file

@ -41,7 +41,8 @@
"ui": "UI",
"experimental": "實驗性",
"language": "語言",
"about": "關於 Roo Code"
"about": "關於 Roo Code",
"skills": "Skills"
},
"about": {
"bugReport": {
@ -994,5 +995,60 @@
"output": "輸出",
"cacheReads": "快取讀取"
}
},
"ui": {
"collapseThinking": {
"label": "預設折疊「思考」訊息",
"description": "啟用後,「思考」塊將預設折疊,直到您與其互動"
},
"requireCtrlEnterToSend": {
"label": "需要 {{primaryMod}}+Enter 傳送訊息",
"description": "啟用後,必須按 {{primaryMod}}+Enter 傳送訊息,而不只是 Enter"
}
},
"skills": {
"description": "管理為代理提供上下文指令的技能。技能會在與您的任務相關時自動套用。<DocsLink>深入了解</DocsLink>",
"projectSkills": "專案技能",
"globalSkills": "全域技能",
"noProjectSkills": "未設定專案技能。建立一個以新增專案特定的代理功能。",
"noGlobalSkills": "未設定全域技能。建立一個以新增在所有專案中可用的代理功能。",
"addSkill": "新增技能",
"editSkill": "編輯技能",
"deleteSkill": "刪除技能",
"deleteDialog": {
"title": "刪除技能",
"description": "您確定要刪除技能「{{name}}」嗎?此動作無法復原。",
"confirm": "刪除",
"cancel": "取消"
},
"createDialog": {
"title": "建立新技能",
"description": "定義新的技能範本,為代理提供上下文指令。",
"nameLabel": "名稱",
"namePlaceholder": "my-skill-name",
"nameHint": "僅限小寫字母、數字和連字號1-64個字元",
"descriptionLabel": "說明",
"descriptionPlaceholder": "描述何時應使用此技能...",
"descriptionHint": "說明此技能的作用以及代理應何時套用它1-1024個字元",
"sourceLabel": "位置",
"sourceHint": "選擇此技能是全域可用還是僅在此專案中可用",
"modeLabel": "模式(選填)",
"modePlaceholder": "任何模式",
"modeHint": "將此技能限制為特定模式",
"modeAny": "任何模式",
"create": "建立",
"cancel": "取消"
},
"source": {
"global": "全域(在所有專案中可用)",
"project": "專案(僅此工作區)"
},
"validation": {
"nameRequired": "名稱為必填",
"nameTooLong": "名稱不得超過64個字元",
"nameInvalid": "名稱必須為1-64個小寫字母、數字或連字號",
"descriptionRequired": "說明為必填",
"descriptionTooLong": "說明不得超過1024個字元"
}
}
}