From 792f0b490267d0154c58b146176ad2cee54b5efb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 16 May 2026 17:33:43 +0200 Subject: [PATCH] feat: add personal skill resolver (#25362) > 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 --- agent/agentcontextconfig/api.go | 9 +- coderd/x/chatd/chattool/skill.go | 2 +- coderd/x/skills/doc.go | 52 ++++ coderd/x/skills/skills.go | 219 +++++++++++++++ coderd/x/skills/skills_test.go | 326 ++++++++++++++++++++++ codersdk/workspacesdk/frontmatter.go | 17 +- codersdk/workspacesdk/frontmatter_test.go | 1 + 7 files changed, 615 insertions(+), 11 deletions(-) create mode 100644 coderd/x/skills/doc.go create mode 100644 coderd/x/skills/skills.go create mode 100644 coderd/x/skills/skills_test.go diff --git a/agent/agentcontextconfig/api.go b/agent/agentcontextconfig/api.go index c29a26b73e..e7036de2f3 100644 --- a/agent/agentcontextconfig/api.go +++ b/agent/agentcontextconfig/api.go @@ -28,7 +28,7 @@ const ( const ( maxInstructionFileBytes = 64 * 1024 - maxSkillMetaBytes = 64 * 1024 + maxSkillMetaBytes = workspacesdk.MaxSkillMetaBytes ) // markdownCommentPattern strips HTML comments from instruction @@ -53,11 +53,6 @@ var invisibleRunePattern = regexp.MustCompile( "\ufff0-\ufff8]", ) -// skillNamePattern validates kebab-case skill names. -var skillNamePattern = regexp.MustCompile( - `^[a-z0-9]+(-[a-z0-9]+)*$`, -) - // Default values for agent-internal configuration. These are // used when the corresponding env vars are unset. // @@ -357,7 +352,7 @@ func discoverSkills(skillsDirs []string, metaFile string) []codersdk.ChatMessage if name != entry.Name() { continue } - if !skillNamePattern.MatchString(name) { + if !workspacesdk.SkillNamePattern.MatchString(name) { continue } diff --git a/coderd/x/chatd/chattool/skill.go b/coderd/x/chatd/chattool/skill.go index 2282ec924b..bafdbf6415 100644 --- a/coderd/x/chatd/chattool/skill.go +++ b/coderd/x/chatd/chattool/skill.go @@ -15,7 +15,7 @@ import ( ) const ( - maxSkillMetaBytes = 64 * 1024 + maxSkillMetaBytes = workspacesdk.MaxSkillMetaBytes maxSkillFileBytes = 512 * 1024 ) diff --git a/coderd/x/skills/doc.go b/coderd/x/skills/doc.go new file mode 100644 index 0000000000..896c18ff73 --- /dev/null +++ b/coderd/x/skills/doc.go @@ -0,0 +1,52 @@ +// Package skills defines the shared model for personal and workspace skills +// used by chatd. +// +// Glossary: +// +// - Personal skill: A user-owned skill that follows the user across Coder +// chats and workspaces, stored by Coder rather than discovered from a +// workspace filesystem. +// - Workspace skill: A skill discovered from the workspace filesystem, +// currently under .agents/skills by default. +// - Skill source: The origin of a skill available to chatd, such as personal +// storage or workspace filesystem discovery. +// - Skill alias: A chat or tool lookup name for a skill. Bare aliases use the +// skill name. Qualified aliases use personal/ or workspace/. +// +// Decision: +// +// Personal skills are stored by Coder. For each chat turn, chatd fetches +// personal skill metadata fresh, combines it with workspace skill metadata, and +// injects the available skills into the existing skill prompt. +// When chatd needs skill content, it resolves personal skills through the +// read_skill flow instead of syncing files into workspace filesystems. +// +// If a personal skill and workspace skill share the same kebab-case name, both +// are exposed with qualified aliases: personal/ for the personal skill +// and workspace/ for the workspace skill. One source must not silently +// override the other. +// +// Site admins can read and modify personal skill content. Personal skills are +// user-authored instructions, not secret material. Audit records can include +// raw Markdown content diffs alongside the actor, target user, and relevant +// metadata. +// +// Personal skill edits affect the next chat turn. Old chat turns are not exact +// snapshots of the personal skill state that existed when they ran. +// +// The v1 design does not include CLI support, web UI support, supporting files, +// organization-scoped personal skills, syncing personal skills into workspace +// filesystems, or stable public API documentation. +// +// Consequences: +// +// Chatd can use personal and workspace skills through one prompt and one read +// path, while storage remains owned by Coder instead of individual workspace +// filesystems. Fresh metadata keeps skill changes responsive, but chat history +// is less reproducible because old turns do not capture an exact copy of +// personal skill content. +// +// Explicit qualified aliases make ambiguous names visible to users and tools. +// Admin access improves operability and abuse handling, but it creates a +// privacy trade-off that must remain clear in product and support expectations. +package skills diff --git a/coderd/x/skills/skills.go b/coderd/x/skills/skills.go new file mode 100644 index 0000000000..9bfe102ac7 --- /dev/null +++ b/coderd/x/skills/skills.go @@ -0,0 +1,219 @@ +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 + +// 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") + // 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, 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, + ) + } + 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 +} diff --git a/coderd/x/skills/skills_test.go b/coderd/x/skills/skills_test.go new file mode 100644 index 0000000000..1c27b09187 --- /dev/null +++ b/coderd/x/skills/skills_test.go @@ -0,0 +1,326 @@ +package skills_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/x/skills" +) + +func TestParsePersonalSkillMarkdown(t *testing.T) { + t.Parallel() + + t.Run("ValidWithDescription", func(t *testing.T) { + t.Parallel() + + content, err := skills.ParsePersonalSkillMarkdown([]byte( + "---\nname: my-skill\ndescription: Does a thing\n---\nUse this skill.\n", + )) + + require.NoError(t, err) + require.Equal(t, "my-skill", content.Name) + require.Equal(t, "Does a thing", content.Description) + require.Equal(t, skills.SourcePersonal, content.Source) + require.Equal(t, "Use this skill.", content.Body) + }) + + t.Run("ValidWithoutDescription", func(t *testing.T) { + t.Parallel() + + content, err := skills.ParsePersonalSkillMarkdown([]byte( + "---\nname: my-skill\n---\nUse this skill.\n", + )) + + require.NoError(t, err) + require.Equal(t, "my-skill", content.Name) + require.Empty(t, content.Description) + require.Equal(t, skills.SourcePersonal, content.Source) + require.Equal(t, "Use this skill.", content.Body) + }) + + t.Run("MissingOpeningDelimiter", func(t *testing.T) { + t.Parallel() + + _, err := skills.ParsePersonalSkillMarkdown([]byte("name: my-skill\n---\nBody.\n")) + + require.ErrorContains(t, err, "missing opening frontmatter delimiter") + }) + + t.Run("MissingClosingDelimiter", func(t *testing.T) { + t.Parallel() + + _, err := skills.ParsePersonalSkillMarkdown([]byte("---\nname: my-skill\nBody.\n")) + + require.ErrorContains(t, err, "missing closing frontmatter delimiter") + }) + + t.Run("MissingName", func(t *testing.T) { + t.Parallel() + + _, err := skills.ParsePersonalSkillMarkdown([]byte( + "---\ndescription: No name\n---\nBody.\n", + )) + + require.ErrorIs(t, err, skills.ErrInvalidSkillName) + require.ErrorContains(t, err, "frontmatter must contain a 'name' field") + }) + + t.Run("NonKebabCaseName", func(t *testing.T) { + t.Parallel() + + _, err := skills.ParsePersonalSkillMarkdown([]byte( + "---\nname: Not_Kebab\n---\nBody.\n", + )) + + require.ErrorIs(t, err, skills.ErrInvalidSkillName) + require.ErrorContains(t, err, "Not_Kebab") + }) + + t.Run("NameTooLong", func(t *testing.T) { + t.Parallel() + + _, err := skills.ParsePersonalSkillMarkdown([]byte(personalSkillMarkdownForTest( + strings.Repeat("a", skills.MaxPersonalSkillNameBytes+1), + "Too long", + "Body.", + ))) + + require.ErrorIs(t, err, skills.ErrInvalidSkillName) + require.ErrorContains(t, err, "maximum is 256 bytes") + }) + + t.Run("EmptyBody", func(t *testing.T) { + t.Parallel() + + _, err := skills.ParsePersonalSkillMarkdown([]byte( + "---\nname: my-skill\n---\n\n", + )) + + require.ErrorIs(t, err, skills.ErrSkillBodyRequired) + require.ErrorContains(t, err, "my-skill") + }) + + t.Run("OversizedContent", func(t *testing.T) { + t.Parallel() + + raw := []byte(strings.Repeat("a", skills.MaxPersonalSkillSizeBytes+1)) + _, err := skills.ParsePersonalSkillMarkdown(raw) + + require.ErrorIs(t, err, skills.ErrSkillTooLarge) + }) +} + +func personalSkillMarkdownForTest(name string, description string, body string) string { + return "---\nname: " + name + "\ndescription: " + description + "\n---\n\n" + body + "\n" +} + +func TestMergeSkills(t *testing.T) { + t.Parallel() + + t.Run("PersonalOnlyUsesBareAlias", func(t *testing.T) { + t.Parallel() + + resolved := skills.MergeSkills( + []skills.Skill{{Name: "my-skill", Description: "Mine"}}, + nil, + ) + + require.Equal(t, []skills.ResolvedSkill{{ + Skill: skills.Skill{ + Name: "my-skill", + Description: "Mine", + Source: skills.SourcePersonal, + }, + Alias: "my-skill", + }}, resolved) + }) + + t.Run("WorkspaceOnlyUsesBareAlias", func(t *testing.T) { + t.Parallel() + + resolved := skills.MergeSkills( + nil, + []skills.Skill{{Name: "my-skill", Description: "Workspace"}}, + ) + + require.Equal(t, []skills.ResolvedSkill{{ + Skill: skills.Skill{ + Name: "my-skill", + Description: "Workspace", + Source: skills.SourceWorkspace, + }, + Alias: "my-skill", + }}, resolved) + }) + + t.Run("NonCollidingSkillsUseBareAliases", func(t *testing.T) { + t.Parallel() + + resolved := skills.MergeSkills( + []skills.Skill{{Name: "personal-skill"}}, + []skills.Skill{{Name: "workspace-skill"}}, + ) + + require.Equal(t, []skills.ResolvedSkill{ + { + Skill: skills.Skill{ + Name: "personal-skill", + Source: skills.SourcePersonal, + }, + Alias: "personal-skill", + }, + { + Skill: skills.Skill{ + Name: "workspace-skill", + Source: skills.SourceWorkspace, + }, + Alias: "workspace-skill", + }, + }, resolved) + }) + + t.Run("CollidingSkillsUseQualifiedAliases", func(t *testing.T) { + t.Parallel() + + resolved := skills.MergeSkills( + []skills.Skill{{Name: "shared-skill", Description: "Mine"}}, + []skills.Skill{{Name: "shared-skill", Description: "Workspace"}}, + ) + + require.Equal(t, []skills.ResolvedSkill{ + { + Skill: skills.Skill{ + Name: "shared-skill", + Description: "Mine", + Source: skills.SourcePersonal, + }, + Alias: "personal/shared-skill", + }, + { + Skill: skills.Skill{ + Name: "shared-skill", + Description: "Workspace", + Source: skills.SourceWorkspace, + }, + Alias: "workspace/shared-skill", + }, + }, resolved) + + personal, err := skills.Lookup(resolved, "personal/shared-skill") + require.NoError(t, err) + require.Equal(t, skills.SourcePersonal, personal.Source) + require.Equal(t, "shared-skill", personal.Name) + + workspace, err := skills.Lookup(resolved, "workspace/shared-skill") + require.NoError(t, err) + require.Equal(t, skills.SourceWorkspace, workspace.Source) + require.Equal(t, "shared-skill", workspace.Name) + + _, err = skills.Lookup(resolved, "shared-skill") + require.ErrorIs(t, err, skills.ErrSkillAmbiguous) + require.ErrorContains(t, err, "personal/shared-skill") + require.ErrorContains(t, err, "workspace/shared-skill") + }) + + t.Run("DuplicatesWithinSourceKeepFirst", func(t *testing.T) { + t.Parallel() + + resolved := skills.MergeSkills( + []skills.Skill{ + {Name: "duplicate-skill", Description: "First"}, + {Name: "duplicate-skill", Description: "Second"}, + }, + []skills.Skill{ + {Name: "workspace-skill", Description: "Workspace"}, + {Name: "workspace-skill", Description: "Workspace duplicate"}, + }, + ) + + require.Equal(t, []skills.ResolvedSkill{ + { + Skill: skills.Skill{ + Name: "duplicate-skill", + Description: "First", + Source: skills.SourcePersonal, + }, + Alias: "duplicate-skill", + }, + { + Skill: skills.Skill{ + Name: "workspace-skill", + Description: "Workspace", + Source: skills.SourceWorkspace, + }, + Alias: "workspace-skill", + }, + }, resolved) + }) +} + +func TestLookup(t *testing.T) { + t.Parallel() + + t.Run("BareNameOnNonCollidingSkill", func(t *testing.T) { + t.Parallel() + + resolved := skills.MergeSkills( + []skills.Skill{{Name: "personal-skill"}}, + []skills.Skill{{Name: "workspace-skill"}}, + ) + + personal, err := skills.Lookup(resolved, "personal-skill") + require.NoError(t, err) + require.Equal(t, skills.SourcePersonal, personal.Source) + require.Equal(t, "personal-skill", personal.Name) + + workspace, err := skills.Lookup(resolved, "workspace-skill") + require.NoError(t, err) + require.Equal(t, skills.SourceWorkspace, workspace.Source) + require.Equal(t, "workspace-skill", workspace.Name) + }) + + t.Run("QualifiedAliasWorksWithoutCollision", func(t *testing.T) { + t.Parallel() + + resolved := skills.MergeSkills( + []skills.Skill{{Name: "personal-skill"}}, + []skills.Skill{{Name: "workspace-skill"}}, + ) + + personal, err := skills.Lookup(resolved, "personal/personal-skill") + require.NoError(t, err) + require.Equal(t, skills.SourcePersonal, personal.Source) + require.Equal(t, "personal-skill", personal.Name) + + workspace, err := skills.Lookup(resolved, "workspace/workspace-skill") + require.NoError(t, err) + require.Equal(t, skills.SourceWorkspace, workspace.Source) + require.Equal(t, "workspace-skill", workspace.Name) + }) + + t.Run("BareNameFallsBackToSingleQualifiedAliasMatch", func(t *testing.T) { + t.Parallel() + + resolved := []skills.ResolvedSkill{{ + Skill: skills.Skill{Name: "personal-skill", Source: skills.SourcePersonal}, + Alias: "personal/personal-skill", + }} + + personal, err := skills.Lookup(resolved, "personal-skill") + + require.NoError(t, err) + require.Equal(t, skills.SourcePersonal, personal.Source) + require.Equal(t, "personal-skill", personal.Name) + }) + + t.Run("UnknownLookupReturnsNotFound", func(t *testing.T) { + t.Parallel() + + _, err := skills.Lookup(nil, "missing-skill") + + require.ErrorIs(t, err, skills.ErrSkillNotFound) + require.ErrorContains(t, err, "missing-skill") + }) +} diff --git a/codersdk/workspacesdk/frontmatter.go b/codersdk/workspacesdk/frontmatter.go index 8895a34462..296b32159b 100644 --- a/codersdk/workspacesdk/frontmatter.go +++ b/codersdk/workspacesdk/frontmatter.go @@ -7,10 +7,23 @@ import ( "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(``) +// 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: @@ -65,9 +78,7 @@ func ParseSkillFrontmatter(content string) (name, description, body string, err } if name == "" { - return "", "", "", xerrors.New( - "frontmatter missing required 'name' field", - ) + return "", "", "", xerrors.Errorf("%w", ErrFrontmatterNameRequired) } // Everything after the closing delimiter is the body. diff --git a/codersdk/workspacesdk/frontmatter_test.go b/codersdk/workspacesdk/frontmatter_test.go index 0be4ec2046..d25077c4d2 100644 --- a/codersdk/workspacesdk/frontmatter_test.go +++ b/codersdk/workspacesdk/frontmatter_test.go @@ -117,6 +117,7 @@ func TestParseSkillFrontmatter(t *testing.T) { _, _, _, err := workspacesdk.ParseSkillFrontmatter( "---\ndescription: no name\n---\n", ) + require.ErrorIs(t, err, workspacesdk.ErrFrontmatterNameRequired) require.ErrorContains(t, err, "frontmatter missing required 'name' field") })