Add Find Skills and Install Skills

This commit is contained in:
harshbansal7 2026-02-17 04:30:24 +05:30
parent 13e4028d42
commit 1748868000
12 changed files with 1857 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
@ -101,6 +102,21 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
}) })
registry.Register(messageTool) registry.Register(messageTool)
// Skill discovery and installation tools
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
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,
},
})
searchCache := skills.NewSearchCache(50, 5*time.Minute)
registry.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
registry.Register(tools.NewInstallSkillTool(registryMgr, workspace))
return registry return registry
} }

View file

@ -213,6 +213,24 @@ type WebToolsConfig struct {
type ToolsConfig struct { type ToolsConfig struct {
Web WebToolsConfig `json:"web"` Web WebToolsConfig `json:"web"`
Skills SkillsToolsConfig `json:"skills"`
}
type SkillsToolsConfig struct {
Registries SkillsRegistriesConfig `json:"registries"`
}
type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub"`
}
type ClawHubRegistryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
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"`
} }
func DefaultConfig() *Config { func DefaultConfig() *Config {
@ -322,6 +340,14 @@ func DefaultConfig() *Config {
MaxResults: 5, MaxResults: 5,
}, },
}, },
Skills: SkillsToolsConfig{
Registries: SkillsRegistriesConfig{
ClawHub: ClawHubRegistryConfig{
Enabled: true,
BaseURL: "https://clawhub.ai",
},
},
},
}, },
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{
Enabled: true, Enabled: true,

View file

@ -0,0 +1,323 @@
package skills
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const (
clawHubDefaultTimeout = 15 * time.Second
maxZipSize = 10 * 1024 * 1024 // 10 MB max ZIP size
)
// ClawHubRegistry implements SkillRegistry for the ClawhHub platform.
type ClawHubRegistry struct {
baseURL string
authToken string
searchPath string
skillsPath string
downloadPath string
client *http.Client
}
// NewClawHubRegistry creates a new ClawhHub registry client from config.
func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
searchPath := cfg.SearchPath
if searchPath == "" {
searchPath = "/api/v1/search"
}
skillsPath := cfg.SkillsPath
if skillsPath == "" {
skillsPath = "/api/v1/skills"
}
downloadPath := cfg.DownloadPath
if downloadPath == "" {
downloadPath = "/api/v1/download"
}
return &ClawHubRegistry{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
authToken: cfg.AuthToken,
searchPath: searchPath,
skillsPath: skillsPath,
downloadPath: downloadPath,
client: &http.Client{
Timeout: clawHubDefaultTimeout,
Transport: &http.Transport{
MaxIdleConns: 5,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
},
}
}
func (c *ClawHubRegistry) Name() string {
return "clawhub"
}
// --- Search ---
type clawhubSearchResponse struct {
Results []clawhubSearchResult `json:"results"`
}
type clawhubSearchResult struct {
Score float64 `json:"score"`
Slug *string `json:"slug"`
DisplayName *string `json:"displayName"`
Summary *string `json:"summary"`
Version *string `json:"version"`
}
func (c *ClawHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
u, err := url.Parse(c.baseURL + c.searchPath)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
q := u.Query()
q.Set("q", query)
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}
u.RawQuery = q.Encode()
body, err := c.doGet(ctx, u.String())
if err != nil {
return nil, fmt.Errorf("search request failed: %w", err)
}
var resp clawhubSearchResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("failed to parse search response: %w", err)
}
results := make([]SearchResult, 0, len(resp.Results))
for _, r := range resp.Results {
results = append(results, SearchResult{
Score: r.Score,
Slug: derefStr(r.Slug, "unknown"),
DisplayName: derefStr(r.DisplayName, ""),
Summary: derefStr(r.Summary, ""),
Version: derefStr(r.Version, ""),
RegistryName: c.Name(),
})
}
return results, nil
}
// --- GetSkillMeta ---
type clawhubSkillResponse struct {
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
LatestVersion *clawhubVersionInfo `json:"latestVersion"`
Moderation *clawhubModerationInfo `json:"moderation"`
}
type clawhubVersionInfo struct {
Version string `json:"version"`
}
type clawhubModerationInfo struct {
IsMalwareBlocked bool `json:"isMalwareBlocked"`
IsSuspicious bool `json:"isSuspicious"`
}
func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) {
if !isSafeSlug(slug) {
return nil, fmt.Errorf("invalid slug: %q", slug)
}
u := c.baseURL + c.skillsPath + "/" + url.PathEscape(slug)
body, err := c.doGet(ctx, u)
if err != nil {
return nil, fmt.Errorf("skill metadata request failed: %w", err)
}
var resp clawhubSkillResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("failed to parse skill metadata: %w", err)
}
meta := &SkillMeta{
Slug: resp.Slug,
DisplayName: resp.DisplayName,
Summary: resp.Summary,
RegistryName: c.Name(),
}
if resp.LatestVersion != nil {
meta.LatestVersion = resp.LatestVersion.Version
}
if resp.Moderation != nil {
meta.IsMalwareBlocked = resp.Moderation.IsMalwareBlocked
meta.IsSuspicious = resp.Moderation.IsSuspicious
}
return meta, nil
}
// --- DownloadAndExtract ---
func (c *ClawHubRegistry) DownloadAndExtract(ctx context.Context, slug, version, targetDir string) error {
if !isSafeSlug(slug) {
return fmt.Errorf("invalid slug: %q", slug)
}
u, err := url.Parse(c.baseURL + c.downloadPath)
if err != nil {
return fmt.Errorf("invalid base URL: %w", err)
}
q := u.Query()
q.Set("slug", slug)
if version != "" {
q.Set("version", version)
}
u.RawQuery = q.Encode()
zipData, err := c.doGet(ctx, u.String())
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
if len(zipData) > maxZipSize {
return fmt.Errorf("ZIP too large: %d bytes (max %d)", len(zipData), maxZipSize)
}
return extractZip(zipData, targetDir)
}
// --- HTTP helper ---
func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if c.authToken != "" {
req.Header.Set("Authorization", "Bearer "+c.authToken)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Limit response body read to prevent memory issues.
body, err := io.ReadAll(io.LimitReader(resp.Body, 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 body, nil
}
// --- ZIP extraction ---
func extractZip(data []byte, targetDir string) error {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return fmt.Errorf("invalid ZIP: %w", err)
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create target dir: %w", err)
}
for _, f := range reader.File {
// Path traversal protection.
cleanName := filepath.Clean(f.Name)
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
return fmt.Errorf("zip entry has unsafe path: %q", f.Name)
}
destPath := filepath.Join(targetDir, cleanName)
// Double-check the resolved path is within target.
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(targetDir)) {
return fmt.Errorf("zip entry escapes target dir: %q", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, 0755); err != nil {
return err
}
continue
}
// Ensure parent directory exists.
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open zip entry %q: %w", f.Name, err)
}
outFile, err := os.Create(destPath)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create file %q: %w", destPath, err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract %q: %w", f.Name, err)
}
}
return nil
}
// --- Utilities ---
func isSafeSlug(slug string) bool {
slug = strings.TrimSpace(slug)
if slug == "" {
return false
}
if strings.ContainsAny(slug, "/\\") || strings.Contains(slug, "..") {
return false
}
return true
}
func derefStr(s *string, fallback string) string {
if s == nil {
return fallback
}
return *s
}
func truncateBytes(b []byte, maxLen int) string {
if len(b) <= maxLen {
return string(b)
}
return string(b[:maxLen]) + "…"
}

