Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
211754b535 feat: add skill search CLI
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 15:04:05 +08:00
5 changed files with 354 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -60,6 +61,13 @@ var skillImportCmd = &cobra.Command{
RunE: runSkillImport,
}
var skillSearchCmd = &cobra.Command{
Use: "search <query>",
Short: "Search for installable skills",
Args: exactArgs(1),
RunE: runSkillSearch,
}
// Skill file subcommands.
var skillFilesCmd = &cobra.Command{
@@ -95,6 +103,7 @@ func init() {
skillCmd.AddCommand(skillUpdateCmd)
skillCmd.AddCommand(skillDeleteCmd)
skillCmd.AddCommand(skillImportCmd)
skillCmd.AddCommand(skillSearchCmd)
skillCmd.AddCommand(skillFilesCmd)
skillFilesCmd.AddCommand(skillFilesListCmd)
@@ -128,6 +137,9 @@ func init() {
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
// skill search
skillSearchCmd.Flags().String("output", "json", "Output format: table or json")
// skill files list
skillFilesListCmd.Flags().String("output", "table", "Output format: table or json")
@@ -388,6 +400,46 @@ func handleSkillImportConflict(cmd *cobra.Command, err error) bool {
return true
}
func runSkillSearch(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
query := strings.TrimSpace(args[0])
if query == "" {
return fmt.Errorf("query is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var results []map[string]any
path := "/api/skills/search?q=" + url.QueryEscape(query)
if err := client.GetJSON(ctx, path, &results); err != nil {
return fmt.Errorf("search skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, results)
}
headers := []string{"NAME", "URL", "SOURCE", "INSTALLS", "DESCRIPTION"}
rows := make([][]string, 0, len(results))
for _, result := range results {
rows = append(rows, []string{
strVal(result, "name"),
strVal(result, "url"),
strVal(result, "source"),
strVal(result, "install_count"),
strVal(result, "description"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
// ---------------------------------------------------------------------------
// Skill file subcommands
// ---------------------------------------------------------------------------

View File

@@ -96,3 +96,42 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
t.Fatalf("existing_skill = %#v", existing)
}
}
func TestRunSkillSearchRequestsSearchEndpoint(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.String()
if r.URL.Path != "/api/skills/search" {
t.Fatalf("expected /api/skills/search, got %s", r.URL.Path)
}
if r.URL.Query().Get("q") != "react hooks" {
t.Fatalf("expected q=react hooks, got %q", r.URL.Query().Get("q"))
}
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"name": "React",
"url": "https://clawhub.ai/ivangdavila/react",
"source": "clawhub.ai",
"repo": nil,
"install_count": 62,
"github_stars": nil,
"description": "React engineering skill",
},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "search"}
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := runSkillSearch(cmd, []string{"react hooks"}); err != nil {
t.Fatalf("runSkillSearch: %v", err)
}
if gotPath == "" {
t.Fatal("expected search endpoint to be requested")
}
}

View File

@@ -616,6 +616,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Route("/api/skills", func(r chi.Router) {
r.Get("/", h.ListSkills)
r.Post("/", h.CreateSkill)
r.Get("/search", h.SearchSkills)
r.Post("/import", h.ImportSkill)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetSkill)

View File

@@ -85,6 +85,16 @@ type SkillFileResponse struct {
UpdatedAt string `json:"updated_at"`
}
type SkillSearchCandidateResponse struct {
Name string `json:"name"`
URL string `json:"url"`
Source string `json:"source"`
Repo *string `json:"repo"`
InstallCount *int64 `json:"install_count"`
GitHubStars *int64 `json:"github_stars"`
Description string `json:"description"`
}
type SkillWithFilesResponse struct {
SkillResponse
Files []SkillFileResponse `json:"files"`
@@ -262,6 +272,25 @@ func (h *Handler) ListSkills(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) SearchSkills(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" {
writeError(w, http.StatusBadRequest, "query is required")
return
}
httpClient := &http.Client{Timeout: 30 * time.Second}
candidates, err := searchClawHubSkills(httpClient, query)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{
"code": "upstream_unavailable",
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, candidates)
}
func (h *Handler) GetSkill(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
@@ -575,6 +604,26 @@ func isLikelyBinaryFilePath(path string) bool {
// --- ClawHub types ---
var clawHubAPIBase = "https://clawhub.ai/api/v1"
const clawHubSearchStatsLimit = 10
type clawhubSearchResponse struct {
Results []clawhubSearchResult `json:"results"`
}
type clawhubSearchResult struct {
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
OwnerHandle string `json:"ownerHandle"`
}
type clawhubSkillStats struct {
InstallsAllTime int64 `json:"installsAllTime"`
InstallsCurrent int64 `json:"installsCurrent"`
}
type clawhubGetSkillResponse struct {
Skill clawhubSkill `json:"skill"`
LatestVersion *clawhubLatestVersion `json:"latestVersion"`
@@ -585,6 +634,7 @@ type clawhubSkill struct {
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
Tags map[string]string `json:"tags"`
Stats clawhubSkillStats `json:"stats"`
}
type clawhubLatestVersion struct {
@@ -719,13 +769,83 @@ func parseClawHubSlug(raw string) (string, error) {
return "", fmt.Errorf("could not extract skill slug from URL: %s", raw)
}
func searchClawHubSkills(httpClient *http.Client, query string) ([]SkillSearchCandidateResponse, error) {
searchURL := clawHubAPIBase + "/search?q=" + url.QueryEscape(query)
resp, err := httpClient.Get(searchURL)
if err != nil {
return nil, fmt.Errorf("failed to reach ClawHub: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ClawHub search returned status %d", resp.StatusCode)
}
var searchResp clawhubSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to parse ClawHub search response")
}
candidates := make([]SkillSearchCandidateResponse, 0, len(searchResp.Results))
for i, result := range searchResp.Results {
if result.Slug == "" {
continue
}
candidate := SkillSearchCandidateResponse{
Name: result.DisplayName,
URL: buildClawHubSkillURL(result.OwnerHandle, result.Slug),
Source: "clawhub.ai",
Description: result.Summary,
}
if candidate.Name == "" {
candidate.Name = result.Slug
}
if i < clawHubSearchStatsLimit {
if count, ok := fetchClawHubInstallCount(httpClient, result.Slug); ok {
candidate.InstallCount = &count
}
}
candidates = append(candidates, candidate)
}
return candidates, nil
}
func buildClawHubSkillURL(ownerHandle, slug string) string {
if ownerHandle == "" {
return "https://clawhub.ai/" + url.PathEscape(slug)
}
return "https://clawhub.ai/" + url.PathEscape(ownerHandle) + "/" + url.PathEscape(slug)
}
func fetchClawHubInstallCount(httpClient *http.Client, slug string) (int64, bool) {
detailURL := clawHubAPIBase + "/skills/" + url.PathEscape(slug)
resp, err := httpClient.Get(detailURL)
if err != nil {
slog.Warn("clawhub search: failed to fetch skill details", "slug", slug, "error", err)
return 0, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("clawhub search: skill details returned non-200", "slug", slug, "status", resp.StatusCode)
return 0, false
}
var detail clawhubGetSkillResponse
if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil {
slog.Warn("clawhub search: failed to parse skill details", "slug", slug, "error", err)
return 0, false
}
if detail.Skill.Stats.InstallsAllTime > 0 {
return detail.Skill.Stats.InstallsAllTime, true
}
return detail.Skill.Stats.InstallsCurrent, true
}
func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, error) {
slug, err := parseClawHubSlug(rawURL)
if err != nil {
return nil, err
}
apiBase := "https://clawhub.ai/api/v1"
apiBase := clawHubAPIBase
// 1. Fetch skill metadata
skillResp, err := httpClient.Get(apiBase + "/skills/" + url.PathEscape(slug))

View File

@@ -0,0 +1,141 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSearchSkillsReturnsNormalizedClawHubCandidates(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
if got := r.URL.Query().Get("q"); got != "react" {
t.Fatalf("expected q=react, got %q", got)
}
writeJSON(w, http.StatusOK, map[string]any{
"results": []map[string]any{
{
"slug": "react",
"displayName": "React",
"summary": "React engineering skill",
"ownerHandle": "ivangdavila",
},
{
"slug": "react-expert",
"displayName": "React Expert",
"summary": "Advanced React review",
"ownerHandle": "veeramanikandanr48",
},
},
})
case "/skills/react":
writeJSON(w, http.StatusOK, map[string]any{
"skill": map[string]any{
"slug": "react",
"displayName": "React",
"summary": "React engineering skill",
"stats": map[string]any{
"installsAllTime": 62,
"stars": 3,
},
},
})
case "/skills/react-expert":
writeJSON(w, http.StatusOK, map[string]any{
"skill": map[string]any{
"slug": "react-expert",
"displayName": "React Expert",
"summary": "Advanced React review",
"stats": map[string]any{
"installsAllTime": 11,
"stars": 7,
},
},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
oldBase := clawHubAPIBase
clawHubAPIBase = upstream.URL
t.Cleanup(func() { clawHubAPIBase = oldBase })
w := httptest.NewRecorder()
req := newRequest(http.MethodGet, "/api/skills/search?q=react", nil)
testHandler.SearchSkills(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SearchSkills: expected 200, got %d: %s", w.Code, w.Body.String())
}
var got []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("SearchSkills: decode response: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 candidates, got %d: %#v", len(got), got)
}
first := got[0]
if first["name"] != "React" {
t.Fatalf("expected normalized name, got %#v", first["name"])
}
if first["url"] != "https://clawhub.ai/ivangdavila/react" {
t.Fatalf("expected importable ClawHub URL, got %#v", first["url"])
}
if first["source"] != "clawhub.ai" {
t.Fatalf("expected source clawhub.ai, got %#v", first["source"])
}
if first["repo"] != nil {
t.Fatalf("repo should be null when ClawHub has no GitHub repo field, got %#v", first["repo"])
}
if first["github_stars"] != nil {
t.Fatalf("github_stars should not use ClawHub stars, got %#v", first["github_stars"])
}
if first["install_count"] != float64(62) {
t.Fatalf("expected install_count from details stats, got %#v", first["install_count"])
}
if first["description"] != "React engineering skill" {
t.Fatalf("expected description from summary, got %#v", first["description"])
}
}
func TestSearchSkillsEmptyQueryReturns400(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest(http.MethodGet, "/api/skills/search?q=", nil)
testHandler.SearchSkills(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("SearchSkills empty query: expected 400, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "query is required") {
t.Fatalf("expected query is required error, got %s", w.Body.String())
}
}
func TestSearchSkillsUpstreamUnavailableReturnsStructuredError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "temporary outage", http.StatusBadGateway)
}))
defer upstream.Close()
oldBase := clawHubAPIBase
clawHubAPIBase = upstream.URL
t.Cleanup(func() { clawHubAPIBase = oldBase })
w := httptest.NewRecorder()
req := newRequest(http.MethodGet, "/api/skills/search?q=react", nil)
testHandler.SearchSkills(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("SearchSkills outage: expected 502, got %d: %s", w.Code, w.Body.String())
}
var got map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode error body: %v", err)
}
if got["code"] != "upstream_unavailable" {
t.Fatalf("expected structured upstream_unavailable code, got %#v", got)
}
}