From 74cb4ec7b4fc03a9ae138dee253de1a7dae2f282 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:41:54 +0800 Subject: [PATCH] feat(cli): add skill content file and stdin input Co-authored-by: multica-agent --- server/cmd/multica/cmd_skill.go | 86 ++++++++- server/cmd/multica/cmd_skill_test.go | 260 +++++++++++++++++++++++++++ 2 files changed, 339 insertions(+), 7 deletions(-) diff --git a/server/cmd/multica/cmd_skill.go b/server/cmd/multica/cmd_skill.go index 1b31e86c9..0c17123e1 100644 --- a/server/cmd/multica/cmd_skill.go +++ b/server/cmd/multica/cmd_skill.go @@ -6,11 +6,13 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "os" "strings" "time" + "unicode/utf8" "github.com/spf13/cobra" @@ -120,6 +122,8 @@ func init() { skillCreateCmd.Flags().String("name", "", "Skill name (required)") skillCreateCmd.Flags().String("description", "", "Skill description") skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)") + skillCreateCmd.Flags().Bool("content-stdin", false, "Read skill content from stdin. Mutually exclusive with --content and --content-file.") + skillCreateCmd.Flags().String("content-file", "", "Read skill content from a UTF-8 file. Mutually exclusive with --content and --content-stdin.") skillCreateCmd.Flags().String("config", "", "Skill config as JSON string") skillCreateCmd.Flags().String("output", "json", "Output format: table or json") @@ -127,6 +131,8 @@ func init() { skillUpdateCmd.Flags().String("name", "", "New name") skillUpdateCmd.Flags().String("description", "", "New description") skillUpdateCmd.Flags().String("content", "", "New content") + skillUpdateCmd.Flags().Bool("content-stdin", false, "Read new content from stdin. Mutually exclusive with --content and --content-file.") + skillUpdateCmd.Flags().String("content-file", "", "Read new content from a UTF-8 file. Mutually exclusive with --content and --content-stdin.") skillUpdateCmd.Flags().String("config", "", "New config as JSON string") skillUpdateCmd.Flags().String("output", "json", "Output format: table or json") @@ -146,6 +152,8 @@ func init() { // skill files upsert skillFilesUpsertCmd.Flags().String("path", "", "File path within the skill (required)") skillFilesUpsertCmd.Flags().String("content", "", "File content (required)") + skillFilesUpsertCmd.Flags().Bool("content-stdin", false, "Read file content from stdin. Mutually exclusive with --content and --content-file.") + skillFilesUpsertCmd.Flags().String("content-file", "", "Read file content from a UTF-8 file. Mutually exclusive with --content and --content-stdin.") skillFilesUpsertCmd.Flags().String("output", "json", "Output format: table or json") } @@ -153,6 +161,60 @@ func init() { // Skill commands // --------------------------------------------------------------------------- +// resolveSkillContentFlag intentionally stays separate from resolveTextFlag. +// Skill bodies are Markdown documents where byte-level preservation matters: +// inline --content is not backslash-unescaped, and stdin/file input is not +// trimmed, so agents can round-trip generated SKILL.md content exactly. +func resolveSkillContentFlag(cmd *cobra.Command) (string, bool, error) { + useStdin, _ := cmd.Flags().GetBool("content-stdin") + inline, _ := cmd.Flags().GetString("content") + filePath, _ := cmd.Flags().GetString("content-file") + inlineSet := cmd.Flags().Changed("content") + + sources := 0 + if inlineSet { + sources++ + } + if useStdin { + sources++ + } + if filePath != "" { + sources++ + } + if sources > 1 { + return "", false, fmt.Errorf("--content, --content-stdin, and --content-file are mutually exclusive") + } + + if useStdin { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", false, fmt.Errorf("read stdin for --content-stdin: %w", err) + } + return skillContentBytesToString(data, "stdin content for --content-stdin") + } + if filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return "", false, fmt.Errorf("read file for --content-file: %w", err) + } + return skillContentBytesToString(data, "file content for --content-file") + } + if inlineSet { + return inline, true, nil + } + return "", false, nil +} + +func skillContentBytesToString(data []byte, label string) (string, bool, error) { + if len(data) == 0 { + return "", false, fmt.Errorf("%s is empty", label) + } + if !utf8.Valid(data) { + return "", false, fmt.Errorf("%s must be valid UTF-8", label) + } + return string(data), true, nil +} + func runSkillList(cmd *cobra.Command, _ []string) error { client, err := newAPIClient(cmd) if err != nil { @@ -233,8 +295,12 @@ func runSkillCreate(cmd *cobra.Command, _ []string) error { if v, _ := cmd.Flags().GetString("description"); v != "" { body["description"] = v } - if v, _ := cmd.Flags().GetString("content"); v != "" { - body["content"] = v + content, hasContent, err := resolveSkillContentFlag(cmd) + if err != nil { + return err + } + if hasContent && content != "" { + body["content"] = content } if cmd.Flags().Changed("config") { v, _ := cmd.Flags().GetString("config") @@ -277,9 +343,12 @@ func runSkillUpdate(cmd *cobra.Command, args []string) error { v, _ := cmd.Flags().GetString("description") body["description"] = v } - if cmd.Flags().Changed("content") { - v, _ := cmd.Flags().GetString("content") - body["content"] = v + content, hasContent, err := resolveSkillContentFlag(cmd) + if err != nil { + return err + } + if hasContent { + body["content"] = content } if cmd.Flags().Changed("config") { v, _ := cmd.Flags().GetString("config") @@ -487,8 +556,11 @@ func runSkillFilesUpsert(cmd *cobra.Command, args []string) error { if filePath == "" { return fmt.Errorf("--path is required") } - content, _ := cmd.Flags().GetString("content") - if content == "" { + content, hasContent, err := resolveSkillContentFlag(cmd) + if err != nil { + return err + } + if !hasContent || content == "" { return fmt.Errorf("--content is required") } diff --git a/server/cmd/multica/cmd_skill_test.go b/server/cmd/multica/cmd_skill_test.go index 7dde37c2b..86ba6bc2f 100644 --- a/server/cmd/multica/cmd_skill_test.go +++ b/server/cmd/multica/cmd_skill_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "github.com/spf13/cobra" @@ -135,3 +136,262 @@ func TestRunSkillSearchRequestsSearchEndpoint(t *testing.T) { t.Fatal("expected search endpoint to be requested") } } + +func newSkillCreateTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "create"} + cmd.Flags().String("server-url", "", "") + cmd.Flags().String("workspace-id", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flags().String("name", "", "") + cmd.Flags().String("description", "", "") + cmd.Flags().String("content", "", "") + cmd.Flags().Bool("content-stdin", false, "") + cmd.Flags().String("content-file", "", "") + cmd.Flags().String("config", "", "") + cmd.Flags().String("output", "json", "") + return cmd +} + +func newSkillUpdateTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "update"} + cmd.Flags().String("server-url", "", "") + cmd.Flags().String("workspace-id", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flags().String("name", "", "") + cmd.Flags().String("description", "", "") + cmd.Flags().String("content", "", "") + cmd.Flags().Bool("content-stdin", false, "") + cmd.Flags().String("content-file", "", "") + cmd.Flags().String("config", "", "") + cmd.Flags().String("output", "json", "") + return cmd +} + +func newSkillFilesUpsertTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "upsert"} + cmd.Flags().String("server-url", "", "") + cmd.Flags().String("workspace-id", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flags().String("path", "", "") + cmd.Flags().String("content", "", "") + cmd.Flags().Bool("content-stdin", false, "") + cmd.Flags().String("content-file", "", "") + cmd.Flags().String("output", "json", "") + return cmd +} + +func newSkillBodyCaptureServer(t *testing.T, wantMethod, wantPath string, body *map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != wantMethod { + t.Fatalf("method = %s, want %s", r.Method, wantMethod) + } + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + t.Fatalf("decode body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "skill-123", + "name": "skill-name", + "path": "docs/SKILL.md", + "description": "desc", + "content": (*body)["content"], + }) + })) +} + +func setSkillServerEnv(t *testing.T, serverURL string) { + t.Helper() + t.Setenv("MULTICA_SERVER_URL", serverURL) + t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") + t.Setenv("MULTICA_TOKEN", "test-token") +} + +func TestRunSkillCreateReadsContentFileVerbatim(t *testing.T) { + var body map[string]any + srv := newSkillBodyCaptureServer(t, http.MethodPost, "/api/skills", &body) + defer srv.Close() + setSkillServerEnv(t, srv.URL) + + content := "标题 / Заголовок\n\nBody with `code`, \"quotes\", and a literal \\n.\n" + path := t.TempDir() + string(os.PathSeparator) + "SKILL.md" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write skill file: %v", err) + } + + cmd := newSkillCreateTestCmd() + _ = cmd.Flags().Set("name", "skill-name") + _ = cmd.Flags().Set("content-file", path) + if _, err := captureStdout(t, func() error { return runSkillCreate(cmd, nil) }); err != nil { + t.Fatalf("runSkillCreate: %v", err) + } + if body["content"] != content { + t.Fatalf("content = %q, want verbatim %q", body["content"], content) + } +} + +func TestRunSkillCreateKeepsInlineContentLiteral(t *testing.T) { + var body map[string]any + srv := newSkillBodyCaptureServer(t, http.MethodPost, "/api/skills", &body) + defer srv.Close() + setSkillServerEnv(t, srv.URL) + + content := `regex \d and path C:\\new and literal \n done` + cmd := newSkillCreateTestCmd() + _ = cmd.Flags().Set("name", "skill-name") + _ = cmd.Flags().Set("content", content) + if _, err := captureStdout(t, func() error { return runSkillCreate(cmd, nil) }); err != nil { + t.Fatalf("runSkillCreate: %v", err) + } + if body["content"] != content { + t.Fatalf("content = %q, want literal inline %q", body["content"], content) + } +} + +func TestRunSkillUpdateReadsContentStdinVerbatim(t *testing.T) { + var body map[string]any + srv := newSkillBodyCaptureServer(t, http.MethodPut, "/api/skills/skill-123", &body) + defer srv.Close() + setSkillServerEnv(t, srv.URL) + + content := "first line\nsecond line with literal \\n\n" + cmd := newSkillUpdateTestCmd() + _ = cmd.Flags().Set("content-stdin", "true") + pipeStdin(t, content, func() { + if _, err := captureStdout(t, func() error { return runSkillUpdate(cmd, []string{"skill-123"}) }); err != nil { + t.Fatalf("runSkillUpdate: %v", err) + } + }) + if body["content"] != content { + t.Fatalf("content = %q, want verbatim %q", body["content"], content) + } +} + +func TestRunSkillFilesUpsertReadsContentFileVerbatim(t *testing.T) { + var body map[string]any + srv := newSkillBodyCaptureServer(t, http.MethodPut, "/api/skills/skill-123/files", &body) + defer srv.Close() + setSkillServerEnv(t, srv.URL) + + content := "file asset body\n\n" + path := t.TempDir() + string(os.PathSeparator) + "asset.txt" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write content file: %v", err) + } + + cmd := newSkillFilesUpsertTestCmd() + _ = cmd.Flags().Set("path", "docs/SKILL.md") + _ = cmd.Flags().Set("content-file", path) + if _, err := captureStdout(t, func() error { return runSkillFilesUpsert(cmd, []string{"skill-123"}) }); err != nil { + t.Fatalf("runSkillFilesUpsert: %v", err) + } + if body["path"] != "docs/SKILL.md" { + t.Fatalf("path = %v", body["path"]) + } + if body["content"] != content { + t.Fatalf("content = %q, want verbatim %q", body["content"], content) + } +} + +func TestRunSkillContentInputsAreMutuallyExclusive(t *testing.T) { + path := t.TempDir() + string(os.PathSeparator) + "SKILL.md" + if err := os.WriteFile(path, []byte("body"), 0o644); err != nil { + t.Fatalf("write tempfile: %v", err) + } + + cases := []struct { + name string + set func(*cobra.Command) + }{ + {name: "inline + stdin", set: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("content", "inline") + _ = cmd.Flags().Set("content-stdin", "true") + }}, + {name: "inline + file", set: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("content", "inline") + _ = cmd.Flags().Set("content-file", path) + }}, + {name: "stdin + file", set: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("content-stdin", "true") + _ = cmd.Flags().Set("content-file", path) + }}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + cmd := newSkillCreateTestCmd() + _ = cmd.Flags().Set("name", "skill-name") + tt.set(cmd) + err := runSkillCreate(cmd, nil) + if err == nil { + t.Fatalf("expected mutually-exclusive error") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("error = %v, want mutually exclusive", err) + } + }) + } +} + +func TestRunSkillContentFileAndStdinRejectEmptyInput(t *testing.T) { + emptyPath := t.TempDir() + string(os.PathSeparator) + "empty.md" + if err := os.WriteFile(emptyPath, []byte(""), 0o644); err != nil { + t.Fatalf("write tempfile: %v", err) + } + + cmd := newSkillCreateTestCmd() + _ = cmd.Flags().Set("name", "skill-name") + _ = cmd.Flags().Set("content-file", emptyPath) + if err := runSkillCreate(cmd, nil); err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("empty content-file error = %v", err) + } + + cmd = newSkillCreateTestCmd() + _ = cmd.Flags().Set("name", "skill-name") + _ = cmd.Flags().Set("content-stdin", "true") + pipeStdin(t, "", func() { + if err := runSkillCreate(cmd, nil); err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("empty content-stdin error = %v", err) + } + }) +} + +func TestRunSkillInlineEmptyContentKeepsExistingBehavior(t *testing.T) { + var createBody map[string]any + createSrv := newSkillBodyCaptureServer(t, http.MethodPost, "/api/skills", &createBody) + defer createSrv.Close() + setSkillServerEnv(t, createSrv.URL) + + createCmd := newSkillCreateTestCmd() + _ = createCmd.Flags().Set("name", "skill-name") + _ = createCmd.Flags().Set("content", "") + if _, err := captureStdout(t, func() error { return runSkillCreate(createCmd, nil) }); err != nil { + t.Fatalf("runSkillCreate: %v", err) + } + if _, ok := createBody["content"]; ok { + t.Fatalf("create body unexpectedly included empty content: %#v", createBody) + } + + var updateBody map[string]any + updateSrv := newSkillBodyCaptureServer(t, http.MethodPut, "/api/skills/skill-123", &updateBody) + defer updateSrv.Close() + setSkillServerEnv(t, updateSrv.URL) + + updateCmd := newSkillUpdateTestCmd() + _ = updateCmd.Flags().Set("content", "") + if _, err := captureStdout(t, func() error { return runSkillUpdate(updateCmd, []string{"skill-123"}) }); err != nil { + t.Fatalf("runSkillUpdate: %v", err) + } + if updateBody["content"] != "" { + t.Fatalf("update content = %q, want empty string", updateBody["content"]) + } + + upsertCmd := newSkillFilesUpsertTestCmd() + _ = upsertCmd.Flags().Set("path", "docs/SKILL.md") + _ = upsertCmd.Flags().Set("content", "") + if err := runSkillFilesUpsert(upsertCmd, []string{"skill-123"}); err == nil || !strings.Contains(err.Error(), "--content is required") { + t.Fatalf("upsert inline empty error = %v", err) + } +}