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/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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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