enhance skill installer
This commit is contained in:
parent
7ea7bb0717
commit
2f3e274e26
1 changed files with 110 additions and 28 deletions
|
|
@ -2,81 +2,163 @@ package skills
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"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 {
|
||||
workspace string
|
||||
}
|
||||
|
||||
func NewSkillInstaller(workspace string) *SkillInstaller {
|
||||
return &SkillInstaller{
|
||||
workspace: workspace,
|
||||
}
|
||||
return &SkillInstaller{workspace: workspace}
|
||||
}
|
||||
|
||||
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
|
||||
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo))
|
||||
// parseRepoRef 解析 owner/repo/path 格式,默认 main 分支
|
||||
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 {
|
||||
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
|
||||
func (si *SkillInstaller) downloadFile(ctx context.Context, fileURL, savePath string) error {
|
||||
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: 15 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", fileURL, nil)
|
||||
|
||||
resp, err := utils.DoRequestWithRetry(client, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch skill: %w", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("failed to create skill directory: %w", err)
|
||||
var contents []GitHubContent
|
||||
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.
|
||||
if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write skill file: %w", err)
|
||||
switch item.Type {
|
||||
case "file":
|
||||
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
|
||||
}
|
||||
|
||||
func (si *SkillInstaller) Uninstall(skillName string) error {
|
||||
skillDir := filepath.Join(si.workspace, "skills", skillName)
|
||||
|
||||
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("skill '%s' not found", skillName)
|
||||
}
|
||||
return os.RemoveAll(skillDir)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(skillDir); err != nil {
|
||||
return fmt.Errorf("failed to remove skill: %w", err)
|
||||
// InstallFromRegistry installs a skill from a registry.
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue