mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
feat(cli): add skill content file and stdin input
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user