Compare commits

...

19 Commits

Author SHA1 Message Date
DevCats
17e195e4a1 fix: use named results in separateSkillsFrontmatter for revive lint 2026-05-20 03:29:59 +00:00
DevCats
bc92a24b18 feat: validate skills README frontmatter
Add a validator for registry/<namespace>/skills/README.md that checks
top-level keys, the sources[].repo format, per-skill override shape,
icon path prefix (3-deep for skills, distinct from modules/templates
which are 4-deep), and tag URL-safety. Closes the readmevalidation
gap flagged in PR review.
2026-05-19 22:34:07 +00:00
DevCats
d62b91f2f5 fix: remove skills handling from version-bump and release workflows
Skills are not versioned in the registry. Their versioning happens in
the source repos. The version-bump workflow only handles modules, and
the release.yml skill-path detection was based on the old per-skill
directory format that no longer exists.
2026-05-19 21:52:04 +00:00
DevCats
ec76fb7116
Merge branch 'main' into cat/agent-skills-catalogue 2026-05-19 16:32:38 -05:00
DevelopmentCats
ff47a35db9 fix: format skills README table for prettier 2026-05-15 14:33:13 -05:00
DevCats
59b9569e88 ci: retrigger checks 2026-05-15 19:28:53 +00:00
DevCats
acee7b881a fix: format markdown table for prettier 2026-05-15 19:15:13 +00:00
DevCats
fbda582f7c
Merge branch 'main' into cat/agent-skills-catalogue 2026-05-15 14:11:12 -05:00
DevCats
5e703ff70f
Apply suggestion from @DevelopmentCats 2026-05-15 14:10:41 -05:00
DevCats
0029cd2078 fix: remove install section and list available skills in README
The install instructions duplicate what the registry UI's dropdown
already provides. Replace with a table listing the available skills.
2026-05-15 19:10:05 +00:00
DevCats
f2895a40f4
Apply suggestion from @DevelopmentCats 2026-05-15 14:08:30 -05:00
DevCats
61edb408ca chore: remove DevelopmentCats test namespace 2026-05-14 16:03:05 +00:00
DevCats
b4a041ab48 test: use distinct icons for DevCats skills 2026-05-12 18:31:39 +00:00
DevCats
774f2c24e6 refactor: per-skill metadata in skills README frontmatter
Sources now use structured YAML objects instead of flat strings.
Each skill can have its own display_name, icon, and tags.
Top-level icon serves as the default for skills without overrides.
Removed top-level display_name, description, and tags (per-skill only).
2026-05-12 18:17:44 +00:00
DevCats
45d19b2ddd test: add DevelopmentCats namespace with skills from external repo 2026-05-12 17:35:22 +00:00
DevCats
0e41e0e750 fix: use flat source format compatible with frontmatter parser
The registry's separateFrontmatter() function trims indentation from
each frontmatter line, which breaks nested YAML structures. Use the
flat "owner/repo@ref" string format that the registry-server parses
with strings.Cut.
2026-05-12 15:29:29 +00:00
DevCats
d8add4d3c1 fix: add skills to allowed namespace subdirectories
The README validation tool has an allowlist of directories permitted
under registry/<namespace>/. Add 'skills' to that list so the
skills/README.md catalogue entry passes validation.

Skills are added to supportedUserNameSpaceDirectories but not to
supportedResourceTypes, so the skills directory is allowed to exist
without being validated as a module or template.
2026-05-11 21:14:02 +00:00
DevCats
42263af7a0 refactor: replace per-skill catalogue entries with namespace-level source references
Skills content now lives in source repos (e.g., coder/skills) instead of
being duplicated in the registry. Each namespace declares its skill source
repo(s) in a single registry/<namespace>/skills/README.md with a sources
field in the frontmatter.

Changes:
- Replace per-skill directories (coder-modules/, coder-templates/) with
  a single registry/coder/skills/README.md that references coder/skills
- Revert .agents/skills/ frontmatter changes (those files will move to
  coder/skills repo)
