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

Commit 1a2cae6

Browse files
daniel-lxshannesrudolph
authored andcommitted
refactor: improve code quality and add i18n for skills feature
- Extract shared skill name validation to packages/types/src/skills.ts (DRY) - Replace hardcoded modes with getAllModes() from @roo/modes - Remove race condition setTimeout calls (backend already sends updated skills) - Consolidate duplicate getAllSkills/getSkillsMetadata methods - Remove redundant type assertion in SkillsSettings - Remove stale 'Phase 4' comments - Remove unnecessary ExtendedExtensionState interface workaround - Add i18n support for all skills error messages (17 locales) - Add 21 tests for shared validation function
1 parent e044de2 commit 1a2cae6

27 files changed

Lines changed: 593 additions & 112 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {
2+
validateSkillName,
3+
SkillNameValidationError,
4+
SKILL_NAME_MIN_LENGTH,
5+
SKILL_NAME_MAX_LENGTH,
6+
SKILL_NAME_REGEX,
7+
} from "../skills.js"
8+
9+
describe("validateSkillName", () => {
10+
describe("valid names", () => {
11+
it("accepts single lowercase word", () => {
12+
expect(validateSkillName("myskill")).toEqual({ valid: true })
13+
})
14+
15+
it("accepts lowercase letters and numbers", () => {
16+
expect(validateSkillName("skill123")).toEqual({ valid: true })
17+
})
18+
19+
it("accepts hyphenated words", () => {
20+
expect(validateSkillName("my-skill")).toEqual({ valid: true })
21+
})
22+
23+
it("accepts multiple hyphenated words", () => {
24+
expect(validateSkillName("my-awesome-skill")).toEqual({ valid: true })
25+
})
26+
27+
it("accepts single character", () => {
28+
expect(validateSkillName("a")).toEqual({ valid: true })
29+
})
30+
31+
it("accepts single digit", () => {
32+
expect(validateSkillName("1")).toEqual({ valid: true })
33+
})
34+
35+
it("accepts maximum length name (64 characters)", () => {
36+
const maxLengthName = "a".repeat(SKILL_NAME_MAX_LENGTH)
37+
expect(validateSkillName(maxLengthName)).toEqual({ valid: true })
38+
})
39+
})
40+
41+
describe("empty or missing names", () => {
42+
it("rejects empty string", () => {
43+
expect(validateSkillName("")).toEqual({
44+
valid: false,
45+
error: SkillNameValidationError.Empty,
46+
})
47+
})
48+
})
49+
50+
describe("names that are too long", () => {
51+
it("rejects names longer than 64 characters", () => {
52+
const tooLongName = "a".repeat(SKILL_NAME_MAX_LENGTH + 1)
53+
expect(validateSkillName(tooLongName)).toEqual({
54+
valid: false,
55+
error: SkillNameValidationError.TooLong,
56+
})
57+
})
58+
})
59+
60+
describe("invalid format", () => {
61+
it("rejects uppercase letters", () => {
62+
expect(validateSkillName("MySkill")).toEqual({
63+
valid: false,
64+
error: SkillNameValidationError.InvalidFormat,
65+
})
66+
})
67+
68+
it("rejects leading hyphen", () => {
69+
expect(validateSkillName("-myskill")).toEqual({
70+
valid: false,
71+
error: SkillNameValidationError.InvalidFormat,
72+
})
73+
})
74+
75+
it("rejects trailing hyphen", () => {
76+
expect(validateSkillName("myskill-")).toEqual({
77+
valid: false,
78+
error: SkillNameValidationError.InvalidFormat,
79+
})
80+
})
81+
82+
it("rejects consecutive hyphens", () => {
83+
expect(validateSkillName("my--skill")).toEqual({
84+
valid: false,
85+
error: SkillNameValidationError.InvalidFormat,
86+
})
87+
})
88+
89+
it("rejects spaces", () => {
90+
expect(validateSkillName("my skill")).toEqual({
91+
valid: false,
92+
error: SkillNameValidationError.InvalidFormat,
93+
})
94+
})
95+
96+
it("rejects underscores", () => {
97+
expect(validateSkillName("my_skill")).toEqual({
98+
valid: false,
99+
error: SkillNameValidationError.InvalidFormat,
100+
})
101+
})
102+
103+
it("rejects special characters", () => {
104+
expect(validateSkillName("my@skill")).toEqual({
105+
valid: false,
106+
error: SkillNameValidationError.InvalidFormat,
107+
})
108+
})
109+
110+
it("rejects dots", () => {
111+
expect(validateSkillName("my.skill")).toEqual({
112+
valid: false,
113+
error: SkillNameValidationError.InvalidFormat,
114+
})
115+
})
116+
})
117+
})
118+
119+
describe("SKILL_NAME_REGEX", () => {
120+
it("matches valid names", () => {
121+
expect(SKILL_NAME_REGEX.test("myskill")).toBe(true)
122+
expect(SKILL_NAME_REGEX.test("my-skill")).toBe(true)
123+
expect(SKILL_NAME_REGEX.test("skill123")).toBe(true)
124+
expect(SKILL_NAME_REGEX.test("a1-b2-c3")).toBe(true)
125+
})
126+
127+
it("does not match invalid names", () => {
128+
expect(SKILL_NAME_REGEX.test("-start")).toBe(false)
129+
expect(SKILL_NAME_REGEX.test("end-")).toBe(false)
130+
expect(SKILL_NAME_REGEX.test("double--hyphen")).toBe(false)
131+
expect(SKILL_NAME_REGEX.test("UPPER")).toBe(false)
132+
expect(SKILL_NAME_REGEX.test("")).toBe(false)
133+
})
134+
})
135+
136+
describe("constants", () => {
137+
it("has correct min length", () => {
138+
expect(SKILL_NAME_MIN_LENGTH).toBe(1)
139+
})
140+
141+
it("has correct max length", () => {
142+
expect(SKILL_NAME_MAX_LENGTH).toBe(64)
143+
})
144+
})

