diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go index 7f8bd011d..65eb127b9 100644 --- a/cmd/picoclaw/internal/skills/command.go +++ b/cmd/picoclaw/internal/skills/command.go @@ -71,7 +71,7 @@ func NewSkillsCommand() *cobra.Command { newInstallBuiltinCommand(workspaceFn), newListBuiltinCommand(), newRemoveCommand(installerFn), - newSearchCommand(installerFn), + newSearchCommand(), newShowCommand(loaderFn), ) diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index 439b81a4f..a59a2013a 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -15,6 +15,8 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +const skillsSearchMaxResults = 20 + func skillsListCmd(loader *skills.SkillsLoader) { allSkills := loader.ListSkills() @@ -215,34 +217,43 @@ func skillsListBuiltinCmd() { } } -func skillsSearchCmd(installer *skills.SkillInstaller) { +func skillsSearchCmd(query string) { fmt.Println("Searching for available skills...") + cfg, err := internal.LoadConfig() + if err != nil { + fmt.Printf("✗ Failed to load config: %v\n", err) + return + } + + registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ + MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, + ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - availableSkills, err := installer.ListAvailableSkills(ctx) + results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults) if err != nil { fmt.Printf("✗ Failed to fetch skills list: %v\n", err) return } - if len(availableSkills) == 0 { + if len(results) == 0 { fmt.Println("No skills available.") return } - fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) + fmt.Printf("\nAvailable Skills (%d):\n", len(results)) fmt.Println("--------------------") - for _, skill := range availableSkills { - fmt.Printf(" 📦 %s\n", skill.Name) - fmt.Printf(" %s\n", skill.Description) - fmt.Printf(" Repo: %s\n", skill.Repository) - if skill.Author != "" { - fmt.Printf(" Author: %s\n", skill.Author) - } - if len(skill.Tags) > 0 { - fmt.Printf(" Tags: %v\n", skill.Tags) + for _, result := range results { + fmt.Printf(" 📦 %s\n", result.DisplayName) + fmt.Printf(" %s\n", result.Summary) + fmt.Printf(" Slug: %s\n", result.Slug) + fmt.Printf(" Registry: %s\n", result.RegistryName) + if result.Version != "" { + fmt.Printf(" Version: %s\n", result.Version) } fmt.Println() } diff --git a/cmd/picoclaw/internal/skills/search.go b/cmd/picoclaw/internal/skills/search.go index 53bc99109..54f72259f 100644 --- a/cmd/picoclaw/internal/skills/search.go +++ b/cmd/picoclaw/internal/skills/search.go @@ -2,20 +2,19 @@ package skills import ( "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/skills" ) -func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { +func newSearchCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "search", + Use: "search [query]", Short: "Search available skills", - RunE: func(_ *cobra.Command, _ []string) error { - installer, err := installerFn() - if err != nil { - return err + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + query := "" + if len(args) == 1 { + query = args[0] } - skillsSearchCmd(installer) + skillsSearchCmd(query) return nil }, } diff --git a/cmd/picoclaw/internal/skills/search_test.go b/cmd/picoclaw/internal/skills/search_test.go index 19f63a9ff..ed92e25cc 100644 --- a/cmd/picoclaw/internal/skills/search_test.go +++ b/cmd/picoclaw/internal/skills/search_test.go @@ -8,11 +8,11 @@ import ( ) func TestNewSearchSubcommand(t *testing.T) { - cmd := newSearchCommand(nil) + cmd := newSearchCommand() require.NotNil(t, cmd) - assert.Equal(t, "search", cmd.Use) + assert.Equal(t, "search [query]", cmd.Use) assert.Equal(t, "Search available skills", cmd.Short) assert.Nil(t, cmd.Run) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 771603f3e..7a23f9617 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -148,6 +148,10 @@ func (c *WeComAppChannel) Name() string { func (c *WeComAppChannel) Start(ctx context.Context) error { logger.InfoC("wecom_app", "Starting WeCom App channel...") + // Cancel the context created in the constructor to avoid a resource leak. + if c.cancel != nil { + c.cancel() + } c.ctx, c.cancel = context.WithCancel(ctx) // Get initial access token @@ -601,14 +605,14 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag return } c.processedMsgs[msgID] = true - c.msgMu.Unlock() - - // Clean up old messages periodically (keep last 1000) + // Clean up old messages while still holding the lock to avoid a data race + // on len(). Reset the map but re-insert the current msgID so it remains + // deduplicated. if len(c.processedMsgs) > 1000 { - c.msgMu.Lock() c.processedMsgs = make(map[string]bool) - c.msgMu.Unlock() + c.processedMsgs[msgID] = true } + c.msgMu.Unlock() senderID := msg.FromUserName chatID := senderID // WeCom App uses user ID as chat ID for direct messages diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index e99c710ef..39f84d55c 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -112,6 +112,10 @@ func (c *WeComBotChannel) Name() string { func (c *WeComBotChannel) Start(ctx context.Context) error { logger.InfoC("wecom", "Starting WeCom Bot channel...") + // Cancel the context created in the constructor to avoid a resource leak. + if c.cancel != nil { + c.cancel() + } c.ctx, c.cancel = context.WithCancel(ctx) c.SetRunning(true) @@ -326,14 +330,14 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag return } c.processedMsgs[msgID] = true - c.msgMu.Unlock() - - // Clean up old messages periodically (keep last 1000) + // Clean up old messages while still holding the lock to avoid a data race + // on len(). Reset the map but re-insert the current msgID so it remains + // deduplicated. if len(c.processedMsgs) > 1000 { - c.msgMu.Lock() c.processedMsgs = make(map[string]bool) - c.msgMu.Unlock() + c.processedMsgs[msgID] = true } + c.msgMu.Unlock() senderID := msg.From.UserID diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 20f6a49d9..c9f19f25d 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -2,7 +2,6 @@ package skills import ( "context" - "encoding/json" "fmt" "io" "net/http" @@ -18,14 +17,6 @@ type SkillInstaller struct { workspace string } -type AvailableSkill struct { - Name string `json:"name"` - Repository string `json:"repository"` - Description string `json:"description"` - Author string `json:"author"` - Tags []string `json:"tags"` -} - func NewSkillInstaller(workspace string) *SkillInstaller { return &SkillInstaller{ workspace: workspace, @@ -89,35 +80,3 @@ func (si *SkillInstaller) Uninstall(skillName string) error { return nil } - -func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableSkill, error) { - url := "https://raw.githubusercontent.com/sipeed/picoclaw-skills/main/skills.json" - - client := &http.Client{Timeout: 15 * time.Second} - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := utils.DoRequestWithRetry(client, req) - if err != nil { - return nil, fmt.Errorf("failed to fetch skills list: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("failed to fetch skills list: HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - var skills []AvailableSkill - if err := json.Unmarshal(body, &skills); err != nil { - return nil, fmt.Errorf("failed to parse skills list: %w", err) - } - - return skills, nil -}