Files
coder/coderd/x/skills/skills.go
T
Michael Suchacz 5a8d0016a5 feat: add personal skill storage, API, and SDK (#25363)
> Mux updated this PR on behalf of Mike.

## Stack Context

This PR is the storage, permissions, API, and SDK layer for experimental
personal skills. #25362 has landed on `main`, so this branch is
restacked directly on `main`.

Stack order:
1. #25363 storage, permissions, API, and SDK
2. #25365 API test coverage
3. #25366 chattool and chatd integration
4. #25066 settings UI and docs
5. #25386 personal skills slash menu

## What?

Adds the `user_skills` database table, generated queries, RBAC resources
and scopes, audit resource handling, experimental user-scoped CRUD
endpoints, SDK types, and generated API/site types.

Follow-up review and restack fixes:
- Enforce a bounded personal skill description in parser and database
constraints.
- Return `403 Forbidden` for unauthorized create and update attempts.
- Return explicit conflict responses when soft-deleted users are
targeted.
- Keep user admins out of personal skills, while site owners can read
and delete but not create or update.
- Document trigger-raised constraint names and keep schema constants
covered by tests.
- Reuse `UserSkillMetadata` in the full `UserSkill` SDK response type.
- Generate user skill IDs in Go instead of relying on a database
default.
- Rebase on latest `main` and renumber the user skills migration to
`000502_user_skills`.

## Why?

Personal skills need durable user-owned storage with owner
authorization, limited site-owner moderation, and a hidden API surface
before chatd can consume them.

## Validation

- `make gen`
- `go test ./coderd/database -run '^TestUserSkillSchemaConstants$'
-count=1`
- `go test ./coderd/database/dbauthz -run
'^TestMethodTestSuite/TestUserSkills$' -count=1`
- `go test ./coderd -run '^TestPatchUserSkill$' -count=1`
- `go test ./codersdk ./coderd/database/db2sdk`
- `make lint`
- pre-commit hook on `97fd58108d`
2026-05-20 00:09:09 +02:00

240 lines
7.2 KiB
Go

package skills
import (
"maps"
"slices"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// MaxPersonalSkillSizeBytes is the maximum raw Markdown size accepted for a
// personal skill upload.
const MaxPersonalSkillSizeBytes = workspacesdk.MaxSkillMetaBytes
// MaxPersonalSkillNameBytes is the maximum skill name length accepted for a
// personal skill upload. Skill names are also used in URL paths.
const MaxPersonalSkillNameBytes = 256
// MaxPersonalSkillDescriptionBytes is the maximum frontmatter description size
// accepted for a personal skill upload.
const MaxPersonalSkillDescriptionBytes = 4096
// MaxPersonalSkillsPerUser is the maximum number of personal skills a user may
// create.
const MaxPersonalSkillsPerUser = 100
// Source identifies where a skill came from.
type Source string
const (
// SourcePersonal identifies a user-owned, DB-backed skill.
SourcePersonal Source = "personal"
// SourceWorkspace identifies a filesystem-discovered workspace skill.
SourceWorkspace Source = "workspace"
)
var (
// ErrInvalidSkillName indicates that a skill name is missing, not valid
// kebab-case, or exceeds the maximum length.
ErrInvalidSkillName = xerrors.New("invalid skill name")
// ErrSkillBodyRequired indicates that the skill has no body after frontmatter.
ErrSkillBodyRequired = xerrors.New("skill body is required")
// ErrSkillTooLarge indicates that the raw skill Markdown is too large.
ErrSkillTooLarge = xerrors.New("skill is too large")
// ErrSkillDescriptionTooLarge indicates that the description is too large.
ErrSkillDescriptionTooLarge = xerrors.New("skill description is too large")
// ErrSkillNotFound indicates that a skill lookup did not match any alias.
ErrSkillNotFound = xerrors.New("skill not found")
// ErrSkillAmbiguous indicates that a skill lookup matched multiple sources.
ErrSkillAmbiguous = xerrors.New("skill lookup is ambiguous")
)
// Skill is the source-aware metadata needed to list and resolve a skill.
type Skill struct {
Name string
Description string
Source Source
}
// ParsedSkill is a parsed skill with the Markdown body after frontmatter.
// Body has HTML comments stripped and surrounding whitespace trimmed.
type ParsedSkill struct {
Skill
Body string
}
// ResolvedSkill is a skill with the alias exposed to chat tools.
type ResolvedSkill struct {
Skill
Alias string
}
// ParsePersonalSkillMarkdown parses raw personal skill Markdown and enforces
// the personal skill contract. The raw size must not exceed
// MaxPersonalSkillSizeBytes, frontmatter must contain a valid kebab-case name,
// the skill name must not exceed MaxPersonalSkillNameBytes, the description must
// not exceed MaxPersonalSkillDescriptionBytes, and the body after frontmatter
// must be non-empty.
func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
if len(raw) > MaxPersonalSkillSizeBytes {
return ParsedSkill{}, xerrors.Errorf(
"%w: got %d bytes, maximum is %d bytes",
ErrSkillTooLarge,
len(raw),
MaxPersonalSkillSizeBytes,
)
}
name, description, body, err := workspacesdk.ParseSkillFrontmatter(string(raw))
if err != nil {
if xerrors.Is(err, workspacesdk.ErrFrontmatterNameRequired) {
return ParsedSkill{}, xerrors.Errorf("%w: frontmatter must contain a 'name' field", ErrInvalidSkillName)
}
return ParsedSkill{}, xerrors.Errorf("parse skill frontmatter: %w", err)
}
if !workspacesdk.SkillNamePattern.MatchString(name) {
return ParsedSkill{}, xerrors.Errorf(
"%w: %q must match %s",
ErrInvalidSkillName,
name,
workspacesdk.SkillNameRegex,
)
}
nameBytes := len(name)
if nameBytes > MaxPersonalSkillNameBytes {
return ParsedSkill{}, xerrors.Errorf(
"%w: %q is %d bytes, maximum is %d bytes",
ErrInvalidSkillName,
name,
nameBytes,
MaxPersonalSkillNameBytes,
)
}
descriptionBytes := len(description)
if descriptionBytes > MaxPersonalSkillDescriptionBytes {
return ParsedSkill{}, xerrors.Errorf(
"%w: got %d bytes, maximum is %d bytes",
ErrSkillDescriptionTooLarge,
descriptionBytes,
MaxPersonalSkillDescriptionBytes,
)
}
if strings.TrimSpace(body) == "" {
return ParsedSkill{}, xerrors.Errorf(
"%w: skill %q has no content after frontmatter",
ErrSkillBodyRequired,
name,
)
}
return ParsedSkill{
Skill: Skill{
Name: name,
Description: description,
Source: SourcePersonal,
},
Body: body,
}, nil
}
// MergeSkills combines personal and workspace skills into a deterministic list
// with aliases for chat tool display and lookup. Skill names must already be
// valid kebab-case names because qualified aliases use / as a separator. If a
// source contains duplicate names, the first skill for that source wins.
func MergeSkills(personalSkills, workspaceSkills []Skill) []ResolvedSkill {
personalByName := skillsByName(personalSkills, SourcePersonal)
workspaceByName := skillsByName(workspaceSkills, SourceWorkspace)
names := make(map[string]struct{}, len(personalByName)+len(workspaceByName))
for name := range personalByName {
names[name] = struct{}{}
}
for name := range workspaceByName {
names[name] = struct{}{}
}
resolved := make([]ResolvedSkill, 0, len(personalByName)+len(workspaceByName))
for _, name := range slices.Sorted(maps.Keys(names)) {
personal, hasPersonal := personalByName[name]
workspace, hasWorkspace := workspaceByName[name]
if hasPersonal && hasWorkspace {
resolved = append(resolved,
ResolvedSkill{
Skill: personal,
Alias: QualifiedAlias(SourcePersonal, name),
},
ResolvedSkill{
Skill: workspace,
Alias: QualifiedAlias(SourceWorkspace, name),
},
)
continue
}
if hasPersonal {
resolved = append(resolved, ResolvedSkill{
Skill: personal,
Alias: name,
})
continue
}
resolved = append(resolved, ResolvedSkill{
Skill: workspace,
Alias: name,
})
}
return resolved
}
// Lookup finds a resolved skill by bare alias or qualified source alias. It
// returns ErrSkillNotFound if no alias matches, or ErrSkillAmbiguous if a bare
// name matches skills from multiple sources.
func Lookup(resolved []ResolvedSkill, lookup string) (ResolvedSkill, error) {
var (
bareNameMatch ResolvedSkill
matches []string
)
for _, skill := range resolved {
qualifiedAlias := QualifiedAlias(skill.Source, skill.Name)
if lookup == skill.Alias || lookup == qualifiedAlias {
return skill, nil
}
if lookup == skill.Name {
bareNameMatch = skill
matches = append(matches, qualifiedAlias)
}
}
switch len(matches) {
case 0:
return ResolvedSkill{}, xerrors.Errorf("%w: %q", ErrSkillNotFound, lookup)
case 1:
return bareNameMatch, nil
default:
return ResolvedSkill{}, xerrors.Errorf(
"%w: %q matches %s",
ErrSkillAmbiguous,
lookup,
strings.Join(matches, ", "),
)
}
}
// QualifiedAlias returns the stable source-qualified alias for a skill name.
func QualifiedAlias(source Source, name string) string {
return string(source) + "/" + name
}
func skillsByName(skills []Skill, source Source) map[string]Skill {
byName := make(map[string]Skill, len(skills))
for _, skill := range skills {
if _, ok := byName[skill.Name]; ok {
continue
}
skill.Source = source
byName[skill.Name] = skill
}
return byName
}