mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
> Mux updated this PR on behalf of Mike. ## Stack Context This stack splits experimental personal skills into smaller reviewable PRs. Personal skills are user-owned `SKILL.md` files stored by Coder and injected into chatd alongside workspace skills. Stack order: 1. #25362 personal skill resolver 2. #25363 storage, permissions, API, and SDK 3. #25365 API test coverage 4. #25366 chattool and chatd integration 5. #25066 settings UI and docs 6. #25386 personal skills slash menu ## What? Adds the shared personal skill parser and resolver package, plus reusable skill-name validation exported from `workspacesdk`. The parser enforces the full personal skill contract: max raw size, kebab-case name, max name length, and non-empty body. ## Why? The rest of the stack needs one source-aware resolver for personal and workspace skills, including collision handling and qualified aliases. Keeping personal skill constraints in the parser prevents callers from accidentally parsing invalid personal skills. ## Validation - `go test ./coderd/x/skills ./codersdk/workspacesdk` - pre-commit hooks on this branch
91 lines
2.5 KiB
Go
91 lines
2.5 KiB
Go
package workspacesdk
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
// SkillNameRegex is the regular expression used to validate kebab-case skill names.
|
|
const SkillNameRegex = "^[a-z0-9]+(-[a-z0-9]+)*$"
|
|
|
|
// MaxSkillMetaBytes is the maximum raw Markdown size accepted for a skill meta file.
|
|
const MaxSkillMetaBytes = 64 * 1024
|
|
|
|
// SkillNamePattern is the compiled pattern used to validate kebab-case skill names.
|
|
var SkillNamePattern = regexp.MustCompile(SkillNameRegex)
|
|
|
|
// markdownCommentRe strips HTML comments from skill file bodies so
|
|
// they don't leak into the LLM prompt.
|
|
var markdownCommentRe = regexp.MustCompile(`<!--[\s\S]*?-->`)
|
|
|
|
// ErrFrontmatterNameRequired is returned by ParseSkillFrontmatter when
|
|
// the frontmatter is missing a required name field.
|
|
var ErrFrontmatterNameRequired = xerrors.New("frontmatter missing required 'name' field")
|
|
|
|
// ParseSkillFrontmatter extracts name, description, and the
|
|
// remaining body from a skill meta file. The expected format is
|
|
// YAML-ish frontmatter delimited by "---" lines:
|
|
//
|
|
// ---
|
|
// name: my-skill
|
|
// description: Does a thing
|
|
// ---
|
|
// Body text here...
|
|
func ParseSkillFrontmatter(content string) (name, description, body string, err error) {
|
|
content = strings.TrimPrefix(content, "\xef\xbb\xbf")
|
|
lines := strings.Split(content, "\n")
|
|
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
|
return "", "", "", xerrors.New(
|
|
"missing opening frontmatter delimiter",
|
|
)
|
|
}
|
|
|
|
closingIdx := -1
|
|
for i := 1; i < len(lines); i++ {
|
|
if strings.TrimSpace(lines[i]) == "---" {
|
|
closingIdx = i
|
|
break
|
|
}
|
|
}
|
|
if closingIdx < 0 {
|
|
return "", "", "", xerrors.New(
|
|
"missing closing frontmatter delimiter",
|
|
)
|
|
}
|
|
|
|
for _, line := range lines[1:closingIdx] {
|
|
key, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
value = strings.TrimSpace(value)
|
|
// Strip surrounding quotes from YAML string values.
|
|
if len(value) >= 2 {
|
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
switch strings.ToLower(key) {
|
|
case "name":
|
|
name = value
|
|
case "description":
|
|
description = value
|
|
}
|
|
}
|
|
|
|
if name == "" {
|
|
return "", "", "", xerrors.Errorf("%w", ErrFrontmatterNameRequired)
|
|
}
|
|
|
|
// Everything after the closing delimiter is the body.
|
|
body = strings.Join(lines[closingIdx+1:], "\n")
|
|
body = markdownCommentRe.ReplaceAllString(body, "")
|
|
body = strings.TrimSpace(body)
|
|
|
|
return name, description, body, nil
|
|
}
|