mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
0425cd4d77
* 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
306 lines
8.2 KiB
Go
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)
|
|
}
|