packages/types/src/skills.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,63 @@ export interface SkillMetadata {
99
source: "global" | "project" // Where the skill was discovered
1010
mode?: string // If set, skill is only available in this mode
1111
}
12+
13+
/**
14+
* Skill name validation constants per agentskills.io specification:
15+
* https://agentskills.io/specification
16+
*
17+
* Name constraints:
18+
* - 1-64 characters
19+
* - Lowercase letters, numbers, and hyphens only
20+
* - Must not start or end with a hyphen
21+
* - Must not contain consecutive hyphens
22+
*/
23+
export const SKILL_NAME_MIN_LENGTH = 1
24+
export const SKILL_NAME_MAX_LENGTH = 64
25+
26+
/**
27+
* Regex pattern for valid skill names.
28+
* Matches: lowercase letters/numbers, optionally followed by groups of hyphen + lowercase letters/numbers.
29+
* This ensures no leading/trailing hyphens and no consecutive hyphens.
30+
*/
31+
export const SKILL_NAME_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
32+
33+
/**
34+
* Error codes for skill name validation.
35+
* These can be mapped to translation keys in the frontend or error messages in the backend.
36+
*/
37+
export enum SkillNameValidationError {
38+
Empty = "empty",
39+
TooLong = "too_long",
40+
InvalidFormat = "invalid_format",
41+
}
42+
43+
/**
44+
* Result of skill name validation.
45+
*/
46+
export interface SkillNameValidationResult {
47+
valid: boolean
48+
error?: SkillNameValidationError
49+
}
50+
51+
/**
52+
* Validate a skill name according to agentskills.io specification.
53+
*
54+
* @param name - The skill name to validate
55+
* @returns Validation result with error code if invalid
56+
*/
57+
export function validateSkillName(name: string): SkillNameValidationResult {
58+
if (!name || name.length < SKILL_NAME_MIN_LENGTH) {
59+
return { valid: false, error: SkillNameValidationError.Empty }
60+
}
61+
62+
if (name.length > SKILL_NAME_MAX_LENGTH) {
63+
return { valid: false, error: SkillNameValidationError.TooLong }
64+
}
65+
66+
if (!SKILL_NAME_REGEX.test(name)) {
67+
return { valid: false, error: SkillNameValidationError.InvalidFormat }
68+
}
69+
70+
return { valid: true }
71+
}