- Revert version-bump.sh and tag_release.sh skill-specific version
  extraction (versioning lives in source repos now)
- Keep CI/CD path triggers for registry/**/skills/** so catalogue
  metadata changes trigger deployment

The registry-server build pipeline (PR #442) will need to be updated
to clone source repos declared in the README.md frontmatter and
auto-discover skills from them.
2026-05-11 19:46:48 +00:00
DevCats
1282d3c3c6 cat: add skills as first-class catalogue items
Add agent skills to the registry catalogue alongside modules and
templates, following the agentskills.io specification with proper
frontmatter metadata (license, version, author, tags).

- Add registry/coder/skills/coder-modules/ and
  registry/coder/skills/coder-templates/ as catalogue entries,
  each with SKILL.md (full spec frontmatter + body) and README.md.
- Update .agents/skills/ frontmatter to match catalogue versions.
- Update CI/CD workflows and scripts to support skills:
  - deploy-registry.yaml: add skills path trigger
  - release.yml: detect skills vs modules for changelog path
  - version-bump.yaml: add skills path trigger
  - version-bump.sh: include skills in change detection
  - tag_release.sh: scan skills directories and extract version
    from SKILL.md frontmatter
2026-05-04 20:25:29 +00:00
6 changed files with 372 additions and 2 deletions

View File

@ -9,11 +9,12 @@ on:
# Matches release/<namespace>/<resource_name>/<semantic_version>
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
- "release/*/*/v*.*.*"
branches: # Templates get released when merged to main
branches: # Templates and skills get released when merged to main
- main
paths:
- ".github/workflows/deploy-registry.yaml"
- "registry/**/templates/**"
- "registry/**/skills/**"
- "registry/**/README.md"
- ".icons/**"

View File

@ -33,6 +33,7 @@ jobs:
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
echo "module=$MODULE" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"

View File

