Compare commits
1 Commits
cat/agent-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1601ab3e8b |
3
.github/workflows/deploy-registry.yaml
vendored
3
.github/workflows/deploy-registry.yaml
vendored
@ -9,12 +9,11 @@ on:
|
|||||||
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
||||||
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
|
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
|
||||||
- "release/*/*/v*.*.*"
|
- "release/*/*/v*.*.*"
|
||||||
branches: # Templates and skills get released when merged to main
|
branches: # Templates get released when merged to main
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/deploy-registry.yaml"
|
- ".github/workflows/deploy-registry.yaml"
|
||||||
- "registry/**/templates/**"
|
- "registry/**/templates/**"
|
||||||
- "registry/**/skills/**"
|
|
||||||
- "registry/**/README.md"
|
- "registry/**/README.md"
|
||||||
- ".icons/**"
|
- ".icons/**"
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@ -33,7 +33,6 @@ jobs:
|
|||||||
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
||||||
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
||||||
|
|||||||
4
.icons/coder-modules.svg
Normal file
4
.icons/coder-modules.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="7" height="7" x="14" y="3" rx="1"/>
|
||||||
|
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 339 B |
5
.icons/coder-templates.svg
Normal file
5
.icons/coder-templates.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="18" height="7" x="3" y="3" rx="1"/>
|
||||||
|
<rect width="9" height="7" x="3" y="14" rx="1"/>
|
||||||
|
<rect width="5" height="7" x="16" y="14" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
@ -1,339 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -39,10 +39,6 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
err = validateAllCoderSkills()
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) == 0 {
|
if len(errs) == 0 {
|
||||||
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
|
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images", "skills")
|
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||||
|
|
||||||
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
// 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])?$`)
|
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
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. |
|
|
||||||
Loading…
x
Reference in New Issue
Block a user