View file

@ -0,0 +1,244 @@
package skills
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestRegistry(serverURL, authToken string) *ClawHubRegistry {
return NewClawHubRegistry(ClawHubConfig{
Enabled: true,
BaseURL: serverURL,
AuthToken: authToken,
})
}
func TestClawHubRegistrySearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/search", r.URL.Path)
assert.Equal(t, "github", r.URL.Query().Get("q"))
slug := "github"
name := "GitHub Integration"
summary := "Interact with GitHub repos"
version := "1.0.0"
json.NewEncoder(w).Encode(clawhubSearchResponse{
Results: []clawhubSearchResult{
{Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version},
},
})
}))
defer srv.Close()
reg := newTestRegistry(srv.URL, "")
results, err := reg.Search(context.Background(), "github", 5)
require.NoError(t, err)
require.Len(t, results, 1)
assert.Equal(t, "github", results[0].Slug)
assert.Equal(t, "GitHub Integration", results[0].DisplayName)
assert.InDelta(t, 0.95, results[0].Score, 0.001)
assert.Equal(t, "clawhub", results[0].RegistryName)
}
func TestClawHubRegistryGetSkillMeta(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/skills/github", r.URL.Path)
json.NewEncoder(w).Encode(clawhubSkillResponse{
Slug: "github",
DisplayName: "GitHub Integration",
Summary: "Full GitHub API integration",
LatestVersion: &clawhubVersionInfo{
Version: "2.1.0",
},
Moderation: &clawhubModerationInfo{
IsMalwareBlocked: false,
IsSuspicious: true,
},
})
}))
defer srv.Close()
reg := newTestRegistry(srv.URL, "")
meta, err := reg.GetSkillMeta(context.Background(), "github")
require.NoError(t, err)
assert.Equal(t, "github", meta.Slug)
assert.Equal(t, "2.1.0", meta.LatestVersion)
assert.False(t, meta.IsMalwareBlocked)
assert.True(t, meta.IsSuspicious)
}
func TestClawHubRegistryGetSkillMetaUnsafeSlug(t *testing.T) {
reg := newTestRegistry("https://example.com", "")
_, err := reg.GetSkillMeta(context.Background(), "../etc/passwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid slug")
}
func TestClawHubRegistryDownloadAndExtract(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",
"README.md": "# Test Skill\n",
})
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)
}))
defer srv.Close()
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "test-skill")
reg := newTestRegistry(srv.URL, "")
err := reg.DownloadAndExtract(context.Background(), "test-skill", "1.0.0", targetDir)
require.NoError(t, err)
// Verify extracted files.
skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md"))
require.NoError(t, err)
assert.Contains(t, string(skillContent), "Hello skill")
readmeContent, err := os.ReadFile(filepath.Join(targetDir, "README.md"))
require.NoError(t, err)
assert.Contains(t, string(readmeContent), "# Test Skill")
}
func TestClawHubRegistryAuthToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
assert.Equal(t, "Bearer test-token-123", authHeader)
json.NewEncoder(w).Encode(clawhubSearchResponse{Results: nil})
}))
defer srv.Close()
reg := newTestRegistry(srv.URL, "test-token-123")
_, _ = reg.Search(context.Background(), "test", 5)
}
func TestExtractZipPathTraversal(t *testing.T) {
// Create a ZIP with a path traversal entry.
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// Malicious entry trying to escape directory.
w, err := zw.Create("../../etc/passwd")
require.NoError(t, err)
w.Write([]byte("malicious"))
zw.Close()
tmpDir := t.TempDir()
err = extractZip(buf.Bytes(), tmpDir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsafe path")
}
func TestExtractZipWithSubdirectories(t *testing.T) {
zipBuf := createTestZip(t, map[string]string{
"SKILL.md": "root file",
"scripts/helper.sh": "#!/bin/bash\necho hello",
"examples/demo.yaml": "key: value",
})
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "my-skill")
err := extractZip(zipBuf, targetDir)
require.NoError(t, err)
// Verify nested file.
data, err := os.ReadFile(filepath.Join(targetDir, "scripts", "helper.sh"))
require.NoError(t, err)
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)
w.Write([]byte("Internal Server Error"))
}))
defer srv.Close()
reg := newTestRegistry(srv.URL, "")
_, err := reg.Search(context.Background(), "test", 5)
assert.Error(t, err)
assert.Contains(t, err.Error(), "500")
}
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).
json.NewEncoder(w).Encode(clawhubSearchResponse{
Results: []clawhubSearchResult{
{Score: 0.8, Slug: nil, DisplayName: nil, Summary: nil, Version: nil},
},
})
}))
defer srv.Close()
reg := newTestRegistry(srv.URL, "")
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)
}
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)
}
// --- helpers ---
func createTestZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range files {
w, err := zw.Create(name)
require.NoError(t, err)
_, err = w.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}

