Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
e9e0d598c8 Handle duplicate skill imports as structured results
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 14:22:38 +08:00
4 changed files with 209 additions and 1 deletions

View File

@@ -4,7 +4,9 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -346,6 +348,9 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
if handleSkillImportConflict(cmd, err) {
return nil
}
return fmt.Errorf("import skill: %w", err)
}
@@ -358,6 +363,31 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
return nil
}
func handleSkillImportConflict(cmd *cobra.Command, err error) bool {
var httpErr *cli.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict || strings.TrimSpace(httpErr.Body) == "" {
return false
}
var body map[string]any
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
return false
}
if _, ok := body["existing_skill"]; !ok {
return false
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
_ = cli.PrintJSON(os.Stdout, body)
return true
}
existing, _ := body["existing_skill"].(map[string]any)
fmt.Printf("Skill already exists: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
return true
}
// ---------------------------------------------------------------------------
// Skill file subcommands
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/spf13/cobra"
)
func newSkillImportTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "import"}
cmd.Flags().String("server-url", "", "")
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("url", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func captureStdout(t *testing.T, fn func() error) (string, error) {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
os.Stdout = w
defer func() { os.Stdout = old }()
runErr := fn()
if err := w.Close(); err != nil {
t.Fatalf("close stdout writer: %v", err)
}
out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read stdout: %v", err)
}
return string(out), runErr
}
func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/api/skills/import" {
t.Fatalf("path = %q, want /api/skills/import", r.URL.Path)
}
if r.Header.Get("X-Workspace-ID") != "workspace-123" {
t.Fatalf("X-Workspace-ID = %q, want workspace-123", r.Header.Get("X-Workspace-ID"))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "a skill with this name already exists",
"existing_skill": map[string]any{
"id": "skill-123",
"name": "review-helper",
},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newSkillImportTestCmd()
_ = cmd.Flags().Set("url", "https://skills.sh/acme/review-helper")
_ = cmd.Flags().Set("output", "json")
out, err := captureStdout(t, func() error {
return runSkillImport(cmd, nil)
})
if err != nil {
t.Fatalf("runSkillImport returned error for duplicate import: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode stdout JSON %q: %v", out, err)
}
if got["error"] != "a skill with this name already exists" {
t.Fatalf("error = %v", got["error"])
}
existing, ok := got["existing_skill"].(map[string]any)
if !ok {
t.Fatalf("existing_skill missing or wrong type: %#v", got["existing_skill"])
}
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
t.Fatalf("existing_skill = %#v", existing)
}
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -14,6 +15,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@@ -88,6 +90,18 @@ type SkillWithFilesResponse struct {
Files []SkillFileResponse `json:"files"`
}
type ExistingSkillIdentity struct {
ID string `json:"id"`
Name string `json:"name"`
}
func writeSkillImportDuplicateConflict(w http.ResponseWriter, existing ExistingSkillIdentity) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "a skill with this name already exists",
"existing_skill": existing,
})
}
func skillToResponse(s db.Skill) SkillResponse {
return SkillResponse{
ID: uuidToString(s.ID),
@@ -102,6 +116,20 @@ func skillToResponse(s db.Skill) SkillResponse {
}
}
func (h *Handler) existingSkillIdentityByName(ctx context.Context, workspaceID pgtype.UUID, name string) (ExistingSkillIdentity, bool, error) {
skill, err := h.Queries.GetSkillByWorkspaceAndName(ctx, db.GetSkillByWorkspaceAndNameParams{
WorkspaceID: workspaceID,
Name: name,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ExistingSkillIdentity{}, false, nil
}
return ExistingSkillIdentity{}, false, err
}
return ExistingSkillIdentity{ID: uuidToString(skill.ID), Name: skill.Name}, true, nil
}
// decodeSkillConfig decodes a JSONB skill.config blob, defaulting to {} when
// missing or unparseable so the API surface always returns a JSON object.
func decodeSkillConfig(raw []byte) any {
@@ -1657,7 +1685,11 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "a skill with this name already exists")
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, imported.name); findErr == nil && found {
writeSkillImportDuplicateConflict(w, existing)
} else {
writeError(w, http.StatusConflict, "a skill with this name already exists")
}
return
}
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())

View File

@@ -0,0 +1,48 @@
package handler
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
)
func TestExistingSkillIdentityByNameReturnsIDAndName(t *testing.T) {
namePrefix := "duplicate-import-identity"
name := namePrefix + "-" + t.Name()
skillID := insertHandlerTestSkill(t, namePrefix, "# Duplicate import identity")
existing, ok, err := testHandler.existingSkillIdentityByName(context.Background(), parseUUID(testWorkspaceID), name)
if err != nil {
t.Fatalf("existingSkillIdentityByName: %v", err)
}
if !ok {
t.Fatal("expected existing skill identity to be found")
}
if existing.ID != skillID || existing.Name != name {
t.Fatalf("existing skill = %#v, want id %s name %s", existing, skillID, name)
}
}
func TestWriteSkillImportDuplicateConflictIncludesExistingSkill(t *testing.T) {
w := httptest.NewRecorder()
writeSkillImportDuplicateConflict(w, ExistingSkillIdentity{ID: "skill-123", Name: "review-helper"})
if w.Code != 409 {
t.Fatalf("status = %d, want 409: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["error"] != "a skill with this name already exists" {
t.Fatalf("error = %v", body["error"])
}
existing, ok := body["existing_skill"].(map[string]any)
if !ok {
t.Fatalf("existing_skill missing or wrong type: %#v", body["existing_skill"])
}
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
t.Fatalf("existing_skill = %#v", existing)
}
}