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":
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) {

View file

@ -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": {

View file

@ -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))

View file

@ -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 {

View file

@ -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]) + "…"
}

View file

@ -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 ---

View file

@ -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

View file

@ -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) {

View file

@ -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

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))
}
// 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.