@ -0,0 +1,339 @@
package main
import (
"bufio"
"context"
"errors"
"os"
"path"
"regexp"
"slices"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// skillsRepoSpecRe matches the "owner/repo" or "owner/repo@ref" format used
// in the skills README sources frontmatter. Owners and repo names allow
// alphanumerics, hyphens, underscores, and dots. Refs allow the same plus
// forward slashes for paths like refs/heads/main.
var skillsRepoSpecRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./-]+)?$`)
// skillsIconPrefix is the relative path prefix from a skills README to the
// repo-level .icons directory. The skills README lives at depth 3
// (registry/<namespace>/skills/README.md), so the prefix is three levels up.
// This is distinct from modules and templates, which live at depth 4 and use
// "../../../../.icons/".
const skillsIconPrefix = "../../../.icons/"
// skillOverride holds per-skill presentation metadata defined in the
// registry README. All fields are optional.
type skillOverride struct {
DisplayName string `yaml:"display_name"`
Description string `yaml:"description"`
Icon string `yaml:"icon"`
Tags []string `yaml:"tags"`
}
// skillSource is one entry in the sources list, describing a single source
// repo and optional per-skill overrides.
type skillSource struct {
Repo string `yaml:"repo"`
Skills map[string]skillOverride `yaml:"skills"`
}
// coderSkillsFrontmatter is the YAML frontmatter schema for
// registry/<namespace>/skills/README.md.
type coderSkillsFrontmatter struct {
Icon string `yaml:"icon"`
Sources []skillSource `yaml:"sources"`
}
// supportedSkillsTopLevelKeys lists the keys allowed at the root of the
// skills README frontmatter. Nested keys under sources are validated
// separately because the typed unmarshal handles them.
var supportedSkillsTopLevelKeys = []string{"icon", "sources"}
// coderSkillsReadme represents a parsed skills README file.
type coderSkillsReadme struct {
filePath string
body string
frontmatter coderSkillsFrontmatter
}
// separateSkillsFrontmatter is like separateFrontmatter but preserves
// indentation in the frontmatter block. The skills README uses nested YAML
// (per-skill metadata under each source), which the indentation-trimming
// behavior of the shared separateFrontmatter helper destroys.
func separateSkillsFrontmatter(readmeText string) (frontmatter string, body string, err error) {
if readmeText == "" {
return "", "", xerrors.New("README is empty")
}
const fence = "---"
var fmBuilder strings.Builder
var bodyBuilder strings.Builder
fenceCount := 0
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
for lineScanner.Scan() {
nextLine := lineScanner.Text()
if fenceCount < 2 && strings.TrimSpace(nextLine) == fence {
fenceCount++
continue
}
if fenceCount == 0 {
break
}
if fenceCount >= 2 {
bodyBuilder.WriteString(nextLine)
bodyBuilder.WriteString("\n")
} else {
fmBuilder.WriteString(nextLine)
fmBuilder.WriteString("\n")
}
}
if fenceCount < 2 {
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
}
if strings.TrimSpace(fmBuilder.String()) == "" {
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
}
return fmBuilder.String(), strings.TrimSpace(bodyBuilder.String()), nil
}
// isPermittedSkillsIconURL validates that an icon URL references the
// repo-level .icons directory using the 3-deep prefix appropriate for
// skills READMEs, and that the file exists on disk.
func isPermittedSkillsIconURL(checkURL string, readmeFilePath string) error {
if !strings.HasPrefix(checkURL, skillsIconPrefix) {
return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, skillsIconPrefix)
}
readmeDir := path.Dir(readmeFilePath)
resolvedPath := path.Join(readmeDir, checkURL)
if _, err := os.Stat(resolvedPath); err != nil {
if os.IsNotExist(err) {
return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL)
}
return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err)
}
return nil
}
func validateSkillsIconURL(iconURL string, filePath string) []error {
if iconURL == "" {
return nil
}
var errs []error
if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") {
errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL))
return errs
}
if err := isPermittedSkillsIconURL(iconURL, filePath); err != nil {
errs = append(errs, err)
}
return errs
}
// validateSkillsTopLevelKeys parses the (indentation-preserved) frontmatter
// as a YAML map and verifies that every top-level key is in the supported
// set. This catches typos like "source:" vs "sources:".
func validateSkillsTopLevelKeys(fm string) []error {
var rawKeys map[string]any
if err := yaml.Unmarshal([]byte(fm), &rawKeys); err != nil {
return []error{xerrors.Errorf("failed to parse frontmatter as YAML map: %v", err)}
}
var errs []error
for key := range rawKeys {
if !slices.Contains(supportedSkillsTopLevelKeys, key) {
errs = append(errs, xerrors.Errorf("detected unknown top-level key %q (allowed: %s)", key, strings.Join(supportedSkillsTopLevelKeys, ", ")))
}
}
return errs
}
func validateSkillsSources(sources []skillSource, filePath string) []error {
if len(sources) == 0 {
return []error{xerrors.New("at least one source repo is required under 'sources'")}
}
var errs []error
for i, src := range sources {
if src.Repo == "" {
errs = append(errs, xerrors.Errorf("sources[%d]: missing required 'repo' field", i))
continue
}
if !skillsRepoSpecRe.MatchString(src.Repo) {
errs = append(errs, xerrors.Errorf("sources[%d]: repo %q is not a valid owner/repo or owner/repo@ref spec", i, src.Repo))
}
for slug, override := range src.Skills {
if !validNameRe.MatchString(slug) {
errs = append(errs, xerrors.Errorf("sources[%d]: skill slug %q contains invalid characters (only alphanumeric and hyphens allowed)", i, slug))
}
for _, iconErr := range validateSkillsIconURL(override.Icon, filePath) {
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, iconErr))
}
// validateCoderResourceTags returns an error for nil tags, which is
// fine for modules/templates that require tags but not for skills
// where tags are an optional override.
if override.Tags != nil {
if err := validateCoderResourceTags(override.Tags); err != nil {
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, err))
}
}
}
}
return errs
}
func validateCoderSkillsFrontmatter(filePath string, fm coderSkillsFrontmatter) []error {
var errs []error
for _, err := range validateSkillsIconURL(fm.Icon, filePath) {
errs = append(errs, addFilePathToError(filePath, err))
}
for _, err := range validateSkillsSources(fm.Sources, filePath) {
errs = append(errs, addFilePathToError(filePath, err))
}
return errs
}
func parseCoderSkillsReadme(rm readme) (coderSkillsReadme, []error) {
fm, body, err := separateSkillsFrontmatter(rm.rawText)
if err != nil {
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
}
keyErrs := validateSkillsTopLevelKeys(fm)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return coderSkillsReadme{}, remapped
}
yml := coderSkillsFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
}
return coderSkillsReadme{
filePath: rm.filePath,
body: body,
frontmatter: yml,
}, nil
}
func parseCoderSkillsReadmeFiles(rms []readme) ([]coderSkillsReadme, error) {
var parsed []coderSkillsReadme
var parsingErrs []error
for _, rm := range rms {
p, errs := parseCoderSkillsReadme(rm)
if len(errs) != 0 {
parsingErrs = append(parsingErrs, errs...)
continue
}
parsed = append(parsed, p)
}
if len(parsingErrs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
errors: parsingErrs,
}
}
return parsed, nil
}
func validateAllCoderSkillsReadmes(readmes []coderSkillsReadme) error {
var validationErrs []error
for _, rm := range readmes {
errs := validateCoderSkillsFrontmatter(rm.filePath, rm.frontmatter)
if len(errs) > 0 {
validationErrs = append(validationErrs, errs...)
}
}
if len(validationErrs) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: validationErrs,
}
}
return nil
}
// aggregateSkillsReadmeFiles walks registry/<namespace>/skills/README.md
// entries, skipping namespaces that do not have a skills directory.
func aggregateSkillsReadmeFiles() ([]readme, error) {
namespaceDirs, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
}
var allReadmeFiles []readme
var errs []error
for _, nDir := range namespaceDirs {
if !nDir.IsDir() {
continue
}
skillsReadmePath := path.Join(rootRegistryPath, nDir.Name(), "skills", "README.md")
rmBytes, err := os.ReadFile(skillsReadmePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
errs = append(errs, err)
continue
}
allReadmeFiles = append(allReadmeFiles, readme{
filePath: skillsReadmePath,
rawText: string(rmBytes),
})
}
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFile,
errors: errs,
}
}
return allReadmeFiles, nil
}
func validateAllCoderSkills() error {
allReadmeFiles, err := aggregateSkillsReadmeFiles()
if err != nil {
return err
}
logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles))
if len(allReadmeFiles) == 0 {
return nil
}
readmes, err := parseCoderSkillsReadmeFiles(allReadmeFiles)
if err != nil {
return err
}
if err := validateAllCoderSkillsReadmes(readmes); err != nil {
return err
}
logger.Info(context.Background(), "processed all skills README files", "num_files", len(readmes))
return nil
}

View File

@ -39,6 +39,10 @@ func main() {
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderSkills()
if err != nil {
errs = append(errs, err)
}
if len(errs) == 0 {
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)

View File

@ -11,7 +11,7 @@ import (
"golang.org/x/xerrors"
)
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images", "skills")
// validNameRe validates that names contain only alphanumeric characters and hyphens
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)

View File

@ -0,0 +1,25 @@
---
icon: ../../../.icons/coder.svg
sources:
- repo: coder/skills@main
skills:
setup:
display_name: Coder Setup
icon: ../../../.icons/coder.svg
tags: [coder, deployment, configuration]
---
# Coder Skills
Agent skills maintained by [Coder](https://coder.com) for installing,
configuring, and developing with the Coder platform.
Skills are sourced from [coder/skills](https://github.com/coder/skills)
and served through the registry's API, MCP tools, and
[well-known discovery endpoint](https://agentskills.io/specification).
## Available Skills
| Skill | Description |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Coder Setup](https://registry.coder.com/skills/coder/setup) | Install, deploy, or bootstrap a new Coder deployment end-to-end. Covers Docker, Kubernetes/Helm, VM, cloud, HTTPS/domain setup, first admin creation, starter templates, and first workspace. |