mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 07:59:30 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/matt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b36841e4 |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
120
server/cmd/multica/cmd_runtime_local_skills_test.go
Normal file
120
server/cmd/multica/cmd_runtime_local_skills_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
150
server/cmd/multica/cmd_skill_bundle_test.go
Normal file
150
server/cmd/multica/cmd_skill_bundle_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user