Files
picoclaw/pkg/skills/github_registry.go
T
lxowalle 0425cd4d77 refactor skills registries and add GitHub-backed skill discovery (#2442)
* refactor skills registries and add GitHub-backed skill discovery

* fix ci

* fix command error

* fix default skills install registry behavior

* fix github registry URL parsing and versioned skill links

* fix skills registry config compatibility and URL installs

* * fix lint

* fix deprecated github base url compatibility

* fix skills registry yaml and github default branch handling

* fix github skills registry fallback and install metadata

* fix cli skills install origin metadata

* fix clawhub registry env compatibility

* fix skills registry config merge compatibility

* fix skill install metadata consistency and onboard template copy

* fix yaml overrides for default skills registries

* fix install_skill registry metadata normalization

* fix github skill URL parsing for slash branch names

* fix skills registry install/search validation and github URLs

* fix github skill URL host validation

* fix install_skill validation for invalid registry archives

* fix redundant skills registry names in saved config

* fix github blob skill URL installs and metadata links

* fix github registry URL scheme validation

* fix v0 skills migration preserving github registry defaults

* fix github blob skill install directory resolution

* fix install_skill rollback on origin metadata write failure

* fix github skill URL validation and registry JSON merging

* fix github registry target resolution and metadata links

* fix install_skill force reinstall rollback

* fix skills config compatibility and legacy security overlays

* fix ci
2026-04-14 15:14:16 +08:00

306 lines
8.2 KiB
Go

package skills
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"path/filepath"
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
RegisterRegistryProviderBuilder("github", func(_ string, cfg config.SkillRegistryConfig) RegistryProvider {
privateCfg := githubRegistryPrivateConfig{}
if err := cfg.DecodeParam(&privateCfg); err != nil {
slog.Warn("invalid github private config", "error", err)
}
return GitHubRegistryConfig{
Enabled: cfg.Enabled,
BaseURL: cfg.BaseURL,
AuthToken: cfg.AuthToken.String(),
Proxy: privateCfg.Proxy,
}
})
}
type githubRegistryPrivateConfig struct {
Proxy string `json:"proxy"`
}
type GitHubRegistryConfig struct {
Enabled bool
BaseURL string
AuthToken string
Proxy string
}
type GitHubRegistry struct {
installer *SkillInstaller
webBase string
}
const githubAuthTokenHelp = "configure registries.github.auth_token"
func (c GitHubRegistryConfig) IsEnabled() bool {
return c.Enabled
}
func (c GitHubRegistryConfig) BuildRegistry() SkillRegistry {
installer, err := NewSkillInstallerWithBaseURL("", c.BaseURL, c.AuthToken, c.Proxy)
if err != nil {
slog.Warn("failed to create github registry installer", "error", err)
return nil
}
return &GitHubRegistry{
installer: installer,
webBase: installer.githubBaseURL,
}
}
func (r *GitHubRegistry) Name() string {
return "github"
}
func (r *GitHubRegistry) ResolveInstallDirName(target string) (string, error) {
return githubInstallDirNameWithBaseURL(target, r.webBase)
}
func (r *GitHubRegistry) NormalizeInstallTarget(target string) string {
normalized, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase)
if err != nil {
return target
}
return normalized
}
func (r *GitHubRegistry) SkillURL(target, version string) string {
defaultRef := strings.TrimSpace(version)
parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, defaultRef)
if err != nil {
return ""
}
ref := parsedTarget.Ref
base := strings.TrimRight(parsedTarget.Endpoints.WebBaseURL, "/")
urlPath := path.Join(ref.Owner, ref.RepoName)
if ref.SubPath != "" {
if ref.Ref == "" {
return ""
}
viewKind := "tree"
if isSkillMarkdownPath(ref.SubPath) {
viewKind = "blob"
}
return fmt.Sprintf("%s/%s/%s/%s/%s", base, urlPath, viewKind, ref.Ref, ref.SubPath)
}
if ref.Ref == "" {
return fmt.Sprintf("%s/%s", base, urlPath)
}
if ref.Ref != "main" {
return fmt.Sprintf("%s/%s/tree/%s", base, urlPath, ref.Ref)
}
return fmt.Sprintf("%s/%s", base, urlPath)
}
type gitHubCodeSearchResponse struct {
Items []gitHubCodeSearchItem `json:"items"`
}
type gitHubCodeSearchItem struct {
Path string `json:"path"`
HTMLURL string `json:"html_url"`
Score float64 `json:"score"`
Repository struct {
FullName string `json:"full_name"`
Name string `json:"name"`
Description string `json:"description"`
DefaultBranch string `json:"default_branch"`
} `json:"repository"`
}
func (r *GitHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
if limit <= 0 {
limit = 5
}
u, err := url.Parse(strings.TrimRight(r.installer.githubAPIBaseURL, "/") + "/search/code")
if err != nil {
return nil, fmt.Errorf("invalid github api base url: %w", err)
}
q := u.Query()
q.Set("q", fmt.Sprintf("%s filename:SKILL.md", query))
q.Set("per_page", fmt.Sprintf("%d", limit))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
if r.installer.githubToken != "" {
req.Header.Set("Authorization", "Bearer "+r.installer.githubToken)
}
resp, err := r.installer.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return nil, fmt.Errorf("failed to read github search response: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized && r.installer.githubToken == "" && isGitHubAuthRequiredError(body) {
slog.Warn("github search requires authentication; returning no results", "help", githubAuthTokenHelp)
return []SearchResult{}, nil
}
if resp.StatusCode == http.StatusForbidden && r.installer.githubToken == "" && isGitHubRateLimitError(body) {
slog.Warn("github search hit unauthenticated rate limit; returning no results", "help", githubAuthTokenHelp)
return []SearchResult{}, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("github search failed: HTTP %d: %s", resp.StatusCode, string(body))
}
var parsed gitHubCodeSearchResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("failed to parse github search response: %w", err)
}
resultsBySlug := map[string]SearchResult{}
for _, item := range parsed.Items {
slug, ok := githubSearchSlug(item)
if !ok {
continue
}
result := SearchResult{
Score: item.Score,
Slug: slug,
DisplayName: githubSearchDisplayName(item),
Summary: strings.TrimSpace(item.Repository.Description),
Version: strings.TrimSpace(item.Repository.DefaultBranch),
RegistryName: r.Name(),
}
if existing, exists := resultsBySlug[slug]; exists && existing.Score >= result.Score {
continue
}
resultsBySlug[slug] = result
}
results := make([]SearchResult, 0, len(resultsBySlug))
for _, result := range resultsBySlug {
results = append(results, result)
}
sort.Slice(results, func(i, j int) bool {
if results[i].Score == results[j].Score {
return results[i].Slug < results[j].Slug
}
return results[i].Score > results[j].Score
})
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
func isGitHubRateLimitError(body []byte) bool {
message := strings.ToLower(string(body))
return strings.Contains(message, "rate limit exceeded")
}
func isGitHubAuthRequiredError(body []byte) bool {
message := strings.ToLower(string(body))
return strings.Contains(message, "requires authentication") ||
strings.Contains(message, "must be authenticated to access the code search api")
}
func githubSearchSlug(item gitHubCodeSearchItem) (string, bool) {
fullName := strings.TrimSpace(item.Repository.FullName)
if fullName == "" {
return "", false
}
cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/")
if cleanPath == "" || filepath.Base(cleanPath) != "SKILL.md" {
return "", false
}
dir := path.Dir(cleanPath)
if dir == "." || dir == "" {
return fullName, true
}
return fullName + "/" + dir, true
}
func githubSearchDisplayName(item gitHubCodeSearchItem) string {
cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/")
if cleanPath != "" {
dir := path.Dir(cleanPath)
if dir != "." && dir != "" {
return path.Base(dir)
}
}
if name := strings.TrimSpace(item.Repository.Name); name != "" {
return name
}
return strings.TrimSpace(item.Repository.FullName)
}
func canonicalGitHubRegistrySlugWithBaseURL(target, githubBaseURL string) (string, error) {
ref, err := parseGitHubRefWithBaseURL(target, githubBaseURL, "")
if err != nil {
return "", err
}
slug := path.Join(ref.Owner, ref.RepoName)
if ref.SubPath != "" {
slug = path.Join(slug, ref.SubPath)
}
return slug, nil
}
func (r *GitHubRegistry) GetSkillMeta(ctx context.Context, target string) (*SkillMeta, error) {
slug, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase)
if err != nil {
return nil, err
}
parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, "")
if err != nil {
return nil, err
}
ref := parsedTarget.Ref
if ref.Ref == "" {
ref.Ref, err = r.installer.fetchDefaultBranchWithAPIBaseURL(
ctx,
parsedTarget.Endpoints.APIBaseURL,
ref.Owner,
ref.RepoName,
)
if err != nil {
return nil, err
}
}
return &SkillMeta{
Slug: slug,
DisplayName: ref.RepoName,
LatestVersion: ref.Ref,
RegistryName: r.Name(),
}, nil
}
func (r *GitHubRegistry) DownloadAndInstall(
ctx context.Context,
target, version, targetDir string,
) (*InstallResult, error) {
return r.installer.InstallFromGitHubToDir(ctx, target, version, targetDir)
}