enhance skill installer

This commit is contained in:
FantasticCode2019 2026-03-09 00:29:19 +08:00
parent 7ea7bb0717
commit 2f3e274e26

View file

@ -2,81 +2,163 @@ package skills
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
type GitHubContent struct {
Name string `json:"name"`
Path string `json:"path"`
DownloadURL string `json:"download_url"`
Type string `json:"type"`
}
type SkillInstaller struct { type SkillInstaller struct {
workspace string workspace string
} }
func NewSkillInstaller(workspace string) *SkillInstaller { func NewSkillInstaller(workspace string) *SkillInstaller {
return &SkillInstaller{ return &SkillInstaller{workspace: workspace}
workspace: workspace,
}
} }
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error { // parseRepoRef 解析 owner/repo/path 格式,默认 main 分支
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo)) func parseRepoRef(repo string) (owner, repoName, ref, path string, err error) {
parts := strings.Split(strings.Trim(repo, "/"), "/")
if len(parts) < 3 {
return "", "", "", "", fmt.Errorf("invalid format: owner/repo/path")
}
return parts[0], parts[1], "main", strings.Join(parts[2:], "/"), nil
}
if _, err := os.Stat(skillDir); err == nil { func (si *SkillInstaller) downloadFile(ctx context.Context, fileURL, savePath string) error {
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo)) dir := filepath.Dir(savePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
} }
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo) client := &http.Client{Timeout: 30 * time.Second}
req, _ := http.NewRequestWithContext(ctx, "GET", fileURL, nil)
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := utils.DoRequestWithRetry(client, req) resp, err := utils.DoRequestWithRetry(client, req)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err) return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode) return fmt.Errorf("HTTP %d", resp.StatusCode)
} }
body, err := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
return fileutil.WriteFileAtomic(savePath, body, 0644)
}
func (si *SkillInstaller) downloadDir(ctx context.Context, owner, repo, ref, dirPath, localRoot string) error {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s",
url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(dirPath), url.PathEscape(ref))
client := &http.Client{Timeout: 30 * time.Second}
req, _ := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
resp, err := utils.DoRequestWithRetry(client, req)
if err != nil { if err != nil {
return fmt.Errorf("failed to read response: %w", err) return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub API HTTP %d", resp.StatusCode)
} }
if err := os.MkdirAll(skillDir, 0o755); err != nil { var contents []GitHubContent
return fmt.Errorf("failed to create skill directory: %w", err) if err := json.NewDecoder(resp.Body).Decode(&contents); err != nil {
return err
} }
skillPath := filepath.Join(skillDir, "SKILL.md") for _, item := range contents {
relPath := strings.TrimPrefix(strings.TrimPrefix(item.Path, dirPath), "/")
localPath := filepath.Join(localRoot, relPath)
// Use unified atomic write utility with explicit sync for flash storage reliability. switch item.Type {
if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil { case "file":
return fmt.Errorf("failed to write skill file: %w", err) if item.DownloadURL != "" {
if err := si.downloadFile(ctx, item.DownloadURL, localPath); err != nil {
return fmt.Errorf("download %s: %w", item.Path, err)
}
}
case "dir":
if err := si.downloadDir(ctx, owner, repo, ref, item.Path, localRoot); err != nil {
return fmt.Errorf("download dir %s: %w", item.Path, err)
}
}
}
return nil
}
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
owner, repoName, ref, path, err := parseRepoRef(repo)
if err != nil {
return err
} }
skillName := filepath.Base(path)
skillDir := filepath.Join(si.workspace, "skills", skillName)
if _, err := os.Stat(skillDir); err == nil {
return fmt.Errorf("skill '%s' already exists", skillName)
}
if err := os.MkdirAll(skillDir, 0755); err != nil {
return err
}
if err := si.downloadDir(ctx, owner, repoName, ref, path, skillDir); err != nil {
os.RemoveAll(skillDir)
return fmt.Errorf("install skill: %w", err)
}
return nil return nil
} }
func (si *SkillInstaller) Uninstall(skillName string) error { func (si *SkillInstaller) Uninstall(skillName string) error {
skillDir := filepath.Join(si.workspace, "skills", skillName) skillDir := filepath.Join(si.workspace, "skills", skillName)
if _, err := os.Stat(skillDir); os.IsNotExist(err) { if _, err := os.Stat(skillDir); os.IsNotExist(err) {
return fmt.Errorf("skill '%s' not found", skillName) return fmt.Errorf("skill '%s' not found", skillName)
} }
return os.RemoveAll(skillDir)
}
if err := os.RemoveAll(skillDir); err != nil { // InstallFromRegistry installs a skill from a registry.
return fmt.Errorf("failed to remove skill: %w", err) func (si *SkillInstaller) InstallFromRegistry(ctx context.Context, registry SkillRegistry, slug string) error {
targetDir := filepath.Join(si.workspace, "skills", slug)
if _, err := os.Stat(targetDir); err == nil {
return fmt.Errorf("skill '%s' already exists", slug)
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
if err != nil {
os.RemoveAll(targetDir)
return err
}
if result.IsMalwareBlocked {
os.RemoveAll(targetDir)
return fmt.Errorf("skill '%s' is flagged as malicious", slug)
} }
return nil return nil
} }