Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
19b36841e4 Add CLI support for local skill lifecycle
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 14:28:11 +08:00
5 changed files with 498 additions and 8 deletions

View File

@@ -43,11 +43,33 @@ var runtimeUpdateCmd = &cobra.Command{
RunE: runRuntimeUpdate,
}
var runtimeLocalSkillsCmd = &cobra.Command{
Use: "local-skills",
Short: "List and import runtime-local skills",
}
var runtimeLocalSkillsListCmd = &cobra.Command{
Use: "list <runtime-id>",
Short: "List local skills exposed by a runtime",
Args: exactArgs(1),
RunE: runRuntimeLocalSkillsList,
}
var runtimeLocalSkillsImportCmd = &cobra.Command{
Use: "import <runtime-id> <skill-key>",
Short: "Import a runtime-local skill into the workspace",
Args: exactArgs(2),
RunE: runRuntimeLocalSkillsImport,
}
func init() {
runtimeCmd.AddCommand(runtimeListCmd)
runtimeCmd.AddCommand(runtimeUsageCmd)
runtimeCmd.AddCommand(runtimeActivityCmd)
runtimeCmd.AddCommand(runtimeUpdateCmd)
runtimeCmd.AddCommand(runtimeLocalSkillsCmd)
runtimeLocalSkillsCmd.AddCommand(runtimeLocalSkillsListCmd)
runtimeLocalSkillsCmd.AddCommand(runtimeLocalSkillsImportCmd)
// runtime list
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
@@ -63,6 +85,12 @@ func init() {
runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)")
runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json")
runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)")
// runtime local-skills
runtimeLocalSkillsListCmd.Flags().String("output", "table", "Output format: table or json")
runtimeLocalSkillsImportCmd.Flags().String("name", "", "Override imported skill name")
runtimeLocalSkillsImportCmd.Flags().String("description", "", "Override imported skill description")
runtimeLocalSkillsImportCmd.Flags().String("output", "json", "Output format: table or json")
}
// ---------------------------------------------------------------------------
@@ -238,3 +266,120 @@ func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
}
}
}
const (
runtimeLocalSkillsPollInterval = 500 * time.Millisecond
runtimeLocalSkillsListTimeout = 30 * time.Second
runtimeLocalSkillsImportTimeout = 4 * time.Minute
)
func runRuntimeLocalSkillsList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
runtimeID := args[0]
ctx, cancel := context.WithTimeout(context.Background(), runtimeLocalSkillsListTimeout)
defer cancel()
var req map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+runtimeID+"/local-skills", map[string]any{}, &req); err != nil {
return fmt.Errorf("initiate local skill list: %w", err)
}
req, err = pollRuntimeLocalSkillRequest(ctx, client, "/api/runtimes/"+runtimeID+"/local-skills/"+strVal(req, "id"), req)
if err != nil {
return err
}
if strVal(req, "status") != "completed" {
return fmt.Errorf("local skill list %s: %s", strVal(req, "status"), strVal(req, "error"))
}
if supported, ok := req["supported"].(bool); ok && !supported {
return fmt.Errorf("runtime does not support local skills")
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, req)
}
skills, _ := req["skills"].([]any)
headers := []string{"KEY", "NAME", "PROVIDER", "FILES", "SOURCE"}
rows := make([][]string, 0, len(skills))
for _, item := range skills {
s, _ := item.(map[string]any)
rows = append(rows, []string{
strVal(s, "key"),
strVal(s, "name"),
strVal(s, "provider"),
strVal(s, "file_count"),
strVal(s, "source_path"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimeLocalSkillsImport(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
runtimeID := args[0]
body := map[string]any{"skill_key": args[1]}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
ctx, cancel := context.WithTimeout(context.Background(), runtimeLocalSkillsImportTimeout)
defer cancel()
var req map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+runtimeID+"/local-skills/import", body, &req); err != nil {
return fmt.Errorf("initiate local skill import: %w", err)
}
req, err = pollRuntimeLocalSkillRequest(ctx, client, "/api/runtimes/"+runtimeID+"/local-skills/import/"+strVal(req, "id"), req)
if err != nil {
return err
}
if strVal(req, "status") != "completed" {
return fmt.Errorf("local skill import %s: %s", strVal(req, "status"), strVal(req, "error"))
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, req)
}
skill, _ := req["skill"].(map[string]any)
fmt.Printf("Skill imported: %s (%s)\n", strVal(skill, "name"), strVal(skill, "id"))
return nil
}
func pollRuntimeLocalSkillRequest(ctx context.Context, client *cli.APIClient, path string, req map[string]any) (map[string]any, error) {
for {
status := strVal(req, "status")
if status == "completed" || status == "failed" || status == "timeout" {
return req, nil
}
select {
case <-ctx.Done():
return req, fmt.Errorf("timed out waiting for runtime local skill request (last status: %s)", status)
case <-time.After(runtimeLocalSkillsPollInterval):
}
var next map[string]any
if err := client.GetJSON(ctx, path, &next); err != nil {
return req, fmt.Errorf("get runtime local skill request: %w", err)
}
req = next
}
}

View File

@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/spf13/cobra"
)
func newRuntimeLocalSkillsListTestCmd() *cobra.Command {
cmd := testCmd()
cmd.Flags().String("output", "json", "")
return cmd
}
func newRuntimeLocalSkillsImportTestCmd() *cobra.Command {
cmd := testCmd()
cmd.Flags().String("name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func TestRunRuntimeLocalSkillsListPollsUntilCompleted(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/runtimes/runtime-1/local-skills":
if r.Method != http.MethodPost {
t.Fatalf("list initiate method = %s", r.Method)
}
_, _ = w.Write([]byte(`{"id":"req-1","runtime_id":"runtime-1","status":"pending","supported":true}`))
case "/api/runtimes/runtime-1/local-skills/req-1":
if r.Method != http.MethodGet {
t.Fatalf("list poll method = %s", r.Method)
}
_, _ = w.Write([]byte(`{"id":"req-1","runtime_id":"runtime-1","status":"completed","supported":true,"skills":[{"key":"review","name":"Review","provider":"claude","source_path":"~/.claude/skills/review","file_count":2}]}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newRuntimeLocalSkillsListTestCmd()
if err := runRuntimeLocalSkillsList(cmd, []string{"runtime-1"}); err != nil {
t.Fatalf("runRuntimeLocalSkillsList: %v", err)
}
want := []string{
"POST /api/runtimes/runtime-1/local-skills",
"GET /api/runtimes/runtime-1/local-skills/req-1",
}
if len(paths) != len(want) {
t.Fatalf("paths = %#v, want %#v", paths, want)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths = %#v, want %#v", paths, want)
}
}
}
func TestRunRuntimeLocalSkillsImportPostsSkillKeyAndPolls(t *testing.T) {
var gotBody map[string]any
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/runtimes/runtime-1/local-skills/import":
if r.Method != http.MethodPost {
t.Fatalf("import initiate method = %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode body: %v", err)
}
_, _ = w.Write([]byte(`{"id":"imp-1","runtime_id":"runtime-1","skill_key":"review","status":"running"}`))
case "/api/runtimes/runtime-1/local-skills/import/imp-1":
if r.Method != http.MethodGet {
t.Fatalf("import poll method = %s", r.Method)
}
_, _ = w.Write([]byte(`{"id":"imp-1","runtime_id":"runtime-1","skill_key":"review","status":"completed","skill":{"id":"skill-1","name":"Review"}}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newRuntimeLocalSkillsImportTestCmd()
_ = cmd.Flags().Set("name", "Override")
if err := runRuntimeLocalSkillsImport(cmd, []string{"runtime-1", "review"}); err != nil {
t.Fatalf("runRuntimeLocalSkillsImport: %v", err)
}
if gotBody["skill_key"] != "review" || gotBody["name"] != "Override" {
t.Fatalf("import body = %#v", gotBody)
}
want := []string{
"POST /api/runtimes/runtime-1/local-skills/import",
"GET /api/runtimes/runtime-1/local-skills/import/imp-1",
}
if len(paths) != len(want) {
t.Fatalf("paths = %#v, want %#v", paths, want)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths = %#v, want %#v", paths, want)
}
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/internal/daemon"
)
var skillCmd = &cobra.Command{
@@ -110,6 +111,7 @@ func init() {
skillCreateCmd.Flags().String("description", "", "Skill description")
skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)")
skillCreateCmd.Flags().String("config", "", "Skill config as JSON string")
skillCreateCmd.Flags().String("bundle-dir", "", "Local skill bundle directory containing SKILL.md")
skillCreateCmd.Flags().String("output", "json", "Output format: table or json")
// skill update
@@ -117,6 +119,7 @@ func init() {
skillUpdateCmd.Flags().String("description", "", "New description")
skillUpdateCmd.Flags().String("content", "", "New content")
skillUpdateCmd.Flags().String("config", "", "New config as JSON string")
skillUpdateCmd.Flags().String("bundle-dir", "", "Local skill bundle directory containing SKILL.md; replaces supporting files")
skillUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// skill delete
@@ -208,18 +211,24 @@ func runSkillCreate(cmd *cobra.Command, _ []string) error {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
body := map[string]any{}
if err := applySkillBundleFlags(cmd, body); err != nil {
return err
}
body := map[string]any{
"name": name,
name, _ := cmd.Flags().GetString("name")
if cmd.Flags().Changed("name") {
body["name"] = name
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
if strVal(body, "name") == "" {
return fmt.Errorf("--name is required unless --bundle-dir SKILL.md has frontmatter name")
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if v, _ := cmd.Flags().GetString("content"); v != "" {
if cmd.Flags().Changed("content") {
v, _ := cmd.Flags().GetString("content")
body["content"] = v
}
if cmd.Flags().Changed("config") {
@@ -255,6 +264,9 @@ func runSkillUpdate(cmd *cobra.Command, args []string) error {
}
body := map[string]any{}
if err := applySkillBundleFlags(cmd, body); err != nil {
return err
}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
@@ -277,7 +289,7 @@ func runSkillUpdate(cmd *cobra.Command, args []string) error {
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --content, or --config")
return fmt.Errorf("no fields to update; use --name, --description, --content, --config, or --bundle-dir")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
@@ -297,6 +309,22 @@ func runSkillUpdate(cmd *cobra.Command, args []string) error {
return nil
}
func applySkillBundleFlags(cmd *cobra.Command, body map[string]any) error {
bundleDir, _ := cmd.Flags().GetString("bundle-dir")
if bundleDir == "" {
return nil
}
bundle, err := daemon.LoadSkillBundleFromDir(bundleDir)
if err != nil {
return fmt.Errorf("read --bundle-dir: %w", err)
}
body["name"] = bundle.Name
body["description"] = bundle.Description
body["content"] = bundle.Content
body["files"] = bundle.Files
return nil
}
func runSkillDelete(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {

View File

@@ -0,0 +1,150 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/spf13/cobra"
)
func newSkillCreateBundleTestCmd() *cobra.Command {
cmd := testCmd()
cmd.Flags().String("name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("content", "", "")
cmd.Flags().String("config", "", "")
cmd.Flags().String("bundle-dir", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func newSkillUpdateBundleTestCmd() *cobra.Command {
cmd := testCmd()
cmd.Flags().String("name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("content", "", "")
cmd.Flags().String("config", "", "")
cmd.Flags().String("bundle-dir", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func writeSkillBundle(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for rel, content := range files {
path := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", rel, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
return dir
}
func TestRunSkillCreateBundleDirPostsSkillWithFiles(t *testing.T) {
bundleDir := writeSkillBundle(t, map[string]string{
"SKILL.md": "---\nname: Bundle Helper\ndescription: Ships as a directory\n---\n# Bundle Helper\n",
"references/api.md": "api notes",
"templates/prompt.md": "prompt body",
"scripts/run.sh": "#!/bin/sh\ntrue\n",
"LICENSE": "ignored",
".hidden/secret.txt": "ignored",
})
var gotPath, gotMethod string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"skill-1","name":"Bundle Helper","files":[]}`))
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newSkillCreateBundleTestCmd()
_ = cmd.Flags().Set("bundle-dir", bundleDir)
if err := runSkillCreate(cmd, nil); err != nil {
t.Fatalf("runSkillCreate: %v", err)
}
if gotMethod != http.MethodPost || gotPath != "/api/skills" {
t.Fatalf("request = %s %s, want POST /api/skills", gotMethod, gotPath)
}
if gotBody["name"] != "Bundle Helper" || gotBody["description"] != "Ships as a directory" {
t.Fatalf("frontmatter fields not mapped: %#v", gotBody)
}
if gotBody["content"] != "---\nname: Bundle Helper\ndescription: Ships as a directory\n---\n# Bundle Helper\n" {
t.Fatalf("content = %#v", gotBody["content"])
}
files := gotBody["files"].([]any)
gotFiles := make(map[string]string, len(files))
for _, item := range files {
f := item.(map[string]any)
gotFiles[f["path"].(string)] = f["content"].(string)
}
wantFiles := map[string]string{
"references/api.md": "api notes",
"scripts/run.sh": "#!/bin/sh\ntrue\n",
"templates/prompt.md": "prompt body",
}
if !reflect.DeepEqual(gotFiles, wantFiles) {
t.Fatalf("files = %#v, want %#v", gotFiles, wantFiles)
}
}
func TestRunSkillUpdateBundleDirReplacesFiles(t *testing.T) {
bundleDir := writeSkillBundle(t, map[string]string{
"SKILL.md": "---\nname: Updated Helper\ndescription: Updated desc\n---\n# Updated\n",
"assets/example.txt": "asset",
})
var gotPath, gotMethod string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"skill-123","name":"Updated Helper","files":[]}`))
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newSkillUpdateBundleTestCmd()
_ = cmd.Flags().Set("bundle-dir", bundleDir)
if err := runSkillUpdate(cmd, []string{"skill-123"}); err != nil {
t.Fatalf("runSkillUpdate: %v", err)
}
if gotMethod != http.MethodPut || gotPath != "/api/skills/skill-123" {
t.Fatalf("request = %s %s, want PUT /api/skills/skill-123", gotMethod, gotPath)
}
if gotBody["name"] != "Updated Helper" || gotBody["description"] != "Updated desc" {
t.Fatalf("frontmatter fields not mapped: %#v", gotBody)
}
files := gotBody["files"].([]any)
if len(files) != 1 || files[0].(map[string]any)["path"] != "assets/example.txt" {
t.Fatalf("files = %#v, want replacement bundle files", files)
}
}

View File

@@ -38,6 +38,18 @@ type runtimeLocalSkillBundle struct {
Files []SkillFileData `json:"files,omitempty"`
}
// SkillBundle is a local skill directory ready to send to the API. It mirrors
// the runtime-local import payload but is provider-neutral so the CLI can use
// the same SKILL.md parsing and supporting-file collection as daemons.
type SkillBundle struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Content string `json:"content"`
SourcePath string `json:"source_path"`
Provider string `json:"provider,omitempty"`
Files []SkillFileData `json:"files,omitempty"`
}
// localSkillRootForProvider tracks the user-level skill locations exposed by
// each runtime/provider. Keep these in sync with upstream docs / conventions:
// - GitHub Copilot: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills
@@ -245,6 +257,41 @@ func collectLocalSkillFiles(skillDir string, includeContent bool) ([]SkillFileDa
return files, nil
}
// LoadSkillBundleFromDir reads a local skill bundle rooted at skillDir. The
// bundle must contain SKILL.md; supporting files are every non-hidden file
// below the directory except SKILL.md and license files.
func LoadSkillBundleFromDir(skillDir string) (*SkillBundle, error) {
info, err := os.Stat(skillDir)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("skill bundle is not a directory")
}
content, err := readLocalSkillMainFile(skillDir)
if err != nil {
return nil, err
}
name, description := parseLocalSkillFrontmatter(content)
if name == "" {
name = filepath.Base(skillDir)
}
files, err := collectLocalSkillFiles(skillDir, true)
if err != nil {
return nil, err
}
return &SkillBundle{
Name: name,
Description: description,
Content: content,
SourcePath: filepath.ToSlash(skillDir),
Files: files,
}, nil
}
func listRuntimeLocalSkills(provider string) ([]runtimeLocalSkillSummary, bool, error) {
root, supported, err := localSkillRootForProvider(provider)
if err != nil || !supported {