Improvements

This commit is contained in:
harshbansal7 2026-02-17 14:57:23 +05:30
parent 1748868000
commit 5c0344d7df
10 changed files with 295 additions and 124 deletions

View file

@ -168,7 +168,7 @@ func main() {
case "list": case "list":
skillsListCmd(skillsLoader) skillsListCmd(skillsLoader)
case "install": case "install":
skillsInstallCmd(installer) skillsInstallCmd(installer, cfg)
case "remove", "uninstall": case "remove", "uninstall":
if len(os.Args) < 4 { if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills remove <skill-name>") fmt.Println("Usage: picoclaw skills remove <skill-name>")
@ -1227,7 +1227,8 @@ func cronEnableCmd(storePath string, disable bool) {
func skillsHelp() { func skillsHelp() {
fmt.Println("\nSkills commands:") fmt.Println("\nSkills commands:")
fmt.Println(" list List installed skills") 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(" install-builtin Install all builtin skills to workspace")
fmt.Println(" list-builtin List available builtin skills") fmt.Println(" list-builtin List available builtin skills")
fmt.Println(" remove <name> Remove installed skill") fmt.Println(" remove <name> Remove installed skill")
@ -1237,6 +1238,7 @@ func skillsHelp() {
fmt.Println("Examples:") fmt.Println("Examples:")
fmt.Println(" picoclaw skills list") fmt.Println(" picoclaw skills list")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather") 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 install-builtin")
fmt.Println(" picoclaw skills list-builtin") fmt.Println(" picoclaw skills list-builtin")
fmt.Println(" picoclaw skills remove weather") 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 { if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills install <github-repo>") 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 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] repo := os.Args[3]
fmt.Printf("Installing skill from %s...\n", repo) fmt.Printf("Installing skill from %s...\n", repo)
@ -1274,11 +1290,74 @@ func skillsInstallCmd(installer *skills.SkillInstaller) {
defer cancel() defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil { 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) 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) { func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {

View file

@ -119,6 +119,17 @@
"api_key": "YOUR_BRAVE_API_KEY", "api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5 "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": { "heartbeat": {

View file

@ -104,6 +104,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
// Skill discovery and installation tools // Skill discovery and installation tools
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{ ClawHub: skills.ClawHubConfig{
Enabled: cfg.Tools.Skills.Registries.ClawHub.Enabled, Enabled: cfg.Tools.Skills.Registries.ClawHub.Enabled,
BaseURL: cfg.Tools.Skills.Registries.ClawHub.BaseURL, 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, SearchPath: cfg.Tools.Skills.Registries.ClawHub.SearchPath,
SkillsPath: cfg.Tools.Skills.Registries.ClawHub.SkillsPath, SkillsPath: cfg.Tools.Skills.Registries.ClawHub.SkillsPath,
DownloadPath: cfg.Tools.Skills.Registries.ClawHub.DownloadPath, 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.NewFindSkillsTool(registryMgr, searchCache))
registry.Register(tools.NewInstallSkillTool(registryMgr, workspace)) registry.Register(tools.NewInstallSkillTool(registryMgr, workspace))

View file

@ -218,6 +218,13 @@ type ToolsConfig struct {
type SkillsToolsConfig 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 { type SkillsRegistriesConfig struct {
@ -231,6 +238,8 @@ type ClawHubRegistryConfig struct {
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_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"` 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 { func DefaultConfig() *Config {

View file

@ -16,22 +16,27 @@ import (
) )
const ( const (
clawHubDefaultTimeout = 15 * time.Second defaultClawHubTimeout = 30 * time.Second
maxZipSize = 10 * 1024 * 1024 // 10 MB max ZIP size defaultMaxZipSize = 50 * 1024 * 1024 // 50 MB
) )
// ClawHubRegistry implements SkillRegistry for the ClawhHub platform. // ClawHubRegistry implements SkillRegistry for the ClawhHub platform.
type ClawHubRegistry struct { type ClawHubRegistry struct {
baseURL string baseURL string
authToken string authToken string // Optional - for elevated rate limits
searchPath string searchPath string // Search API
skillsPath string skillsPath string // For retrieving skill metadata
downloadPath string downloadPath string // For fetching ZIP files for download
maxZipSize int
client *http.Client client *http.Client
} }
// NewClawHubRegistry creates a new ClawhHub registry client from config. // NewClawHubRegistry creates a new ClawhHub registry client from config.
func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry { func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = "https://clawhub.ai"
}
searchPath := cfg.SearchPath searchPath := cfg.SearchPath
if searchPath == "" { if searchPath == "" {
searchPath = "/api/v1/search" searchPath = "/api/v1/search"
@ -45,14 +50,25 @@ func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
downloadPath = "/api/v1/download" 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{ return &ClawHubRegistry{
baseURL: strings.TrimRight(cfg.BaseURL, "/"), baseURL: cfg.BaseURL,
authToken: cfg.AuthToken, authToken: cfg.AuthToken,
searchPath: searchPath, searchPath: searchPath,
skillsPath: skillsPath, skillsPath: skillsPath,
downloadPath: downloadPath, downloadPath: downloadPath,
maxZipSize: maxZip,
client: &http.Client{ client: &http.Client{
Timeout: clawHubDefaultTimeout, Timeout: timeout,
Transport: &http.Transport{ Transport: &http.Transport{
MaxIdleConns: 5, MaxIdleConns: 5,
IdleConnTimeout: 30 * time.Second, 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)) results := make([]SearchResult, 0, len(resp.Results))
for _, r := range 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{ results = append(results, SearchResult{
Score: r.Score, Score: r.Score,
Slug: derefStr(r.Slug, "unknown"), Slug: slug,
DisplayName: derefStr(r.DisplayName, ""), DisplayName: displayName,
Summary: derefStr(r.Summary, ""), Summary: summary,
Version: derefStr(r.Version, ""), Version: derefStr(r.Version, ""),
RegistryName: c.Name(), RegistryName: c.Name(),
}) })
@ -172,35 +203,68 @@ func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*Skill
return meta, nil 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) { 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) u, err := url.Parse(c.baseURL + c.downloadPath)
if err != nil { if err != nil {
return fmt.Errorf("invalid base URL: %w", err) return nil, fmt.Errorf("invalid base URL: %w", err)
} }
q := u.Query() q := u.Query()
q.Set("slug", slug) q.Set("slug", slug)
if version != "" { if installVersion != "latest" {
q.Set("version", version) q.Set("version", installVersion)
} }
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
zipData, err := c.doGet(ctx, u.String()) zipData, err := c.doGet(ctx, u.String())
if err != nil { if err != nil {
return fmt.Errorf("download failed: %w", err) return nil, fmt.Errorf("download failed: %w", err)
} }
if len(zipData) > maxZipSize { if len(zipData) > c.maxZipSize {
return fmt.Errorf("ZIP too large: %d bytes (max %d)", len(zipData), 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 --- // --- HTTP helper ---
@ -223,13 +287,13 @@ func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, err
defer resp.Body.Close() defer resp.Body.Close()
// Limit response body read to prevent memory issues. // 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 { if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err) return nil, fmt.Errorf("failed to read response: %w", err)
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { 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 return body, nil
@ -314,10 +378,3 @@ func derefStr(s *string, fallback string) string {
} }
return *s return *s
} }
func truncateBytes(b []byte, maxLen int) string {
if len(b) <= maxLen {
return string(b)
}
return string(b[:maxLen]) + "…"
}

View file

@ -88,7 +88,7 @@ func TestClawHubRegistryGetSkillMetaUnsafeSlug(t *testing.T) {
assert.Contains(t, err.Error(), "invalid slug") assert.Contains(t, err.Error(), "invalid slug")
} }
func TestClawHubRegistryDownloadAndExtract(t *testing.T) { func TestClawHubRegistryDownloadAndInstall(t *testing.T) {
// Create a valid ZIP in memory. // Create a valid ZIP in memory.
zipBuf := createTestZip(t, map[string]string{ zipBuf := createTestZip(t, map[string]string{
"SKILL.md": "---\nname: test-skill\ndescription: A test\n---\nHello skill", "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) { 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")) assert.Equal(t, "test-skill", r.URL.Query().Get("slug"))
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Write(zipBuf) w.Write(zipBuf)
default:
w.WriteHeader(http.StatusNotFound)
}
})) }))
defer srv.Close() defer srv.Close()
@ -107,9 +119,11 @@ func TestClawHubRegistryDownloadAndExtract(t *testing.T) {
targetDir := filepath.Join(tmpDir, "test-skill") targetDir := filepath.Join(tmpDir, "test-skill")
reg := newTestRegistry(srv.URL, "") 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) require.NoError(t, err)
assert.Equal(t, "1.0.0", result.Version)
assert.False(t, result.IsMalwareBlocked)
// Verify extracted files. // Verify extracted files.
skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) 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") 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) { func TestClawHubRegistrySearchHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -190,10 +199,18 @@ func TestClawHubRegistrySearchHTTPError(t *testing.T) {
func TestClawHubRegistrySearchNullableFields(t *testing.T) { func TestClawHubRegistrySearchNullableFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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{ json.NewEncoder(w).Encode(clawhubSearchResponse{
Results: []clawhubSearchResult{ 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) results, err := reg.Search(context.Background(), "test", 5)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, results, 1) require.Len(t, results, 1, "should only return 1 valid result")
assert.Equal(t, "unknown", results[0].Slug, "null slug should default to 'unknown'")
assert.Equal(t, "", results[0].DisplayName)
}
func TestClawHubRegistryCustomPaths(t *testing.T) { r := results[0]
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "valid-slug", r.Slug)
assert.Equal(t, "/custom/search", r.URL.Path) assert.Equal(t, "valid-slug", r.DisplayName, "should fallback name to slug")
json.NewEncoder(w).Encode(clawhubSearchResponse{Results: nil}) assert.Equal(t, "valid summary", r.Summary)
}))
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)
} }
// --- helpers --- // --- helpers ---

View file

@ -8,6 +8,10 @@ import (
"time" "time"
) )
const (
defaultMaxConcurrentSearches = 2
)
// SearchResult represents a single result from a skill registry search. // SearchResult represents a single result from a skill registry search.
type SearchResult struct { type SearchResult struct {
Score float64 `json:"score"` Score float64 `json:"score"`
@ -29,8 +33,17 @@ type SkillMeta struct {
RegistryName string `json:"registry_name"` 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. // 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 { type SkillRegistry interface {
// Name returns the unique name of this registry (e.g., "clawhub"). // Name returns the unique name of this registry (e.g., "clawhub").
Name() string Name() string
@ -38,14 +51,17 @@ type SkillRegistry interface {
Search(ctx context.Context, query string, limit int) ([]SearchResult, error) Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
// GetSkillMeta retrieves metadata for a specific skill by slug. // GetSkillMeta retrieves metadata for a specific skill by slug.
GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error)
// DownloadAndExtract downloads a skill and extracts it to targetDir. // DownloadAndInstall fetches metadata, resolves the version, downloads and
DownloadAndExtract(ctx context.Context, slug, version, targetDir string) error // 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. // RegistryConfig holds configuration for all skill registries.
// This is the input to NewRegistryManagerFromConfig. // This is the input to NewRegistryManagerFromConfig.
type RegistryConfig struct { type RegistryConfig struct {
ClawHub ClawHubConfig ClawHub ClawHubConfig
MaxConcurrentSearches int
} }
// ClawHubConfig configures the ClawhHub registry. // ClawHubConfig configures the ClawhHub registry.
@ -56,12 +72,15 @@ type ClawHubConfig struct {
SearchPath string // e.g. "/api/v1/search" SearchPath string // e.g. "/api/v1/search"
SkillsPath string // e.g. "/api/v1/skills" SkillsPath string // e.g. "/api/v1/skills"
DownloadPath string // e.g. "/api/v1/download" DownloadPath string // e.g. "/api/v1/download"
Timeout int // seconds, 0 = default (30s)
MaxZipSize int // bytes, 0 = default (50MB)
} }
// RegistryManager coordinates multiple skill registries. // RegistryManager coordinates multiple skill registries.
// It fans out search requests and routes installs to the correct registry. // It fans out search requests and routes installs to the correct registry.
type RegistryManager struct { type RegistryManager struct {
registries []SkillRegistry registries []SkillRegistry
maxConcurrent int
mu sync.RWMutex mu sync.RWMutex
} }
@ -69,6 +88,7 @@ type RegistryManager struct {
func NewRegistryManager() *RegistryManager { func NewRegistryManager() *RegistryManager {
return &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. // instantiating only the enabled registries.
func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager { func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
rm := NewRegistryManager() 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)) rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub))
} }
return rm return rm
@ -101,7 +124,7 @@ func (rm *RegistryManager) GetRegistry(name string) SkillRegistry {
return nil 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. // and merges results sorted by score descending.
func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) { func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) {
rm.mu.RLock() rm.mu.RLock()
@ -118,8 +141,8 @@ func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit in
err error err error
} }
// Semaphore: limit concurrency to 2 goroutines for lightweight infra. // Semaphore: limit concurrency.
sem := make(chan struct{}, 2) sem := make(chan struct{}, rm.maxConcurrent)
resultsCh := make(chan regResult, len(regs)) resultsCh := make(chan regResult, len(regs))
var wg sync.WaitGroup var wg sync.WaitGroup

View file

@ -16,7 +16,8 @@ type mockRegistry struct {
searchErr error searchErr error
meta *SkillMeta meta *SkillMeta
metaErr error metaErr error
downloadErr error installResult *InstallResult
installErr error
} }
func (m *mockRegistry) Name() string { return m.name } 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 return m.meta, m.metaErr
} }
func (m *mockRegistry) DownloadAndExtract(_ context.Context, _, _, _ string) error { func (m *mockRegistry) DownloadAndInstall(_ context.Context, _, _, _ string) (*InstallResult, error) {
return m.downloadErr return m.installResult, m.installErr
} }
func TestRegistryManagerSearchAllSingle(t *testing.T) { func TestRegistryManagerSearchAllSingle(t *testing.T) {

View file

@ -9,8 +9,6 @@ import (
// SearchCache provides lightweight caching for search results. // SearchCache provides lightweight caching for search results.
// It uses trigram-based similarity to match similar queries to cached results, // It uses trigram-based similarity to match similar queries to cached results,
// avoiding redundant API calls. Thread-safe for concurrent access. // avoiding redundant API calls. Thread-safe for concurrent access.
//
// Memory budget: ~50 entries * ~2KB per entry ≈ ~100KB — well within <10MB target.
type SearchCache struct { type SearchCache struct {
mu sync.RWMutex mu sync.RWMutex
entries map[string]*cacheEntry entries map[string]*cacheEntry

View file

@ -104,58 +104,45 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) 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. // Ensure skills directory exists.
if err := os.MkdirAll(skillsDir, 0755); err != nil { if err := os.MkdirAll(skillsDir, 0755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
} }
// Download and extract. // Download and install (handles metadata, version resolution, extraction).
if err := registry.DownloadAndExtract(ctx, slug, installVersion, targetDir); err != nil { result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir)
if err != nil {
// Clean up partial install. // Clean up partial install.
os.RemoveAll(targetDir) os.RemoveAll(targetDir)
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err)) 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. // 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. // Non-fatal: skill is installed, just origin tracking failed.
_ = err _ = err
} }
// Build result with moderation warning if suspicious. // Build result with moderation warning if suspicious.
var result string var output string
if meta.IsSuspicious { if result.IsSuspicious {
result = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) 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", output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
slug, installVersion, registry.Name(), targetDir) slug, result.Version, registry.Name(), targetDir)
if meta.Summary != "" { if result.Summary != "" {
result += fmt.Sprintf("Description: %s\n", meta.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. // originMeta tracks which registry a skill was installed from.