feat(cli): add skill content file and stdin input

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-06-02 16:41:54 +08:00
parent f539fdba83
commit 74cb4ec7b4
2 changed files with 339 additions and 7 deletions

View File

@@ -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")
}

View File

@@ -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)
}
}