mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
feat(cli): add skill import conflict strategies
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user