Add Find Skills and Install Skills
This commit is contained in:
parent
13e4028d42
commit
1748868000
12 changed files with 1857 additions and 1 deletions
|
|
@ -25,6 +25,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
|
|
@ -101,6 +102,21 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
|||
})
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -213,6 +213,24 @@ type WebToolsConfig struct {
|
|||
|
||||
type ToolsConfig struct {
|
||||
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 {
|
||||
|
|
@ -322,6 +340,14 @@ func DefaultConfig() *Config {
|
|||
MaxResults: 5,
|
||||
},
|
||||
},
|
||||
Skills: SkillsToolsConfig{
|
||||
Registries: SkillsRegistriesConfig{
|
||||
ClawHub: ClawHubRegistryConfig{
|
||||
Enabled: true,
|
||||
BaseURL: "https://clawhub.ai",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
|
|
|
|||
323
pkg/skills/clawhub_registry.go
Normal file
323
pkg/skills/clawhub_registry.go
Normal 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]) + "…"
|
||||
}
|
||||
244
pkg/skills/clawhub_registry_test.go
Normal file
244
pkg/skills/clawhub_registry_test.go
Normal 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
197
pkg/skills/registry.go
Normal 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
177
pkg/skills/registry_test.go
Normal 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
214
pkg/skills/search_cache.go
Normal 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
|
||||
}
|
||||
172
pkg/skills/search_cache_test.go
Normal file
172
pkg/skills/search_cache_test.go
Normal 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
185
pkg/tools/skills_install.go
Normal 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)
|
||||
}
|
||||
102
pkg/tools/skills_install_test.go
Normal file
102
pkg/tools/skills_install_test.go
Normal 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
118
pkg/tools/skills_search.go
Normal 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()
|
||||
}
|
||||
82
pkg/tools/skills_search_test.go
Normal file
82
pkg/tools/skills_search_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue