Skip to content

Commit 010aba2

Browse files
SannidhyaSahSannidhyadaniel-lxsroomote
authored
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>
1 parent c983e26 commit 010aba2

56 files changed

Lines changed: 3879 additions & 44 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from "./message.js"
1919
export * from "./mode.js"
2020
export * from "./model.js"
2121
export * from "./provider-settings.js"
22+
export * from "./skills.js"
2223
export * from "./task.js"
2324
export * from "./todo.js"
2425
export * from "./telemetry.js"

packages/types/src/skills.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Skill metadata for discovery (loaded at startup)
3+
* Only name and description are required for now
4+
*/
5+
export interface SkillMetadata {
6+
name: string // Required: skill identifier
7+
description: string // Required: when to use this skill
8+
path: string // Absolute path to SKILL.md
9+
source: "global" | "project" // Where the skill was discovered
10+
mode?: string // If set, skill is only available in this mode
11+
}
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+
}

packages/types/src/vscode-extension-host.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList,
1818
import type { SerializedCustomToolDefinition } from "./custom-tool.js"
1919
import type { GitCommit } from "./git.js"
2020
import type { McpServer } from "./mcp.js"
21+
import type { SkillMetadata } from "./skills.js"
2122
import type { ModelRecord, RouterModels } from "./model.js"
2223
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
2324
import type { WorktreeIncludeStatus } from "./worktree.js"
@@ -108,6 +109,7 @@ export interface ExtensionMessage {
108109
| "worktreeIncludeStatus"
109110
| "branchWorktreeIncludeResult"
110111
| "folderSelected"
112+
| "skills"
111113
text?: string
112114
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
113115
checkpointWarning?: {
@@ -202,6 +204,7 @@ export interface ExtensionMessage {
202204
stepIndex?: number // For browserSessionNavigate: the target step index to display
203205
tools?: SerializedCustomToolDefinition[] // For customToolsResult
204206
modes?: { slug: string; name: string }[] // For modes response
207+
skills?: SkillMetadata[] // For skills response
205208
aggregatedCosts?: {
206209
// For taskWithAggregatedCosts response
207210
totalCost: number
@@ -602,6 +605,11 @@ export interface WebviewMessage {
602605
| "createWorktreeInclude"
603606
| "checkoutBranch"
604607
| "browseForWorktreePath"
608+
// Skills messages
609+
| "requestSkills"
610+
| "createSkill"
611+
| "deleteSkill"
612+
| "openSkillFile"
605613
text?: string
606614
editedMessageContent?: string
607615
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@@ -636,6 +644,9 @@ export interface WebviewMessage {
636644
timeout?: number
637645
payload?: WebViewMessagePayload
638646
source?: "global" | "project"
647+
skillName?: string // For skill operations (createSkill, deleteSkill, openSkillFile)
648+
skillMode?: string // For skill operations (mode restriction)
649+
skillDescription?: string // For createSkill (skill description)
639650
requestId?: string
640651
ids?: string[]
641652
hasSystemPromptOverride?: boolean

0 commit comments

Comments
 (0)