197
pkg/skills/registry.go Normal file
View file

@ -0,0 +1,197 @@
package skills
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
// SearchResult represents a single result from a skill registry search.
type SearchResult struct {
Score float64 `json:"score"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
Version string `json:"version"`
RegistryName string `json:"registry_name"`
}
// SkillMeta holds metadata about a skill from a registry.
type SkillMeta struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
LatestVersion string `json:"latest_version"`
IsMalwareBlocked bool `json:"is_malware_blocked"`
IsSuspicious bool `json:"is_suspicious"`
RegistryName string `json:"registry_name"`
}
// SkillRegistry is the interface that all skill registries must implement.
// Each registry represents a different source of skills (e.g., ClawhHub, GitHub, etc.)
type SkillRegistry interface {
// Name returns the unique name of this registry (e.g., "clawhub").
Name() string
// Search searches the registry for skills matching the query.
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
}
// RegistryConfig holds configuration for all skill registries.
// This is the input to NewRegistryManagerFromConfig.
type RegistryConfig struct {
ClawHub ClawHubConfig
}
// ClawHubConfig configures the ClawhHub registry.
type ClawHubConfig struct {
Enabled bool
BaseURL string
AuthToken string
SearchPath string // e.g. "/api/v1/search"
SkillsPath string // e.g. "/api/v1/skills"
DownloadPath string // e.g. "/api/v1/download"
}
// 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
}
// NewRegistryManager creates an empty RegistryManager.
func NewRegistryManager() *RegistryManager {
return &RegistryManager{
registries: make([]SkillRegistry, 0),
}
}
// NewRegistryManagerFromConfig builds a RegistryManager from config,
// instantiating only the enabled registries.
func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
rm := NewRegistryManager()
if cfg.ClawHub.Enabled && cfg.ClawHub.BaseURL != "" {
rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub))
}
return rm
}
// AddRegistry adds a registry to the manager.
func (rm *RegistryManager) AddRegistry(r SkillRegistry) {
rm.mu.Lock()
defer rm.mu.Unlock()
rm.registries = append(rm.registries, r)
}
// GetRegistry returns a registry by name, or nil if not found.
func (rm *RegistryManager) GetRegistry(name string) SkillRegistry {
rm.mu.RLock()
defer rm.mu.RUnlock()
for _, r := range rm.registries {
if r.Name() == name {
return r
}
}
return nil
}
// SearchAll fans out the query to all registries concurrently (max 2 goroutines)
// and merges results sorted by score descending.
func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) {
rm.mu.RLock()
regs := make([]SkillRegistry, len(rm.registries))
copy(regs, rm.registries)
rm.mu.RUnlock()
if len(regs) == 0 {
return nil, fmt.Errorf("no registries configured")
}
type regResult struct {
results []SearchResult
err error
}
// Semaphore: limit concurrency to 2 goroutines for lightweight infra.
sem := make(chan struct{}, 2)
resultsCh := make(chan regResult, len(regs))
var wg sync.WaitGroup
for _, reg := range regs {
wg.Add(1)
go func(r SkillRegistry) {
defer wg.Done()
// Acquire semaphore slot.
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
resultsCh <- regResult{err: ctx.Err()}
return
}
searchCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
results, err := r.Search(searchCtx, query, limit)
if err != nil {
slog.Warn("registry search failed", "registry", r.Name(), "error", err)
resultsCh <- regResult{err: err}
return
}
resultsCh <- regResult{results: results}
}(reg)
}
// Close results channel after all goroutines complete.
go func() {
wg.Wait()
close(resultsCh)
}()
var merged []SearchResult
var lastErr error
for rr := range resultsCh {
if rr.err != nil {
lastErr = rr.err
continue
}
merged = append(merged, rr.results...)
}
// If all registries failed, return the last error.
if len(merged) == 0 && lastErr != nil {
return nil, fmt.Errorf("all registries failed: %w", lastErr)
}
// Sort by score descending.
sortByScoreDesc(merged)
// Clamp to limit.
if limit > 0 && len(merged) > limit {
merged = merged[:limit]
}
return merged, nil
}
// sortByScoreDesc sorts SearchResults by Score in descending order (insertion sort — small slices).
func sortByScoreDesc(results []SearchResult) {
for i := 1; i < len(results); i++ {
key := results[i]
j := i - 1
for j >= 0 && results[j].Score < key.Score {
results[j+1] = results[j]
j--
}
results[j+1] = key
}
}

177
pkg/skills/registry_test.go Normal file
View file

@ -0,0 +1,177 @@
package skills
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// mockRegistry is a test double implementing SkillRegistry.
type mockRegistry struct {
name string
searchResults []SearchResult
searchErr error
meta *SkillMeta
metaErr error
downloadErr error
}
func (m *mockRegistry) Name() string { return m.name }
func (m *mockRegistry) Search(_ context.Context, _ string, _ int) ([]SearchResult, error) {
return m.searchResults, m.searchErr
}
func (m *mockRegistry) GetSkillMeta(_ context.Context, _ string) (*SkillMeta, error) {
return m.meta, m.metaErr
}
func (m *mockRegistry) DownloadAndExtract(_ context.Context, _, _, _ string) error {
return m.downloadErr
}
func TestRegistryManagerSearchAllSingle(t *testing.T) {
mgr := NewRegistryManager()
mgr.AddRegistry(&mockRegistry{
name: "test",
searchResults: []SearchResult{
{Slug: "skill-a", Score: 0.9, RegistryName: "test"},
{Slug: "skill-b", Score: 0.5, RegistryName: "test"},
},
})
results, err := mgr.SearchAll(context.Background(), "test query", 10)
assert.NoError(t, err)
assert.Len(t, results, 2)
assert.Equal(t, "skill-a", results[0].Slug)
}
func TestRegistryManagerSearchAllMultiple(t *testing.T) {
mgr := NewRegistryManager()
mgr.AddRegistry(&mockRegistry{
name: "alpha",
searchResults: []SearchResult{
{Slug: "skill-a", Score: 0.8, RegistryName: "alpha"},
},
})
mgr.AddRegistry(&mockRegistry{
name: "beta",
searchResults: []SearchResult{
{Slug: "skill-b", Score: 0.95, RegistryName: "beta"},
},
})
results, err := mgr.SearchAll(context.Background(), "test query", 10)
assert.NoError(t, err)
assert.Len(t, results, 2)
// Should be sorted by score descending
assert.Equal(t, "skill-b", results[0].Slug)
assert.Equal(t, "skill-a", results[1].Slug)
}
func TestRegistryManagerSearchAllOneFailsGracefully(t *testing.T) {
mgr := NewRegistryManager()
mgr.AddRegistry(&mockRegistry{
name: "failing",
searchErr: fmt.Errorf("network error"),
})
mgr.AddRegistry(&mockRegistry{
name: "working",
searchResults: []SearchResult{
{Slug: "skill-a", Score: 0.8, RegistryName: "working"},
},
})
results, err := mgr.SearchAll(context.Background(), "test query", 10)
assert.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, "skill-a", results[0].Slug)
}
func TestRegistryManagerSearchAllAllFail(t *testing.T) {
mgr := NewRegistryManager()
mgr.AddRegistry(&mockRegistry{
name: "fail-1",
searchErr: fmt.Errorf("error 1"),
})
_, err := mgr.SearchAll(context.Background(), "test query", 10)
assert.Error(t, err)
}
func TestRegistryManagerSearchAllNoRegistries(t *testing.T) {
mgr := NewRegistryManager()
_, err := mgr.SearchAll(context.Background(), "test query", 10)
assert.Error(t, err)
}
func TestRegistryManagerGetRegistry(t *testing.T) {
mgr := NewRegistryManager()
mock := &mockRegistry{name: "clawhub"}
mgr.AddRegistry(mock)
got := mgr.GetRegistry("clawhub")
assert.NotNil(t, got)
assert.Equal(t, "clawhub", got.Name())
got = mgr.GetRegistry("nonexistent")
assert.Nil(t, got)
}
func TestRegistryManagerSearchAllRespectLimit(t *testing.T) {
mgr := NewRegistryManager()
results := make([]SearchResult, 20)
for i := range results {
results[i] = SearchResult{Slug: fmt.Sprintf("skill-%d", i), Score: float64(20 - i)}
}
mgr.AddRegistry(&mockRegistry{
name: "test",
searchResults: results,
})
got, err := mgr.SearchAll(context.Background(), "test", 5)
assert.NoError(t, err)
assert.Len(t, got, 5)
// Top scores first
assert.Equal(t, "skill-0", got[0].Slug)
}
func TestRegistryManagerSearchAllTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
time.Sleep(5 * time.Millisecond) // Let context expire.
mgr := NewRegistryManager()
mgr.AddRegistry(&mockRegistry{
name: "slow",
searchErr: fmt.Errorf("context deadline exceeded"),
})
_, err := mgr.SearchAll(ctx, "test", 5)
assert.Error(t, err)
}
func TestSortByScoreDesc(t *testing.T) {
results := []SearchResult{
{Slug: "c", Score: 0.3},
{Slug: "a", Score: 0.9},
{Slug: "b", Score: 0.5},
}
sortByScoreDesc(results)
assert.Equal(t, "a", results[0].Slug)
assert.Equal(t, "b", results[1].Slug)
assert.Equal(t, "c", results[2].Slug)
}
func TestIsSafeSlug(t *testing.T) {
assert.True(t, isSafeSlug("github"))
assert.True(t, isSafeSlug("docker-compose"))
assert.False(t, isSafeSlug(""))
assert.False(t, isSafeSlug("../etc/passwd"))
assert.False(t, isSafeSlug("path/traversal"))
assert.False(t, isSafeSlug("path\\traversal"))
}

214
pkg/skills/search_cache.go Normal file
View file

@ -0,0 +1,214 @@
package skills
import (
"strings"
"sync"
"time"
)
// 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
order []string // LRU order: oldest first.
maxEntries int
ttl time.Duration
}
type cacheEntry struct {
query string
trigrams map[string]struct{}
results []SearchResult
createdAt time.Time
}
// similarityThreshold is the minimum trigram Jaccard similarity for a cache hit.
const similarityThreshold = 0.7
// NewSearchCache creates a new search cache.
// maxEntries is the maximum number of cached queries (excess evicts LRU).
// ttl is how long each entry lives before expiration.
func NewSearchCache(maxEntries int, ttl time.Duration) *SearchCache {
if maxEntries <= 0 {
maxEntries = 50
}
if ttl <= 0 {
ttl = 5 * time.Minute
}
return &SearchCache{
entries: make(map[string]*cacheEntry),
order: make([]string, 0),
maxEntries: maxEntries,
ttl: ttl,
}
}
// Get looks up results for a query. Returns cached results and true if found
// (either exact or similar match above threshold). Returns nil, false on miss.
func (sc *SearchCache) Get(query string) ([]SearchResult, bool) {
normalized := normalizeQuery(query)
if normalized == "" {
return nil, false
}
sc.mu.RLock()
defer sc.mu.RUnlock()
// Exact match first.
if entry, ok := sc.entries[normalized]; ok {
if time.Since(entry.createdAt) < sc.ttl {
return copyResults(entry.results), true
}
}
// Similarity match.
queryTrigrams := buildTrigrams(normalized)
var bestEntry *cacheEntry
var bestSim float64
for _, entry := range sc.entries {
if time.Since(entry.createdAt) >= sc.ttl {
continue // Skip expired.
}
sim := jaccardSimilarity(queryTrigrams, entry.trigrams)
if sim > bestSim {
bestSim = sim
bestEntry = entry
}
}
if bestSim >= similarityThreshold && bestEntry != nil {
return copyResults(bestEntry.results), true
}
return nil, false
}
// Put stores results for a query. Evicts the oldest entry if at capacity.
func (sc *SearchCache) Put(query string, results []SearchResult) {
normalized := normalizeQuery(query)
if normalized == "" {
return
}
sc.mu.Lock()
defer sc.mu.Unlock()
// Evict expired entries first.
sc.evictExpiredLocked()
// If already exists, update.
if _, ok := sc.entries[normalized]; ok {
sc.entries[normalized] = &cacheEntry{
query: normalized,
trigrams: buildTrigrams(normalized),
results: copyResults(results),
createdAt: time.Now(),
}
// Move to end of LRU order.
sc.moveToEndLocked(normalized)
return
}
// Evict LRU if at capacity.
for len(sc.entries) >= sc.maxEntries && len(sc.order) > 0 {
oldest := sc.order[0]
sc.order = sc.order[1:]
delete(sc.entries, oldest)
}
// Insert new entry.
sc.entries[normalized] = &cacheEntry{
query: normalized,
trigrams: buildTrigrams(normalized),
results: copyResults(results),
createdAt: time.Now(),
}
sc.order = append(sc.order, normalized)
}
// Len returns the number of entries (for testing).
func (sc *SearchCache) Len() int {
sc.mu.RLock()
defer sc.mu.RUnlock()
return len(sc.entries)
}
// --- internal ---
func (sc *SearchCache) evictExpiredLocked() {
now := time.Now()
newOrder := make([]string, 0, len(sc.order))
for _, key := range sc.order {
entry, ok := sc.entries[key]
if !ok || now.Sub(entry.createdAt) >= sc.ttl {
delete(sc.entries, key)
continue
}
newOrder = append(newOrder, key)
}
sc.order = newOrder
}
func (sc *SearchCache) moveToEndLocked(key string) {
for i, k := range sc.order {
if k == key {
sc.order = append(sc.order[:i], sc.order[i+1:]...)
break
}
}
sc.order = append(sc.order, key)
}
func normalizeQuery(q string) string {
return strings.ToLower(strings.TrimSpace(q))
}
// buildTrigrams generates character trigrams from a string.
// Example: "hello" → {"hel", "ell", "llo"}
func buildTrigrams(s string) map[string]struct{} {
trigrams := make(map[string]struct{})
runes := []rune(s)
for i := 0; i <= len(runes)-3; i++ {
tri := string(runes[i : i+3])
trigrams[tri] = struct{}{}
}
return trigrams
}
// jaccardSimilarity computes |A ∩ B| / |A B|.
func jaccardSimilarity(a, b map[string]struct{}) float64 {
if len(a) == 0 && len(b) == 0 {
return 1.0
}
if len(a) == 0 || len(b) == 0 {
return 0.0
}
intersection := 0
for k := range a {
if _, ok := b[k]; ok {
intersection++
}
}
union := len(a) + len(b) - intersection
if union == 0 {
return 0.0
}
return float64(intersection) / float64(union)
}
func copyResults(results []SearchResult) []SearchResult {
if results == nil {
return nil
}
cp := make([]SearchResult, len(results))
copy(cp, results)
return cp
}

View file

@ -0,0 +1,172 @@
package skills
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestSearchCacheExactHit(t *testing.T) {
cache := NewSearchCache(10, 5*time.Minute)
results := []SearchResult{
{Slug: "github", Score: 0.9, RegistryName: "clawhub"},
{Slug: "docker", Score: 0.7, RegistryName: "clawhub"},
}
cache.Put("github integration", results)
got, hit := cache.Get("github integration")
assert.True(t, hit)
assert.Len(t, got, 2)
assert.Equal(t, "github", got[0].Slug)
}
func TestSearchCacheExactHitCaseInsensitive(t *testing.T) {
cache := NewSearchCache(10, 5*time.Minute)
results := []SearchResult{{Slug: "github", Score: 0.9}}
cache.Put("GitHub Integration", results)
got, hit := cache.Get("github integration")
assert.True(t, hit)
assert.Len(t, got, 1)
}
func TestSearchCacheSimilarHit(t *testing.T) {
cache := NewSearchCache(10, 5*time.Minute)
results := []SearchResult{{Slug: "github", Score: 0.9}}
cache.Put("github integration tool", results)
// "github integration" is very similar to "github integration tool"
got, hit := cache.Get("github integration")
assert.True(t, hit)
assert.Len(t, got, 1)
}
func TestSearchCacheDissimilarMiss(t *testing.T) {
cache := NewSearchCache(10, 5*time.Minute)
results := []SearchResult{{Slug: "github", Score: 0.9}}
cache.Put("github integration", results)
// Completely unrelated query
_, hit := cache.Get("database management")
assert.False(t, hit)
}
func TestSearchCacheTTLExpiration(t *testing.T) {
cache := NewSearchCache(10, 50*time.Millisecond)
results := []SearchResult{{Slug: "github", Score: 0.9}}
cache.Put("github integration", results)
// Immediately should hit
_, hit := cache.Get("github integration")
assert.True(t, hit)
// Wait for expiration
time.Sleep(100 * time.Millisecond)
_, hit = cache.Get("github integration")
assert.False(t, hit)
}
func TestSearchCacheLRUEviction(t *testing.T) {
cache := NewSearchCache(3, 5*time.Minute)
cache.Put("query-1", []SearchResult{{Slug: "a"}})
cache.Put("query-2", []SearchResult{{Slug: "b"}})
cache.Put("query-3", []SearchResult{{Slug: "c"}})
assert.Equal(t, 3, cache.Len())
// Adding a 4th should evict query-1 (oldest)
cache.Put("query-4", []SearchResult{{Slug: "d"}})
assert.Equal(t, 3, cache.Len())
_, hit := cache.Get("query-1")
assert.False(t, hit, "oldest entry should be evicted")
got, hit := cache.Get("query-4")
assert.True(t, hit)
assert.Equal(t, "d", got[0].Slug)
}
func TestSearchCacheEmptyQuery(t *testing.T) {
cache := NewSearchCache(10, 5*time.Minute)
_, hit := cache.Get("")
assert.False(t, hit)
_, hit = cache.Get(" ")
assert.False(t, hit)
}
func TestSearchCacheResultsCopied(t *testing.T) {
cache := NewSearchCache(10, 5*time.Minute)
original := []SearchResult{{Slug: "github", Score: 0.9}}
cache.Put("test", original)
// Mutate original after putting
original[0].Slug = "mutated"
got, hit := cache.Get("test")
assert.True(t, hit)
assert.Equal(t, "github", got[0].Slug, "cache should hold a copy, not a reference")
}
func TestBuildTrigrams(t *testing.T) {
trigrams := buildTrigrams("hello")
assert.Contains(t, trigrams, "hel")
assert.Contains(t, trigrams, "ell")
assert.Contains(t, trigrams, "llo")
assert.Len(t, trigrams, 3)
}
func TestJaccardSimilarity(t *testing.T) {
a := buildTrigrams("github integration")
b := buildTrigrams("github integration tool")
sim := jaccardSimilarity(a, b)
assert.Greater(t, sim, 0.5, "similar strings should have high sim")
c := buildTrigrams("completely different query about databases")
sim2 := jaccardSimilarity(a, c)
assert.Less(t, sim2, 0.3, "dissimilar strings should have low sim")
}
func TestJaccardSimilarityEdgeCases(t *testing.T) {
empty := buildTrigrams("")
nonempty := buildTrigrams("hello")
assert.Equal(t, 1.0, jaccardSimilarity(empty, empty))
assert.Equal(t, 0.0, jaccardSimilarity(empty, nonempty))
assert.Equal(t, 0.0, jaccardSimilarity(nonempty, empty))
}
func TestSearchCacheConcurrency(t *testing.T) {
cache := NewSearchCache(50, 5*time.Minute)
done := make(chan struct{})
// Concurrent writes
go func() {
for i := 0; i < 100; i++ {
cache.Put("query-write-"+string(rune('a'+i%26)), []SearchResult{{Slug: "x"}})
}
done <- struct{}{}
}()
// Concurrent reads
go func() {
for i := 0; i < 100; i++ {
cache.Get("query-write-a")
}
done <- struct{}{}
}()
<-done
<-done
}

185
pkg/tools/skills_install.go Normal file
View file

@ -0,0 +1,185 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/skills"
)
// InstallSkillTool allows the LLM agent to install skills from registries.
// It shares the same RegistryManager that FindSkillsTool uses,
// so all registries configured in config are available for installation.
type InstallSkillTool struct {
registryMgr *skills.RegistryManager
workspace string
}
// NewInstallSkillTool creates a new InstallSkillTool.
// registryMgr is the shared registry manager (same instance as FindSkillsTool).
// workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/.
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
return &InstallSkillTool{
registryMgr: registryMgr,
workspace: workspace,
}
}
func (t *InstallSkillTool) Name() string {
return "install_skill"
}
func (t *InstallSkillTool) Description() string {
return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
}
func (t *InstallSkillTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"slug": map[string]interface{}{
"type": "string",
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
},
"version": map[string]interface{}{
"type": "string",
"description": "Specific version to install (optional, defaults to latest)",
},
"registry": map[string]interface{}{
"type": "string",
"description": "Registry to install from (required, e.g., 'clawhub')",
},
"force": map[string]interface{}{
"type": "boolean",
"description": "Force reinstall if skill already exists (default false)",
},
},
"required": []string{"slug", "registry"},
}
}
func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
slug, ok := args["slug"].(string)
if !ok || strings.TrimSpace(slug) == "" {
return ErrorResult("slug is required and must be a non-empty string")
}
slug = strings.TrimSpace(slug)
// Validate slug safety.
if strings.ContainsAny(slug, "/\\") || strings.Contains(slug, "..") {
return ErrorResult(fmt.Sprintf("invalid slug: %q (must not contain path separators or '..')", slug))
}
version, _ := args["version"].(string)
registryName, ok := args["registry"].(string)
if !ok || strings.TrimSpace(registryName) == "" {
return ErrorResult("registry is required")
}
registryName = strings.TrimSpace(registryName)
force, _ := args["force"].(bool)
// Check if already installed.
skillsDir := filepath.Join(t.workspace, "skills")
targetDir := filepath.Join(skillsDir, slug)
if !force {
if _, err := os.Stat(targetDir); err == nil {
return ErrorResult(fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir))
}
} else {
// Force: remove existing if present.
os.RemoveAll(targetDir)
}
// Resolve which registry to use.
registry := t.registryMgr.GetRegistry(registryName)
if registry == nil {
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 {
// Clean up partial install.
os.RemoveAll(targetDir)
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
}
// Write origin metadata.
if err := writeOriginMeta(targetDir, registry.Name(), slug, installVersion); 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)
}
result += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
slug, installVersion, registry.Name(), targetDir)
if meta.Summary != "" {
result += fmt.Sprintf("Description: %s\n", meta.Summary)
}
result += "\nThe skill is now available and can be loaded in the current session."
return SilentResult(result)
}
// originMeta tracks which registry a skill was installed from.
type originMeta struct {
Version int `json:"version"`
Registry string `json:"registry"`
Slug string `json:"slug"`
InstalledVersion string `json:"installed_version"`
InstalledAt int64 `json:"installed_at"`
}
func writeOriginMeta(targetDir, registryName, slug, version string) error {
meta := originMeta{
Version: 1,
Registry: registryName,
Slug: slug,
InstalledVersion: version,
InstalledAt: time.Now().UnixMilli(),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(targetDir, ".clawhub-origin.json"), data, 0644)
}

View file

@ -0,0 +1,102 @@
package tools
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInstallSkillToolName(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
assert.Equal(t, "install_skill", tool.Name())
}
func TestInstallSkillToolMissingSlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
result := tool.Execute(context.Background(), map[string]interface{}{})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "slug is required")
}
func TestInstallSkillToolEmptySlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
result := tool.Execute(context.Background(), map[string]interface{}{
"slug": " ",
})
assert.True(t, result.IsError)
}
func TestInstallSkillToolUnsafeSlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
cases := []string{
"../etc/passwd",
"path/traversal",
"path\\traversal",
}
for _, slug := range cases {
result := tool.Execute(context.Background(), map[string]interface{}{
"slug": slug,
})
assert.True(t, result.IsError, "slug %q should be rejected", slug)
assert.Contains(t, result.ForLLM, "invalid slug")
}
}
func TestInstallSkillToolAlreadyExists(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "existing-skill")
require.NoError(t, os.MkdirAll(skillDir, 0755))
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
result := tool.Execute(context.Background(), map[string]interface{}{
"slug": "existing-skill",
"registry": "clawhub",
})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "already installed")
}
func TestInstallSkillToolRegistryNotFound(t *testing.T) {
workspace := t.TempDir()
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
result := tool.Execute(context.Background(), map[string]interface{}{
"slug": "some-skill",
"registry": "nonexistent",
})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "registry")
assert.Contains(t, result.ForLLM, "not found")
}
func TestInstallSkillToolParameters(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
params := tool.Parameters()
props, ok := params["properties"].(map[string]interface{})
assert.True(t, ok)
assert.Contains(t, props, "slug")
assert.Contains(t, props, "version")
assert.Contains(t, props, "registry")
assert.Contains(t, props, "force")
required, ok := params["required"].([]string)
assert.True(t, ok)
assert.Contains(t, required, "slug")
assert.Contains(t, required, "registry")
}
func TestInstallSkillToolMissingRegistry(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
result := tool.Execute(context.Background(), map[string]interface{}{
"slug": "some-skill",
})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "registry is required")
}

118
pkg/tools/skills_search.go Normal file
View file

@ -0,0 +1,118 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/skills"
)
// FindSkillsTool allows the LLM agent to search for installable skills from registries.
type FindSkillsTool struct {
registryMgr *skills.RegistryManager
cache *skills.SearchCache
}
// NewFindSkillsTool creates a new FindSkillsTool.
// registryMgr is the shared registry manager (built from config in createToolRegistry).
// cache is the search cache for deduplicating similar queries.
func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
return &FindSkillsTool{
registryMgr: registryMgr,
cache: cache,
}
}
func (t *FindSkillsTool) Name() string {
return "find_skills"
}
func (t *FindSkillsTool) Description() string {
return "Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill."
}
func (t *FindSkillsTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
},
"limit": map[string]interface{}{
"type": "integer",
"description": "Maximum number of results to return (1-20, default 5)",
"minimum": 1.0,
"maximum": 20.0,
},
},
"required": []string{"query"},
}
}
func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
query, ok := args["query"].(string)
if !ok || strings.TrimSpace(query) == "" {
return ErrorResult("query is required and must be a non-empty string")
}
limit := 5
if l, ok := args["limit"].(float64); ok {
li := int(l)
if li >= 1 && li <= 20 {
limit = li
}
}
// Check cache first.
if t.cache != nil {
if cached, hit := t.cache.Get(query); hit {
return SilentResult(formatSearchResults(query, cached, true))
}
}
// Search all registries.
results, err := t.registryMgr.SearchAll(ctx, query, limit)
if err != nil {
return ErrorResult(fmt.Sprintf("skill search failed: %v", err))
}
// Cache the results.
if t.cache != nil && len(results) > 0 {
t.cache.Put(query, results)
}
return SilentResult(formatSearchResults(query, results, false))
}
func formatSearchResults(query string, results []skills.SearchResult, cached bool) string {
if len(results) == 0 {
return fmt.Sprintf("No skills found for query: %q", query)
}
var sb strings.Builder
source := ""
if cached {
source = " (cached)"
}
sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source))
for i, r := range results {
sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug))
if r.Version != "" {
sb.WriteString(fmt.Sprintf(" v%s", r.Version))
}
sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName))
if r.DisplayName != "" && r.DisplayName != r.Slug {
sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName))
}
if r.Summary != "" {
sb.WriteString(fmt.Sprintf(" %s\n", r.Summary))
}
sb.WriteString("\n")
}
sb.WriteString("Use install_skill with the slug to install a skill.")
return sb.String()
}

View file

@ -0,0 +1,82 @@
package tools
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/stretchr/testify/assert"
)
func TestFindSkillsToolName(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
assert.Equal(t, "find_skills", tool.Name())
}
func TestFindSkillsToolMissingQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
result := tool.Execute(context.Background(), map[string]interface{}{})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "query is required")
}
func TestFindSkillsToolEmptyQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
result := tool.Execute(context.Background(), map[string]interface{}{
"query": " ",
})
assert.True(t, result.IsError)
}
func TestFindSkillsToolCacheHit(t *testing.T) {
cache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min
cache.Put("github", []skills.SearchResult{
{Slug: "github", Score: 0.9, RegistryName: "clawhub"},
})
tool := NewFindSkillsTool(skills.NewRegistryManager(), cache)
result := tool.Execute(context.Background(), map[string]interface{}{
"query": "github",
})
assert.False(t, result.IsError)
assert.Contains(t, result.ForLLM, "github")
assert.Contains(t, result.ForLLM, "cached")
}
func TestFindSkillsToolParameters(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
params := tool.Parameters()
props, ok := params["properties"].(map[string]interface{})
assert.True(t, ok)
assert.Contains(t, props, "query")
assert.Contains(t, props, "limit")
required, ok := params["required"].([]string)
assert.True(t, ok)
assert.Contains(t, required, "query")
}
func TestFindSkillsToolDescription(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
assert.NotEmpty(t, tool.Description())
assert.Contains(t, tool.Description(), "skill")
}
func TestFormatSearchResultsEmpty(t *testing.T) {
result := formatSearchResults("test query", nil, false)
assert.Contains(t, result, "No skills found")
}
func TestFormatSearchResultsWithData(t *testing.T) {
results := []skills.SearchResult{
{Slug: "github", Score: 0.95, DisplayName: "GitHub", Summary: "GitHub API integration", Version: "1.0.0", RegistryName: "clawhub"},
}
output := formatSearchResults("github", results, false)
assert.Contains(t, output, "github")
assert.Contains(t, output, "v1.0.0")
assert.Contains(t, output, "0.950")
assert.Contains(t, output, "clawhub")
assert.Contains(t, output, "install_skill")
}