feat(cli): add skill import conflict strategies

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-06-10 16:12:11 +08:00
parent 3d91e6fd58
commit bcfbee32c2
4 changed files with 451 additions and 47 deletions

View File

@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
@@ -141,6 +140,7 @@ func init() {
// skill import
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
skillImportCmd.Flags().String("on-conflict", "fail", "Conflict strategy when a skill with the same name exists: fail, overwrite, rename, or skip")
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
// skill search
@@ -419,9 +419,14 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
if importURL == "" {
return fmt.Errorf("--url is required")
}
onConflict, _ := cmd.Flags().GetString("on-conflict")
if !validSkillImportConflictStrategy(onConflict) {
return fmt.Errorf("--on-conflict must be one of: fail, overwrite, rename, skip")
}
body := map[string]any{
"url": importURL,
"url": importURL,
"on_conflict": onConflict,
}
ctx, cancel := context.WithTimeout(context.Background(), cli.AtLeastAPITimeout(60*time.Second))
@@ -429,44 +434,107 @@ 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
if handledErr := handleSkillImportError(cmd, err); handledErr != nil {
return handledErr
}
return fmt.Errorf("import skill: %w", err)
}
return printSkillImportResult(cmd, result)
}
func validSkillImportConflictStrategy(strategy string) bool {
switch strategy {
case "fail", "overwrite", "rename", "skip":
return true
}
return false
}
func handleSkillImportError(cmd *cobra.Command, err error) error {
var httpErr *cli.HTTPError
if !errors.As(err, &httpErr) || strings.TrimSpace(httpErr.Body) == "" {
return nil
}
var body map[string]any
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
return nil
}
if _, ok := body["status"]; !ok {
if _, hasExisting := body["existing_skill"]; !hasExisting {
return nil
}
body = normalizeLegacySkillImportConflict(body)
}
if err := printSkillImportResult(cmd, body); err != nil {
return err
}
reason := strVal(body, "reason")
if reason == "" {
reason = strVal(body, "error")
}
if reason == "" {
reason = "skill import conflict"
}
return errors.New(reason)
}
func normalizeLegacySkillImportConflict(body map[string]any) map[string]any {
reason := strVal(body, "error")
if reason == "" {
reason = "a skill with this name already exists"
}
reason += "; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy"
return map[string]any{
"status": "conflict",
"reason": reason,
"existing_skill": body["existing_skill"],
}
}
func printSkillImportResult(cmd *cobra.Command, result map[string]any) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
status := strVal(result, "status")
if status == "" {
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
skill := nestedMap(result, "skill")
existing := nestedMap(result, "existing_skill")
reason := strVal(result, "reason")
switch status {
case "created":
fmt.Printf("Skill imported: %s (%s)\n", strVal(skill, "name"), strVal(skill, "id"))
case "updated":
fmt.Printf("Skill updated: %s (%s)\n", strVal(skill, "name"), strVal(skill, "id"))
case "skipped":
fmt.Printf("Skill skipped: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
case "conflict":
fmt.Printf("Skill import conflict: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
case "failed":
fmt.Printf("Skill import failed: %s\n", reason)
default:
fmt.Printf("Skill import %s\n", status)
}
if reason != "" && status != "failed" {
fmt.Printf("Reason: %s\n", reason)
}
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
func nestedMap(m map[string]any, key string) map[string]any {
nested, _ := m[key].(map[string]any)
if nested == nil {
return map[string]any{}
}
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
return nested
}
func runSkillSearch(cmd *cobra.Command, args []string) error {

View File

@@ -18,6 +18,7 @@ func newSkillImportTestCmd() *cobra.Command {
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("url", "", "")
cmd.Flags().String("on-conflict", "fail", "")
cmd.Flags().String("output", "json", "")
return cmd
}
@@ -43,7 +44,7 @@ func captureStdout(t *testing.T, fn func() error) (string, error) {
return string(out), runErr
}
func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
func TestRunSkillImportJsonTreatsDuplicateAsConflictResult(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
@@ -58,10 +59,21 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
if r.Header.Get("X-Workspace-ID") != "workspace-123" {
t.Fatalf("X-Workspace-ID = %q, want workspace-123", r.Header.Get("X-Workspace-ID"))
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body["url"] != "https://skills.sh/acme/review-helper" {
t.Fatalf("url = %v", body["url"])
}
if body["on_conflict"] != "fail" {
t.Fatalf("on_conflict = %v, want fail", body["on_conflict"])
}
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",
"status": "conflict",
"reason": "a skill with this name already exists; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy",
"existing_skill": map[string]any{
"id": "skill-123",
"name": "review-helper",
@@ -78,16 +90,19 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
out, err := captureStdout(t, func() error {
return runSkillImport(cmd, nil)
})
if err != nil {
t.Fatalf("runSkillImport returned error for duplicate import: %v", err)
if err == nil {
t.Fatal("expected duplicate import to return an error")
}
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"])
if got["status"] != "conflict" {
t.Fatalf("status = %v", got["status"])
}
if !strings.Contains(strVal(got, "reason"), "--on-conflict overwrite") {
t.Fatalf("reason = %v", got["reason"])
}
existing, ok := got["existing_skill"].(map[string]any)
if !ok {
@@ -98,6 +113,52 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
}
}
func TestRunSkillImportSendsOnConflictAndPrintsStructuredResult(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) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body["on_conflict"] != "overwrite" {
t.Fatalf("on_conflict = %v, want overwrite", body["on_conflict"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "updated",
"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("on-conflict", "overwrite")
_ = cmd.Flags().Set("output", "json")
out, err := captureStdout(t, func() error {
return runSkillImport(cmd, nil)
})
if err != nil {
t.Fatalf("runSkillImport returned error: %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["status"] != "updated" {
t.Fatalf("status = %v", got["status"])
}
}
func TestRunSkillSearchRequestsSearchEndpoint(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {