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) }