mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/matt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211754b535 |
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
141
server/internal/handler/skill_search_test.go
Normal file
141
server/internal/handler/skill_search_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user