diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 10b53948b..1854b26a3 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -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 ") @@ -1226,17 +1226,19 @@ func cronEnableCmd(storePath string, disable bool) { func skillsHelp() { fmt.Println("\nSkills commands:") - fmt.Println(" list List installed skills") - fmt.Println(" install Install skill from GitHub") - fmt.Println(" install-builtin Install all builtin skills to workspace") - fmt.Println(" list-builtin List available builtin skills") - fmt.Println(" remove Remove installed skill") - fmt.Println(" search Search available skills") - fmt.Println(" show Show skill details") + fmt.Println(" list List installed skills") + fmt.Println(" install Install skill from GitHub (default)") + fmt.Println(" install --registry 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 Remove installed skill") + fmt.Println(" search Search available skills") + fmt.Println(" show Show skill details") fmt.Println() 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 ") - fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather") + fmt.Println(" picoclaw skills install --registry ") return } + // Check for --registry flag. + if os.Args[3] == "--registry" { + if len(os.Args) < 6 { + fmt.Println("Usage: picoclaw skills install --registry ") + 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) { diff --git a/config/config.example.json b/config/config.example.json index 3c9158e9c..e1effa9ec 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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": { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 3e77cf6cf..c5cda7d1d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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)) diff --git a/pkg/config/config.go b/pkg/config/config.go index c8634cd4d..41764fc22 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -217,7 +217,14 @@ type ToolsConfig struct { } type SkillsToolsConfig struct { - Registries SkillsRegistriesConfig `json:"registries"` + 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 { diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index c86f031ca..97767e135 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -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]) + "…" -} diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go index 1c3097af1..32a5cf70c 100644 --- a/pkg/skills/clawhub_registry_test.go +++ b/pkg/skills/clawhub_registry_test.go @@ -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) - assert.Equal(t, "test-skill", r.URL.Query().Get("slug")) - w.Header().Set("Content-Type", "application/zip") - w.Write(zipBuf) + 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 --- diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go index 662ef652c..9a04e87cf 100644 --- a/pkg/skills/registry.go +++ b/pkg/skills/registry.go @@ -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 + ClawHub ClawHubConfig + MaxConcurrentSearches int } // ClawHubConfig configures the ClawhHub registry. @@ -56,19 +72,23 @@ 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 - mu sync.RWMutex + registries []SkillRegistry + maxConcurrent int + mu sync.RWMutex } // NewRegistryManager creates an empty RegistryManager. func NewRegistryManager() *RegistryManager { return &RegistryManager{ - registries: make([]SkillRegistry, 0), + 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 diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go index 4cb73d674..21b585fdc 100644 --- a/pkg/skills/registry_test.go +++ b/pkg/skills/registry_test.go @@ -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) { diff --git a/pkg/skills/search_cache.go b/pkg/skills/search_cache.go index c9b6bbc5d..b6495cadc 100644 --- a/pkg/skills/search_cache.go +++ b/pkg/skills/search_cache.go @@ -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 diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 89b962baa..e46f9d4c1 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -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.