Improvements
This commit is contained in:
parent
1748868000
commit
5c0344d7df
10 changed files with 295 additions and 124 deletions
|
|
@ -168,7 +168,7 @@ func main() {
|
|||
case "list":
|
||||
skillsListCmd(skillsLoader)
|
||||
case "install":
|
||||
skillsInstallCmd(installer)
|
||||
skillsInstallCmd(installer, cfg)
|
||||
case "remove", "uninstall":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
||||
|
|
@ -1227,7 +1227,8 @@ func cronEnableCmd(storePath string, disable bool) {
|
|||
func skillsHelp() {
|
||||
fmt.Println("\nSkills commands:")
|
||||
fmt.Println(" list List installed skills")
|
||||
fmt.Println(" install <repo> Install skill from GitHub")
|
||||
fmt.Println(" install <repo> Install skill from GitHub (default)")
|
||||
fmt.Println(" install --registry <name> <slug> Install from a named registry")
|
||||
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
||||
fmt.Println(" list-builtin List available builtin skills")
|
||||
fmt.Println(" remove <name> Remove installed skill")
|
||||
|
|
@ -1237,6 +1238,7 @@ func skillsHelp() {
|
|||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw skills list")
|
||||
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||
fmt.Println(" picoclaw skills install --registry clawhub github")
|
||||
fmt.Println(" picoclaw skills install-builtin")
|
||||
fmt.Println(" picoclaw skills list-builtin")
|
||||
fmt.Println(" picoclaw skills remove weather")
|
||||
|
|
@ -1260,13 +1262,27 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
|||
}
|
||||
}
|
||||
|
||||
func skillsInstallCmd(installer *skills.SkillInstaller) {
|
||||
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
||||
fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||
fmt.Println(" picoclaw skills install --registry <name> <slug>")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for --registry flag.
|
||||
if os.Args[3] == "--registry" {
|
||||
if len(os.Args) < 6 {
|
||||
fmt.Println("Usage: picoclaw skills install --registry <name> <slug>")
|
||||
fmt.Println("Example: picoclaw skills install --registry clawhub github")
|
||||
return
|
||||
}
|
||||
registryName := os.Args[4]
|
||||
slug := os.Args[5]
|
||||
skillsInstallFromRegistry(cfg, registryName, slug)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: install from GitHub (backward compatible).
|
||||
repo := os.Args[3]
|
||||
fmt.Printf("Installing skill from %s...\n", repo)
|
||||
|
||||
|
|
@ -1274,11 +1290,74 @@ func skillsInstallCmd(installer *skills.SkillInstaller) {
|
|||
defer cancel()
|
||||
|
||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||
fmt.Printf("✗ Failed to install skill: %v\n", err)
|
||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||
}
|
||||
|
||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: cfg.Tools.Skills.Registries.ClawHub.Enabled,
|
||||
BaseURL: cfg.Tools.Skills.Registries.ClawHub.BaseURL,
|
||||
AuthToken: cfg.Tools.Skills.Registries.ClawHub.AuthToken,
|
||||
SearchPath: cfg.Tools.Skills.Registries.ClawHub.SearchPath,
|
||||
SkillsPath: cfg.Tools.Skills.Registries.ClawHub.SkillsPath,
|
||||
DownloadPath: cfg.Tools.Skills.Registries.ClawHub.DownloadPath,
|
||||
Timeout: cfg.Tools.Skills.Registries.ClawHub.Timeout,
|
||||
MaxZipSize: cfg.Tools.Skills.Registries.ClawHub.MaxZipSize,
|
||||
},
|
||||
})
|
||||
|
||||
registry := registryMgr.GetRegistry(registryName)
|
||||
if registry == nil {
|
||||
fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
targetDir := filepath.Join(workspace, "skills", slug)
|
||||
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil {
|
||||
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
||||
if err != nil {
|
||||
os.RemoveAll(targetDir)
|
||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if result.IsMalwareBlocked {
|
||||
os.RemoveAll(targetDir)
|
||||
fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if result.IsSuspicious {
|
||||
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
|
||||
}
|
||||
|
||||
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
|
||||
if result.Summary != "" {
|
||||
fmt.Printf(" %s\n", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,17 @@
|
|||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"enabled": true,
|
||||
"base_url": "https://clawhub.ai",
|
||||
"search_path": "/api/v1/search",
|
||||
"skills_path": "/api/v1/skills",
|
||||
"download_path": "/api/v1/download"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
|||
|
||||
// Skill discovery and installation tools
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: cfg.Tools.Skills.Registries.ClawHub.Enabled,
|
||||
BaseURL: cfg.Tools.Skills.Registries.ClawHub.BaseURL,
|
||||
|
|
@ -111,9 +112,11 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
|||
SearchPath: cfg.Tools.Skills.Registries.ClawHub.SearchPath,
|
||||
SkillsPath: cfg.Tools.Skills.Registries.ClawHub.SkillsPath,
|
||||
DownloadPath: cfg.Tools.Skills.Registries.ClawHub.DownloadPath,
|
||||
Timeout: cfg.Tools.Skills.Registries.ClawHub.Timeout,
|
||||
MaxZipSize: cfg.Tools.Skills.Registries.ClawHub.MaxZipSize,
|
||||
},
|
||||
})
|
||||
searchCache := skills.NewSearchCache(50, 5*time.Minute)
|
||||
searchCache := skills.NewSearchCache(cfg.Tools.Skills.SearchCache.MaxSize, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds) * time.Second)
|
||||
registry.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||
registry.Register(tools.NewInstallSkillTool(registryMgr, workspace))
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,13 @@ type ToolsConfig struct {
|
|||
|
||||
type SkillsToolsConfig struct {
|
||||
Registries SkillsRegistriesConfig `json:"registries"`
|
||||
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||
SearchCache SearchCacheConfig `json:"search_cache"`
|
||||
}
|
||||
|
||||
type SearchCacheConfig struct {
|
||||
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
||||
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
||||
}
|
||||
|
||||
type SkillsRegistriesConfig struct {
|
||||
|
|
@ -231,6 +238,8 @@ type ClawHubRegistryConfig struct {
|
|||
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
|
||||
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
||||
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
|
||||
Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
|
||||
MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
|
|||
|
|
@ -16,22 +16,27 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
clawHubDefaultTimeout = 15 * time.Second
|
||||
maxZipSize = 10 * 1024 * 1024 // 10 MB max ZIP size
|
||||
defaultClawHubTimeout = 30 * time.Second
|
||||
defaultMaxZipSize = 50 * 1024 * 1024 // 50 MB
|
||||
)
|
||||
|
||||
// ClawHubRegistry implements SkillRegistry for the ClawhHub platform.
|
||||
type ClawHubRegistry struct {
|
||||
baseURL string
|
||||
authToken string
|
||||
searchPath string
|
||||
skillsPath string
|
||||
downloadPath string
|
||||
authToken string // Optional - for elevated rate limits
|
||||
searchPath string // Search API
|
||||
skillsPath string // For retrieving skill metadata
|
||||
downloadPath string // For fetching ZIP files for download
|
||||
maxZipSize int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewClawHubRegistry creates a new ClawhHub registry client from config.
|
||||
func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://clawhub.ai"
|
||||
}
|
||||
searchPath := cfg.SearchPath
|
||||
if searchPath == "" {
|
||||
searchPath = "/api/v1/search"
|
||||
|
|
@ -45,14 +50,25 @@ func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
|
|||
downloadPath = "/api/v1/download"
|
||||
}
|
||||
|
||||
timeout := defaultClawHubTimeout
|
||||
if cfg.Timeout > 0 {
|
||||
timeout = time.Duration(cfg.Timeout) * time.Second
|
||||
}
|
||||
|
||||
maxZip := defaultMaxZipSize
|
||||
if cfg.MaxZipSize > 0 {
|
||||
maxZip = cfg.MaxZipSize
|
||||
}
|
||||
|
||||
return &ClawHubRegistry{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
baseURL: cfg.BaseURL,
|
||||
authToken: cfg.AuthToken,
|
||||
searchPath: searchPath,
|
||||
skillsPath: skillsPath,
|
||||
downloadPath: downloadPath,
|
||||
maxZipSize: maxZip,
|
||||
client: &http.Client{
|
||||
Timeout: clawHubDefaultTimeout,
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 5,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
|
|
@ -105,11 +121,26 @@ func (c *ClawHubRegistry) Search(ctx context.Context, query string, limit int) (
|
|||
|
||||
results := make([]SearchResult, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
slug := derefStr(r.Slug, "")
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
summary := derefStr(r.Summary, "")
|
||||
if summary == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
displayName := derefStr(r.DisplayName, "")
|
||||
if displayName == "" {
|
||||
displayName = slug
|
||||
}
|
||||
|
||||
results = append(results, SearchResult{
|
||||
Score: r.Score,
|
||||
Slug: derefStr(r.Slug, "unknown"),
|
||||
DisplayName: derefStr(r.DisplayName, ""),
|
||||
Summary: derefStr(r.Summary, ""),
|
||||
Slug: slug,
|
||||
DisplayName: displayName,
|
||||
Summary: summary,
|
||||
Version: derefStr(r.Version, ""),
|
||||
RegistryName: c.Name(),
|
||||
})
|
||||
|
|
@ -172,35 +203,68 @@ func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*Skill
|
|||
return meta, nil
|
||||
}
|
||||
|
||||
// --- DownloadAndExtract ---
|
||||
// --- DownloadAndInstall ---
|
||||
|
||||
func (c *ClawHubRegistry) DownloadAndExtract(ctx context.Context, slug, version, targetDir string) error {
|
||||
// DownloadAndInstall fetches metadata (with fallback), resolves version,
|
||||
// downloads the skill ZIP, and extracts it to targetDir.
|
||||
// Returns an InstallResult for the caller to use for moderation decisions.
|
||||
func (c *ClawHubRegistry) DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) {
|
||||
if !isSafeSlug(slug) {
|
||||
return fmt.Errorf("invalid slug: %q", slug)
|
||||
return nil, fmt.Errorf("invalid slug: %q", slug)
|
||||
}
|
||||
|
||||
// Step 1: Fetch metadata (with fallback).
|
||||
result := &InstallResult{}
|
||||
meta, err := c.GetSkillMeta(ctx, slug)
|
||||
if err != nil {
|
||||
// Fallback: proceed without metadata.
|
||||
meta = nil
|
||||
}
|
||||
|
||||
if meta != nil {
|
||||
result.IsMalwareBlocked = meta.IsMalwareBlocked
|
||||
result.IsSuspicious = meta.IsSuspicious
|
||||
result.Summary = meta.Summary
|
||||
}
|
||||
|
||||
// Step 2: Resolve version.
|
||||
installVersion := version
|
||||
if installVersion == "" && meta != nil {
|
||||
installVersion = meta.LatestVersion
|
||||
}
|
||||
if installVersion == "" {
|
||||
installVersion = "latest"
|
||||
}
|
||||
result.Version = installVersion
|
||||
|
||||
// Step 3: Download ZIP.
|
||||
u, err := url.Parse(c.baseURL + c.downloadPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid base URL: %w", err)
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("slug", slug)
|
||||
if version != "" {
|
||||
q.Set("version", version)
|
||||
if installVersion != "latest" {
|
||||
q.Set("version", installVersion)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
zipData, err := c.doGet(ctx, u.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
if len(zipData) > maxZipSize {
|
||||
return fmt.Errorf("ZIP too large: %d bytes (max %d)", len(zipData), maxZipSize)
|
||||
if len(zipData) > c.maxZipSize {
|
||||
return nil, fmt.Errorf("ZIP too large: %d bytes (max %d)", len(zipData), c.maxZipSize)
|
||||
}
|
||||
|
||||
return extractZip(zipData, targetDir)
|
||||
// Step 4: Extract.
|
||||
if err := extractZip(zipData, targetDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
|
@ -223,13 +287,13 @@ func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, err
|
|||
defer resp.Body.Close()
|
||||
|
||||
// Limit response body read to prevent memory issues.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxZipSize+1024))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(c.maxZipSize)+1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBytes(body, 200))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
|
|
@ -314,10 +378,3 @@ func derefStr(s *string, fallback string) string {
|
|||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func truncateBytes(b []byte, maxLen int) string {
|
||||
if len(b) <= maxLen {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:maxLen]) + "…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ func TestClawHubRegistryGetSkillMetaUnsafeSlug(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "invalid slug")
|
||||
}
|
||||
|
||||
func TestClawHubRegistryDownloadAndExtract(t *testing.T) {
|
||||
func TestClawHubRegistryDownloadAndInstall(t *testing.T) {
|
||||
// Create a valid ZIP in memory.
|
||||
zipBuf := createTestZip(t, map[string]string{
|
||||
"SKILL.md": "---\nname: test-skill\ndescription: A test\n---\nHello skill",
|
||||
|
|
@ -96,10 +96,22 @@ func TestClawHubRegistryDownloadAndExtract(t *testing.T) {
|
|||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/download", r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/skills/test-skill":
|
||||
// Metadata endpoint.
|
||||
json.NewEncoder(w).Encode(clawhubSkillResponse{
|
||||
Slug: "test-skill",
|
||||
DisplayName: "Test Skill",
|
||||
Summary: "A test skill",
|
||||
LatestVersion: &clawhubVersionInfo{Version: "1.0.0"},
|
||||
})
|
||||
case "/api/v1/download":
|
||||
assert.Equal(t, "test-skill", r.URL.Query().Get("slug"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Write(zipBuf)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
|
|
@ -107,9 +119,11 @@ func TestClawHubRegistryDownloadAndExtract(t *testing.T) {
|
|||
targetDir := filepath.Join(tmpDir, "test-skill")
|
||||
|
||||
reg := newTestRegistry(srv.URL, "")
|
||||
err := reg.DownloadAndExtract(context.Background(), "test-skill", "1.0.0", targetDir)
|
||||
result, err := reg.DownloadAndInstall(context.Background(), "test-skill", "1.0.0", targetDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1.0.0", result.Version)
|
||||
assert.False(t, result.IsMalwareBlocked)
|
||||
|
||||
// Verify extracted files.
|
||||
skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md"))
|
||||
|
|
@ -170,11 +184,6 @@ func TestExtractZipWithSubdirectories(t *testing.T) {
|
|||
assert.Contains(t, string(data), "#!/bin/bash")
|
||||
}
|
||||
|
||||
func TestClawHubRegistryName(t *testing.T) {
|
||||
reg := newTestRegistry("https://clawhub.ai", "")
|
||||
assert.Equal(t, "clawhub", reg.Name())
|
||||
}
|
||||
|
||||
func TestClawHubRegistrySearchHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
|
@ -190,10 +199,18 @@ func TestClawHubRegistrySearchHTTPError(t *testing.T) {
|
|||
|
||||
func TestClawHubRegistrySearchNullableFields(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Return results with null fields (matches ClawhHub API schema).
|
||||
validSlug := "valid-slug"
|
||||
validSummary := "valid summary"
|
||||
|
||||
// Return results with various null/empty fields
|
||||
json.NewEncoder(w).Encode(clawhubSearchResponse{
|
||||
Results: []clawhubSearchResult{
|
||||
{Score: 0.8, Slug: nil, DisplayName: nil, Summary: nil, Version: nil},
|
||||
// Case 1: Null Slug -> Skip
|
||||
{Score: 0.1, Slug: nil, DisplayName: nil, Summary: nil, Version: nil},
|
||||
// Case 2: Valid Slug, Null Summary -> Skip
|
||||
{Score: 0.2, Slug: &validSlug, DisplayName: nil, Summary: nil, Version: nil},
|
||||
// Case 3: Valid Slug, Valid Summary, Null Name -> Keep, Name=Slug
|
||||
{Score: 0.8, Slug: &validSlug, DisplayName: nil, Summary: &validSummary, Version: nil},
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
|
@ -203,26 +220,12 @@ func TestClawHubRegistrySearchNullableFields(t *testing.T) {
|
|||
results, err := reg.Search(context.Background(), "test", 5)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "unknown", results[0].Slug, "null slug should default to 'unknown'")
|
||||
assert.Equal(t, "", results[0].DisplayName)
|
||||
}
|
||||
require.Len(t, results, 1, "should only return 1 valid result")
|
||||
|
||||
func TestClawHubRegistryCustomPaths(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/custom/search", r.URL.Path)
|
||||
json.NewEncoder(w).Encode(clawhubSearchResponse{Results: nil})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
reg := NewClawHubRegistry(ClawHubConfig{
|
||||
Enabled: true,
|
||||
BaseURL: srv.URL,
|
||||
SearchPath: "/custom/search",
|
||||
})
|
||||
results, err := reg.Search(context.Background(), "test", 5)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, results)
|
||||
r := results[0]
|
||||
assert.Equal(t, "valid-slug", r.Slug)
|
||||
assert.Equal(t, "valid-slug", r.DisplayName, "should fallback name to slug")
|
||||
assert.Equal(t, "valid summary", r.Summary)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxConcurrentSearches = 2
|
||||
)
|
||||
|
||||
// SearchResult represents a single result from a skill registry search.
|
||||
type SearchResult struct {
|
||||
Score float64 `json:"score"`
|
||||
|
|
@ -29,8 +33,17 @@ type SkillMeta struct {
|
|||
RegistryName string `json:"registry_name"`
|
||||
}
|
||||
|
||||
// InstallResult is returned by DownloadAndInstall to carry metadata
|
||||
// back to the caller for moderation and user messaging.
|
||||
type InstallResult struct {
|
||||
Version string
|
||||
IsMalwareBlocked bool
|
||||
IsSuspicious bool
|
||||
Summary string
|
||||
}
|
||||
|
||||
// SkillRegistry is the interface that all skill registries must implement.
|
||||
// Each registry represents a different source of skills (e.g., ClawhHub, GitHub, etc.)
|
||||
// Each registry represents a different source of skills (e.g., clawhub.ai)
|
||||
type SkillRegistry interface {
|
||||
// Name returns the unique name of this registry (e.g., "clawhub").
|
||||
Name() string
|
||||
|
|
@ -38,14 +51,17 @@ type SkillRegistry interface {
|
|||
Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
|
||||
// GetSkillMeta retrieves metadata for a specific skill by slug.
|
||||
GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error)
|
||||
// DownloadAndExtract downloads a skill and extracts it to targetDir.
|
||||
DownloadAndExtract(ctx context.Context, slug, version, targetDir string) error
|
||||
// DownloadAndInstall fetches metadata, resolves the version, downloads and
|
||||
// installs the skill to targetDir. Returns an InstallResult with metadata
|
||||
// for the caller to use for moderation and user messaging.
|
||||
DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)
|
||||
}
|
||||
|
||||
// RegistryConfig holds configuration for all skill registries.
|
||||
// This is the input to NewRegistryManagerFromConfig.
|
||||
type RegistryConfig struct {
|
||||
ClawHub ClawHubConfig
|
||||
MaxConcurrentSearches int
|
||||
}
|
||||
|
||||
// ClawHubConfig configures the ClawhHub registry.
|
||||
|
|
@ -56,12 +72,15 @@ type ClawHubConfig struct {
|
|||
SearchPath string // e.g. "/api/v1/search"
|
||||
SkillsPath string // e.g. "/api/v1/skills"
|
||||
DownloadPath string // e.g. "/api/v1/download"
|
||||
Timeout int // seconds, 0 = default (30s)
|
||||
MaxZipSize int // bytes, 0 = default (50MB)
|
||||
}
|
||||
|
||||
// RegistryManager coordinates multiple skill registries.
|
||||
// It fans out search requests and routes installs to the correct registry.
|
||||
type RegistryManager struct {
|
||||
registries []SkillRegistry
|
||||
maxConcurrent int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +88,7 @@ type RegistryManager struct {
|
|||
func NewRegistryManager() *RegistryManager {
|
||||
return &RegistryManager{
|
||||
registries: make([]SkillRegistry, 0),
|
||||
maxConcurrent: defaultMaxConcurrentSearches,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +96,10 @@ func NewRegistryManager() *RegistryManager {
|
|||
// instantiating only the enabled registries.
|
||||
func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
|
||||
rm := NewRegistryManager()
|
||||
if cfg.ClawHub.Enabled && cfg.ClawHub.BaseURL != "" {
|
||||
if cfg.MaxConcurrentSearches > 0 {
|
||||
rm.maxConcurrent = cfg.MaxConcurrentSearches
|
||||
}
|
||||
if cfg.ClawHub.Enabled {
|
||||
rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub))
|
||||
}
|
||||
return rm
|
||||
|
|
@ -101,7 +124,7 @@ func (rm *RegistryManager) GetRegistry(name string) SkillRegistry {
|
|||
return nil
|
||||
}
|
||||
|
||||
// SearchAll fans out the query to all registries concurrently (max 2 goroutines)
|
||||
// SearchAll fans out the query to all registries concurrently
|
||||
// and merges results sorted by score descending.
|
||||
func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) {
|
||||
rm.mu.RLock()
|
||||
|
|
@ -118,8 +141,8 @@ func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit in
|
|||
err error
|
||||
}
|
||||
|
||||
// Semaphore: limit concurrency to 2 goroutines for lightweight infra.
|
||||
sem := make(chan struct{}, 2)
|
||||
// Semaphore: limit concurrency.
|
||||
sem := make(chan struct{}, rm.maxConcurrent)
|
||||
resultsCh := make(chan regResult, len(regs))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ type mockRegistry struct {
|
|||
searchErr error
|
||||
meta *SkillMeta
|
||||
metaErr error
|
||||
downloadErr error
|
||||
installResult *InstallResult
|
||||
installErr error
|
||||
}
|
||||
|
||||
func (m *mockRegistry) Name() string { return m.name }
|
||||
|
|
@ -29,8 +30,8 @@ func (m *mockRegistry) GetSkillMeta(_ context.Context, _ string) (*SkillMeta, er
|
|||
return m.meta, m.metaErr
|
||||
}
|
||||
|
||||
func (m *mockRegistry) DownloadAndExtract(_ context.Context, _, _, _ string) error {
|
||||
return m.downloadErr
|
||||
func (m *mockRegistry) DownloadAndInstall(_ context.Context, _, _, _ string) (*InstallResult, error) {
|
||||
return m.installResult, m.installErr
|
||||
}
|
||||
|
||||
func TestRegistryManagerSearchAllSingle(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import (
|
|||
// SearchCache provides lightweight caching for search results.
|
||||
// It uses trigram-based similarity to match similar queries to cached results,
|
||||
// avoiding redundant API calls. Thread-safe for concurrent access.
|
||||
//
|
||||
// Memory budget: ~50 entries * ~2KB per entry ≈ ~100KB — well within <10MB target.
|
||||
type SearchCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
|
|
|
|||
|
|
@ -104,58 +104,45 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
|
|||
return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
|
||||
}
|
||||
|
||||
// Fetch skill metadata (moderation checks).
|
||||
meta, err := registry.GetSkillMeta(ctx, slug)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to fetch metadata for %q: %v", slug, err))
|
||||
}
|
||||
|
||||
// Moderation: block malware.
|
||||
if meta.IsMalwareBlocked {
|
||||
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
|
||||
}
|
||||
|
||||
// Resolve version.
|
||||
installVersion := version
|
||||
if installVersion == "" {
|
||||
installVersion = meta.LatestVersion
|
||||
}
|
||||
if installVersion == "" {
|
||||
return ErrorResult(fmt.Sprintf("could not resolve version for %q", slug))
|
||||
}
|
||||
|
||||
// Ensure skills directory exists.
|
||||
if err := os.MkdirAll(skillsDir, 0755); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
|
||||
}
|
||||
|
||||
// Download and extract.
|
||||
if err := registry.DownloadAndExtract(ctx, slug, installVersion, targetDir); err != nil {
|
||||
// Download and install (handles metadata, version resolution, extraction).
|
||||
result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir)
|
||||
if err != nil {
|
||||
// Clean up partial install.
|
||||
os.RemoveAll(targetDir)
|
||||
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
|
||||
}
|
||||
|
||||
// Moderation: block malware.
|
||||
if result.IsMalwareBlocked {
|
||||
os.RemoveAll(targetDir)
|
||||
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
|
||||
}
|
||||
|
||||
// Write origin metadata.
|
||||
if err := writeOriginMeta(targetDir, registry.Name(), slug, installVersion); err != nil {
|
||||
if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
|
||||
// Non-fatal: skill is installed, just origin tracking failed.
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Build result with moderation warning if suspicious.
|
||||
var result string
|
||||
if meta.IsSuspicious {
|
||||
result = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug)
|
||||
var output string
|
||||
if result.IsSuspicious {
|
||||
output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug)
|
||||
}
|
||||
result += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
|
||||
slug, installVersion, registry.Name(), targetDir)
|
||||
output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
|
||||
slug, result.Version, registry.Name(), targetDir)
|
||||
|
||||
if meta.Summary != "" {
|
||||
result += fmt.Sprintf("Description: %s\n", meta.Summary)
|
||||
if result.Summary != "" {
|
||||
output += fmt.Sprintf("Description: %s\n", result.Summary)
|
||||
}
|
||||
result += "\nThe skill is now available and can be loaded in the current session."
|
||||
output += "\nThe skill is now available and can be loaded in the current session."
|
||||
|
||||
return SilentResult(result)
|
||||
return SilentResult(output)
|
||||
}
|
||||
|
||||
// originMeta tracks which registry a skill was installed from.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue