From 158c8e5aae906ada1f148ef95fe855ccccf129b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Wed, 8 Jul 2026 02:10:22 +0800 Subject: [PATCH 1/2] fix(skills): correct SKILL.md frontmatter for scholar-profile, member and notification-digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- skills/gitlink-member/SKILL.md | 5 ++++- skills/gitlink-notification-digest/SKILL.md | 1 + skills/gitlink-scholar-profile/SKILL.md | 6 +++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md index 61f8c50..c671bf5 100644 --- a/skills/gitlink-member/SKILL.md +++ b/skills/gitlink-member/SKILL.md @@ -1,7 +1,10 @@ --- name: gitlink-member -description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。" +version: 1.0.0 +description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。当用户需要管理 GitLink 仓库成员、成员角色或邀请链接时触发。" metadata: + requires: + bins: ["gitlink-cli"] cliHelp: "gitlink-cli member --help" --- diff --git a/skills/gitlink-notification-digest/SKILL.md b/skills/gitlink-notification-digest/SKILL.md index 72cf9df..b317c94 100644 --- a/skills/gitlink-notification-digest/SKILL.md +++ b/skills/gitlink-notification-digest/SKILL.md @@ -5,6 +5,7 @@ description: "通知摘要:汇总 GitLink 通知并按类型分类,生成通 metadata: requires: bins: ["gitlink-cli"] + cliHelp: "gitlink-cli api --help" --- # gitlink-notification-digest(通知摘要) diff --git a/skills/gitlink-scholar-profile/SKILL.md b/skills/gitlink-scholar-profile/SKILL.md index e557afb..0ea2b74 100644 --- a/skills/gitlink-scholar-profile/SKILL.md +++ b/skills/gitlink-scholar-profile/SKILL.md @@ -3,9 +3,9 @@ name: gitlink-scholar-profile version: 1.0.0 description: "学者/团队科研画像:跨仓库聚合分析 GitLink 用户或组织的科研产出,生成影响力雷达图与代表性成果报告。当用户需要了解某学者/团队的科研产出全貌、评估科研影响力时触发。" metadata: -requires: -bins: ["gitlink-cli"] -cliHelp: "gitlink-cli user --help" + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli user --help" --- # gitlink-scholar-profile(学者/团队科研画像) From 64db1fa7c0d458b80c2cabb962b08164f4b6b782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Wed, 8 Jul 2026 02:49:34 +0800 Subject: [PATCH 2/2] feat(skills): add SKILL.md metadata validator and CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- .gitea/workflows/ci.yml | 5 + .github/workflows/test.yml | 3 + Makefile | 7 +- internal/skillmeta/cmd/check/main.go | 26 +++++ internal/skillmeta/frontmatter.go | 60 ++++++++++ .../testdata/bad/gitlink-flat/SKILL.md | 11 ++ .../testdata/bad/gitlink-mismatch/SKILL.md | 11 ++ .../testdata/bad/gitlink-nobins/SKILL.md | 11 ++ .../testdata/bad/gitlink-nohelp/SKILL.md | 10 ++ .../testdata/bad/gitlink-noversion/SKILL.md | 10 ++ internal/skillmeta/validate.go | 105 ++++++++++++++++++ internal/skillmeta/validate_test.go | 39 +++++++ 12 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 internal/skillmeta/cmd/check/main.go create mode 100644 internal/skillmeta/frontmatter.go create mode 100644 internal/skillmeta/testdata/bad/gitlink-flat/SKILL.md create mode 100644 internal/skillmeta/testdata/bad/gitlink-mismatch/SKILL.md create mode 100644 internal/skillmeta/testdata/bad/gitlink-nobins/SKILL.md create mode 100644 internal/skillmeta/testdata/bad/gitlink-nohelp/SKILL.md create mode 100644 internal/skillmeta/testdata/bad/gitlink-noversion/SKILL.md create mode 100644 internal/skillmeta/validate.go create mode 100644 internal/skillmeta/validate_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 94fb619..0862b9e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,6 +20,11 @@ jobs: - name: Build run: go build ./... + - name: Validate i18n and skill metadata + run: | + go run ./internal/i18n/cmd/check + go run ./internal/skillmeta/cmd/check + - name: Lint run: make lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d5673c..e503163 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,8 @@ jobs: - name: Scan i18n key references run: go run ./internal/i18n/cmd/check --scan-code + - name: Validate skill metadata + run: go run ./internal/skillmeta/cmd/check + - name: Run Go tests run: go test ./... diff --git a/Makefile b/Makefile index 8c6702d..927e2d4 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINARY := gitlink-cli VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)' -.PHONY: build install clean test check vet fmt cover lint +.PHONY: build install clean test check vet fmt cover lint skills build: go build -ldflags "$(LDFLAGS)" -o $(BINARY) . @@ -35,7 +35,10 @@ cover: lint: golangci-lint run ./... -check: fmt vet lint test +skills: + go run ./internal/skillmeta/cmd/check + +check: fmt vet lint skills test @echo "All checks passed." hooks: diff --git a/internal/skillmeta/cmd/check/main.go b/internal/skillmeta/cmd/check/main.go new file mode 100644 index 0000000..9875ea9 --- /dev/null +++ b/internal/skillmeta/cmd/check/main.go @@ -0,0 +1,26 @@ +// Command check validates the SKILL.md frontmatter of the skills/ registry. +// It mirrors the i18n gate: run from the repo root, it exits non-zero and +// prints every problem, so CI and `make check` can keep the registry honest. +package main + +import ( + "fmt" + "os" + + "github.com/gitlink-org/gitlink-cli/internal/skillmeta" +) + +func main() { + problems, err := skillmeta.Validate("skills") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(problems) > 0 { + for _, p := range problems { + fmt.Fprintln(os.Stderr, p.String()) + } + os.Exit(1) + } + fmt.Println("skill metadata is valid") +} diff --git a/internal/skillmeta/frontmatter.go b/internal/skillmeta/frontmatter.go new file mode 100644 index 0000000..1d8c933 --- /dev/null +++ b/internal/skillmeta/frontmatter.go @@ -0,0 +1,60 @@ +// Package skillmeta validates the YAML frontmatter of the skills/ registry so +// that every SKILL.md is discoverable and structurally sound. +package skillmeta + +import ( + "bytes" + "errors" + "fmt" + + "gopkg.in/yaml.v3" +) + +// Frontmatter is the metadata block every SKILL.md carries. +type Frontmatter struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + Metadata Metadata `yaml:"metadata"` +} + +// Metadata holds the nested metadata fields of a skill. +type Metadata struct { + Requires Requires `yaml:"requires"` + CLIHelp string `yaml:"cliHelp"` +} + +// Requires lists what a skill needs to run. +type Requires struct { + Bins []string `yaml:"bins"` +} + +var errNoFrontmatter = errors.New("no `---` delimited frontmatter block") + +// ExtractFrontmatter returns the YAML between the first pair of `---` fences. +func ExtractFrontmatter(src []byte) ([]byte, error) { + const fence = "---" + trimmed := bytes.TrimLeft(src, " \t\r\n") + if !bytes.HasPrefix(trimmed, []byte(fence)) { + return nil, errNoFrontmatter + } + rest := trimmed[len(fence):] + idx := bytes.Index(rest, []byte("\n"+fence)) + if idx < 0 { + return nil, errNoFrontmatter + } + return rest[:idx], nil +} + +// ParseFrontmatter strictly decodes the frontmatter, rejecting unknown or +// wrongly-nested keys so that structural mistakes surface as errors instead of +// being silently dropped. +func ParseFrontmatter(block []byte) (Frontmatter, error) { + var fm Frontmatter + dec := yaml.NewDecoder(bytes.NewReader(block)) + dec.KnownFields(true) + if err := dec.Decode(&fm); err != nil { + return Frontmatter{}, fmt.Errorf("invalid frontmatter: %w", err) + } + return fm, nil +} diff --git a/internal/skillmeta/testdata/bad/gitlink-flat/SKILL.md b/internal/skillmeta/testdata/bad/gitlink-flat/SKILL.md new file mode 100644 index 0000000..791c9ec --- /dev/null +++ b/internal/skillmeta/testdata/bad/gitlink-flat/SKILL.md @@ -0,0 +1,11 @@ +--- +name: gitlink-flat +version: 1.0.0 +description: "A test skill whose metadata children are flattened to the top instead of nested." +metadata: +requires: +bins: ["gitlink-cli"] +cliHelp: "gitlink-cli x --help" +--- + +# body diff --git a/internal/skillmeta/testdata/bad/gitlink-mismatch/SKILL.md b/internal/skillmeta/testdata/bad/gitlink-mismatch/SKILL.md new file mode 100644 index 0000000..2d6884e --- /dev/null +++ b/internal/skillmeta/testdata/bad/gitlink-mismatch/SKILL.md @@ -0,0 +1,11 @@ +--- +name: gitlink-wrongname +version: 1.0.0 +description: "A test skill whose name field does not match its own directory name." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli x --help" +--- + +# body diff --git a/internal/skillmeta/testdata/bad/gitlink-nobins/SKILL.md b/internal/skillmeta/testdata/bad/gitlink-nobins/SKILL.md new file mode 100644 index 0000000..2708fb6 --- /dev/null +++ b/internal/skillmeta/testdata/bad/gitlink-nobins/SKILL.md @@ -0,0 +1,11 @@ +--- +name: gitlink-nobins +version: 1.0.0 +description: "A test skill whose requires.bins does not include the gitlink-cli binary." +metadata: + requires: + bins: ["other-tool"] + cliHelp: "gitlink-cli x --help" +--- + +# body diff --git a/internal/skillmeta/testdata/bad/gitlink-nohelp/SKILL.md b/internal/skillmeta/testdata/bad/gitlink-nohelp/SKILL.md new file mode 100644 index 0000000..47861b6 --- /dev/null +++ b/internal/skillmeta/testdata/bad/gitlink-nohelp/SKILL.md @@ -0,0 +1,10 @@ +--- +name: gitlink-nohelp +version: 1.0.0 +description: "A test skill whose metadata is missing the cliHelp field for the command." +metadata: + requires: + bins: ["gitlink-cli"] +--- + +# body diff --git a/internal/skillmeta/testdata/bad/gitlink-noversion/SKILL.md b/internal/skillmeta/testdata/bad/gitlink-noversion/SKILL.md new file mode 100644 index 0000000..268119c --- /dev/null +++ b/internal/skillmeta/testdata/bad/gitlink-noversion/SKILL.md @@ -0,0 +1,10 @@ +--- +name: gitlink-noversion +description: "A test skill whose frontmatter is missing the version field entirely." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli x --help" +--- + +# body diff --git a/internal/skillmeta/validate.go b/internal/skillmeta/validate.go new file mode 100644 index 0000000..06ed19a --- /dev/null +++ b/internal/skillmeta/validate.go @@ -0,0 +1,105 @@ +package skillmeta + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +// Problem is a single validation failure against the shared skill schema. +type Problem struct { + Skill string + Field string + Message string +} + +func (p Problem) String() string { + return fmt.Sprintf("%s: %s: %s", p.Skill, p.Field, p.Message) +} + +var ( + semverRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + skillDirRe = regexp.MustCompile(`^gitlink-`) +) + +const minDescriptionRunes = 20 + +// Validate checks every /gitlink-*/SKILL.md against the shared schema and +// returns all problems found; it reports the whole registry rather than +// stopping at the first failure. +func Validate(root string) ([]Problem, error) { + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + var problems []Problem + for _, entry := range entries { + if !entry.IsDir() || !skillDirRe.MatchString(entry.Name()) { + continue + } + problems = append(problems, validateSkill(root, entry.Name())...) + } + sort.Slice(problems, func(i, j int) bool { + if problems[i].Skill != problems[j].Skill { + return problems[i].Skill < problems[j].Skill + } + return problems[i].Field < problems[j].Field + }) + return problems, nil +} + +func validateSkill(root, name string) []Problem { + var ps []Problem + add := func(field, msg string) { + ps = append(ps, Problem{Skill: name, Field: field, Message: msg}) + } + + src, err := os.ReadFile(filepath.Join(root, name, "SKILL.md")) + if err != nil { + add("SKILL.md", "cannot read: "+err.Error()) + return ps + } + block, err := ExtractFrontmatter(src) + if err != nil { + add("frontmatter", err.Error()) + return ps + } + fm, err := ParseFrontmatter(block) + if err != nil { + add("frontmatter", err.Error()) + return ps + } + + switch { + case fm.Name == "": + add("name", "must not be empty") + case fm.Name != name: + add("name", fmt.Sprintf("must equal the directory name %q", name)) + } + if !semverRe.MatchString(fm.Version) { + add("version", "must be semantic version X.Y.Z") + } + if utf8.RuneCountInString(fm.Description) < minDescriptionRunes { + add("description", fmt.Sprintf("must be at least %d characters; it is the router's only routing signal", minDescriptionRunes)) + } + if !containsString(fm.Metadata.Requires.Bins, "gitlink-cli") { + add("metadata.requires.bins", `must contain "gitlink-cli"`) + } + if strings.TrimSpace(fm.Metadata.CLIHelp) == "" { + add("metadata.cliHelp", "must name the command group, e.g. \"gitlink-cli x --help\"") + } + return ps +} + +func containsString(xs []string, target string) bool { + for _, x := range xs { + if x == target { + return true + } + } + return false +} diff --git a/internal/skillmeta/validate_test.go b/internal/skillmeta/validate_test.go new file mode 100644 index 0000000..c35ee84 --- /dev/null +++ b/internal/skillmeta/validate_test.go @@ -0,0 +1,39 @@ +package skillmeta + +import "testing" + +// TestRepoSkillsValid treats the real skills/ registry as a regression +// baseline: once fixed, every SKILL.md must keep passing the schema. +func TestRepoSkillsValid(t *testing.T) { + problems, err := Validate("../../skills") + if err != nil { + t.Fatalf("validate skills: %v", err) + } + for _, p := range problems { + t.Errorf("unexpected problem in registry: %s", p) + } +} + +// TestValidateCatchesBadSkills pins each rule to a deliberately broken sample. +func TestValidateCatchesBadSkills(t *testing.T) { + problems, err := Validate("testdata/bad") + if err != nil { + t.Fatalf("validate testdata: %v", err) + } + got := make(map[string]bool) + for _, p := range problems { + got[p.Skill+"/"+p.Field] = true + } + want := []string{ + "gitlink-noversion/version", + "gitlink-nobins/metadata.requires.bins", + "gitlink-nohelp/metadata.cliHelp", + "gitlink-flat/frontmatter", + "gitlink-mismatch/name", + } + for _, w := range want { + if !got[w] { + t.Errorf("expected problem %q, got %v", w, problems) + } + } +}