src/core/webview/__tests__/skillsMessageHandler.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ vi.mock("../../../integrations/misc/open-file", () => ({
1919
openFile: vi.fn(),
2020
}))
2121

22+
// Mock i18n
23+
vi.mock("../../../i18n", () => ({
24+
t: (key: string, params?: Record<string, any>) => {
25+
const translations: Record<string, string> = {
26+
"skills:errors.missing_create_fields": "Missing required fields: skillName, source, or skillDescription",
27+
"skills:errors.manager_unavailable": "Skills manager not available",
28+
"skills:errors.missing_delete_fields": "Missing required fields: skillName or source",
29+
"skills:errors.skill_not_found": `Skill "${params?.name}" not found`,
30+
}
31+
return translations[key] || key
32+
},
33+
}))
34+
2235
import * as vscode from "vscode"
2336
import { openFile } from "../../../integrations/misc/open-file"
2437
import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "../skillsMessageHandler"

src/core/webview/skillsMessageHandler.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { SkillMetadata, WebviewMessage } from "@roo-code/types"
44

55
import type { ClineProvider } from "./ClineProvider"
66
import { openFile } from "../../integrations/misc/open-file"
7+
import { t } from "../../i18n"
78

89
/**
910
* Handles the requestSkills message - returns all skills metadata
@@ -40,12 +41,12 @@ export async function handleCreateSkill(
4041
const skillMode = message.skillMode
4142

4243
if (!skillName || !source || !skillDescription) {
43-
throw new Error("Missing required fields: skillName, source, or skillDescription")
44+
throw new Error(t("skills:errors.missing_create_fields"))
4445
}
4546

4647
const skillsManager = provider.getSkillsManager()
4748
if (!skillsManager) {
48-
throw new Error("Skills manager not available")
49+
throw new Error(t("skills:errors.manager_unavailable"))
4950
}
5051

5152
const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, skillMode)
@@ -78,12 +79,12 @@ export async function handleDeleteSkill(
7879
const skillMode = message.skillMode
7980

8081
if (!skillName || !source) {
81-
throw new Error("Missing required fields: skillName or source")
82+
throw new Error(t("skills:errors.missing_delete_fields"))
8283
}
8384

8485
const skillsManager = provider.getSkillsManager()
8586
if (!skillsManager) {
86-
throw new Error("Skills manager not available")
87+
throw new Error(t("skills:errors.manager_unavailable"))
8788
}
8889

8990
await skillsManager.deleteSkill(skillName, source, skillMode)
@@ -110,17 +111,17 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv
110111
const skillMode = message.skillMode
111112

112113
if (!skillName || !source) {
113-
throw new Error("Missing required fields: skillName or source")
114+
throw new Error(t("skills:errors.missing_delete_fields"))
114115
}
115116

116117
const skillsManager = provider.getSkillsManager()
117118
if (!skillsManager) {
118-
throw new Error("Skills manager not available")
119+
throw new Error(t("skills:errors.manager_unavailable"))
119120
}
120121

121122
const skill = skillsManager.getSkill(skillName, source, skillMode)
122123
if (!skill) {
123-
throw new Error(`Skill "${skillName}" not found`)
124+
throw new Error(t("skills:errors.skill_not_found", { name: skillName }))
124125
}
125126

126127
openFile(skill.path)

src/i18n/locales/ca/skills.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/de/skills.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/en/skills.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"errors": {
3+
"name_length": "Skill name must be 1-{{maxLength}} characters (got {{length}})",
4+
"name_format": "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)",
5+
"description_length": "Skill description must be 1-1024 characters (got {{length}})",
6+
"no_workspace": "Cannot create project skill: no workspace folder is open",
7+
"already_exists": "Skill \"{{name}}\" already exists at {{path}}",
8+
"not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}",
9+
"missing_create_fields": "Missing required fields: skillName, source, or skillDescription",
10+
"manager_unavailable": "Skills manager not available",
11+
"missing_delete_fields": "Missing required fields: skillName or source",
12+
"skill_not_found": "Skill \"{{name}}\" not found"
13+
}
14+
}

src/i18n/locales/es/skills.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/fr/skills.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/hi/skills.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)