diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index d5391544..a6ca6c2a 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -2,9 +2,11 @@ package main import ( "context" + "encoding/json" "fmt" "net/url" "os" + "strings" "time" "github.com/spf13/cobra" @@ -24,10 +26,124 @@ var agentListCmd = &cobra.Command{ RunE: runAgentList, } +var agentGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get agent details", + Args: cobra.ExactArgs(1), + RunE: runAgentGet, +} + +var agentCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new agent", + RunE: runAgentCreate, +} + +var agentUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentUpdate, +} + +var agentArchiveCmd = &cobra.Command{ + Use: "archive ", + Short: "Archive an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentArchive, +} + +var agentRestoreCmd = &cobra.Command{ + Use: "restore ", + Short: "Restore an archived agent", + Args: cobra.ExactArgs(1), + RunE: runAgentRestore, +} + +var agentTasksCmd = &cobra.Command{ + Use: "tasks ", + Short: "List tasks for an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentTasks, +} + +// Agent skills subcommands. + +var agentSkillsCmd = &cobra.Command{ + Use: "skills", + Short: "Manage agent skill assignments", +} + +var agentSkillsListCmd = &cobra.Command{ + Use: "list ", + Short: "List skills assigned to an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentSkillsList, +} + +var agentSkillsSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set skills for an agent (replaces all current assignments)", + Args: cobra.ExactArgs(1), + RunE: runAgentSkillsSet, +} + func init() { agentCmd.AddCommand(agentListCmd) + agentCmd.AddCommand(agentGetCmd) + agentCmd.AddCommand(agentCreateCmd) + agentCmd.AddCommand(agentUpdateCmd) + agentCmd.AddCommand(agentArchiveCmd) + agentCmd.AddCommand(agentRestoreCmd) + agentCmd.AddCommand(agentTasksCmd) + agentCmd.AddCommand(agentSkillsCmd) + agentSkillsCmd.AddCommand(agentSkillsListCmd) + agentSkillsCmd.AddCommand(agentSkillsSetCmd) + + // agent list agentListCmd.Flags().String("output", "table", "Output format: table or json") + agentListCmd.Flags().Bool("include-archived", false, "Include archived agents") + + // agent get + agentGetCmd.Flags().String("output", "json", "Output format: table or json") + + // agent create + agentCreateCmd.Flags().String("name", "", "Agent name (required)") + agentCreateCmd.Flags().String("description", "", "Agent description") + agentCreateCmd.Flags().String("instructions", "", "Agent instructions") + agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)") + agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string") + agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace") + agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks") + agentCreateCmd.Flags().String("output", "json", "Output format: table or json") + + // agent update + agentUpdateCmd.Flags().String("name", "", "New name") + agentUpdateCmd.Flags().String("description", "", "New description") + agentUpdateCmd.Flags().String("instructions", "", "New instructions") + agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID") + agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string") + agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace") + agentUpdateCmd.Flags().String("status", "", "New status") + agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks") + agentUpdateCmd.Flags().String("output", "json", "Output format: table or json") + + // agent archive + agentArchiveCmd.Flags().String("output", "json", "Output format: table or json") + + // agent restore + agentRestoreCmd.Flags().String("output", "json", "Output format: table or json") + + // agent tasks + agentTasksCmd.Flags().String("output", "table", "Output format: table or json") + + // agent skills list + agentSkillsListCmd.Flags().String("output", "table", "Output format: table or json") + + // agent skills set + agentSkillsSetCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to assign (comma-separated)") + agentSkillsSetCmd.Flags().String("output", "json", "Output format: table or json") } // resolveProfile returns the --profile flag value (empty string means default profile). @@ -90,6 +206,10 @@ func resolveWorkspaceID(cmd *cobra.Command) string { return cfg.WorkspaceID } +// --------------------------------------------------------------------------- +// Agent commands +// --------------------------------------------------------------------------- + func runAgentList(cmd *cobra.Command, _ []string) error { client, err := newAPIClient(cmd) if err != nil { @@ -100,9 +220,16 @@ func runAgentList(cmd *cobra.Command, _ []string) error { defer cancel() var agents []map[string]any - path := "/api/agents" + params := url.Values{} if client.WorkspaceID != "" { - path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode() + params.Set("workspace_id", client.WorkspaceID) + } + if v, _ := cmd.Flags().GetBool("include-archived"); v { + params.Set("include_archived", "true") + } + path := "/api/agents" + if len(params) > 0 { + path += "?" + params.Encode() } if err := client.GetJSON(ctx, path, &agents); err != nil { return fmt.Errorf("list agents: %w", err) @@ -113,20 +240,342 @@ func runAgentList(cmd *cobra.Command, _ []string) error { return cli.PrintJSON(os.Stdout, agents) } - headers := []string{"ID", "NAME", "STATUS", "RUNTIME"} + headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "ARCHIVED"} rows := make([][]string, 0, len(agents)) for _, a := range agents { + archived := "" + if v := strVal(a, "archived_at"); v != "" { + archived = "yes" + } rows = append(rows, []string{ strVal(a, "id"), strVal(a, "name"), strVal(a, "status"), strVal(a, "runtime_mode"), + archived, }) } cli.PrintTable(os.Stdout, headers, rows) return nil } +func runAgentGet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var agent map[string]any + if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil { + return fmt.Errorf("get agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, agent) + } + + headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "VISIBILITY", "DESCRIPTION"} + rows := [][]string{{ + strVal(agent, "id"), + strVal(agent, "name"), + strVal(agent, "status"), + strVal(agent, "runtime_mode"), + strVal(agent, "visibility"), + strVal(agent, "description"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runAgentCreate(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + if name == "" { + return fmt.Errorf("--name is required") + } + runtimeID, _ := cmd.Flags().GetString("runtime-id") + if runtimeID == "" { + return fmt.Errorf("--runtime-id is required") + } + + body := map[string]any{ + "name": name, + "runtime_id": runtimeID, + } + if v, _ := cmd.Flags().GetString("description"); v != "" { + body["description"] = v + } + if v, _ := cmd.Flags().GetString("instructions"); v != "" { + body["instructions"] = v + } + if cmd.Flags().Changed("runtime-config") { + v, _ := cmd.Flags().GetString("runtime-config") + var rc any + if err := json.Unmarshal([]byte(v), &rc); err != nil { + return fmt.Errorf("--runtime-config must be valid JSON: %w", err) + } + body["runtime_config"] = rc + } + if cmd.Flags().Changed("visibility") { + v, _ := cmd.Flags().GetString("visibility") + body["visibility"] = v + } + if cmd.Flags().Changed("max-concurrent-tasks") { + v, _ := cmd.Flags().GetInt32("max-concurrent-tasks") + body["max_concurrent_tasks"] = v + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/agents", body, &result); err != nil { + return fmt.Errorf("create agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent created: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + body := map[string]any{} + 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 + } + if cmd.Flags().Changed("instructions") { + v, _ := cmd.Flags().GetString("instructions") + body["instructions"] = v + } + if cmd.Flags().Changed("runtime-id") { + v, _ := cmd.Flags().GetString("runtime-id") + body["runtime_id"] = v + } + if cmd.Flags().Changed("runtime-config") { + v, _ := cmd.Flags().GetString("runtime-config") + var rc any + if err := json.Unmarshal([]byte(v), &rc); err != nil { + return fmt.Errorf("--runtime-config must be valid JSON: %w", err) + } + body["runtime_config"] = rc + } + if cmd.Flags().Changed("visibility") { + v, _ := cmd.Flags().GetString("visibility") + body["visibility"] = v + } + if cmd.Flags().Changed("status") { + v, _ := cmd.Flags().GetString("status") + body["status"] = v + } + if cmd.Flags().Changed("max-concurrent-tasks") { + v, _ := cmd.Flags().GetInt32("max-concurrent-tasks") + body["max_concurrent_tasks"] = v + } + + if len(body) == 0 { + return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --visibility, --status, or --max-concurrent-tasks") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil { + return fmt.Errorf("update agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentArchive(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/archive", nil, &result); err != nil { + return fmt.Errorf("archive agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent archived: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentRestore(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/restore", nil, &result); err != nil { + return fmt.Errorf("restore agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent restored: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentTasks(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var tasks []map[string]any + if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/tasks", &tasks); err != nil { + return fmt.Errorf("list agent tasks: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, tasks) + } + + headers := []string{"ID", "ISSUE_ID", "STATUS", "CREATED_AT"} + rows := make([][]string, 0, len(tasks)) + for _, t := range tasks { + rows = append(rows, []string{ + strVal(t, "id"), + strVal(t, "issue_id"), + strVal(t, "status"), + strVal(t, "created_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +// --------------------------------------------------------------------------- +// Agent skills subcommands +// --------------------------------------------------------------------------- + +func runAgentSkillsList(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var skills []map[string]any + if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/skills", &skills); err != nil { + return fmt.Errorf("list agent skills: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, skills) + } + + headers := []string{"ID", "NAME", "DESCRIPTION"} + rows := make([][]string, 0, len(skills)) + for _, s := range skills { + rows = append(rows, []string{ + strVal(s, "id"), + strVal(s, "name"), + strVal(s, "description"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runAgentSkillsSet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + if !cmd.Flags().Changed("skill-ids") { + return fmt.Errorf("--skill-ids is required (comma-separated skill IDs; use --skill-ids '' to clear all)") + } + skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids") + // Allow passing empty string to clear all skills. + cleanIDs := make([]string, 0, len(skillIDs)) + for _, id := range skillIDs { + id = strings.TrimSpace(id) + if id != "" { + cleanIDs = append(cleanIDs, id) + } + } + + body := map[string]any{ + "skill_ids": cleanIDs, + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result json.RawMessage + if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/skills", body, &result); err != nil { + return fmt.Errorf("set agent skills: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + var pretty any + json.Unmarshal(result, &pretty) + return cli.PrintJSON(os.Stdout, pretty) + } + + fmt.Printf("Skills updated for agent %s\n", args[0]) + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + func strVal(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { diff --git a/server/cmd/multica/cmd_runtime.go b/server/cmd/multica/cmd_runtime.go new file mode 100644 index 00000000..417efec0 --- /dev/null +++ b/server/cmd/multica/cmd_runtime.go @@ -0,0 +1,306 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var runtimeCmd = &cobra.Command{ + Use: "runtime", + Short: "Manage agent runtimes", +} + +var runtimeListCmd = &cobra.Command{ + Use: "list", + Short: "List runtimes in the workspace", + RunE: runRuntimeList, +} + +var runtimeUsageCmd = &cobra.Command{ + Use: "usage ", + Short: "Get token usage for a runtime", + Args: cobra.ExactArgs(1), + RunE: runRuntimeUsage, +} + +var runtimeActivityCmd = &cobra.Command{ + Use: "activity ", + Short: "Get hourly task activity for a runtime", + Args: cobra.ExactArgs(1), + RunE: runRuntimeActivity, +} + +var runtimePingCmd = &cobra.Command{ + Use: "ping ", + Short: "Ping a runtime to check connectivity", + Args: cobra.ExactArgs(1), + RunE: runRuntimePing, +} + +var runtimeUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Initiate a CLI update on a runtime", + Args: cobra.ExactArgs(1), + RunE: runRuntimeUpdate, +} + +func init() { + runtimeCmd.AddCommand(runtimeListCmd) + runtimeCmd.AddCommand(runtimeUsageCmd) + runtimeCmd.AddCommand(runtimeActivityCmd) + runtimeCmd.AddCommand(runtimePingCmd) + runtimeCmd.AddCommand(runtimeUpdateCmd) + + // runtime list + runtimeListCmd.Flags().String("output", "table", "Output format: table or json") + + // runtime usage + runtimeUsageCmd.Flags().String("output", "table", "Output format: table or json") + runtimeUsageCmd.Flags().Int("days", 90, "Number of days of usage data to retrieve (max 365)") + + // runtime activity + runtimeActivityCmd.Flags().String("output", "table", "Output format: table or json") + + // runtime ping + runtimePingCmd.Flags().String("output", "json", "Output format: table or json") + runtimePingCmd.Flags().Bool("wait", false, "Wait for ping to complete (poll until done)") + + // runtime update + 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 commands +// --------------------------------------------------------------------------- + +func runRuntimeList(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var runtimes []map[string]any + if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil { + return fmt.Errorf("list runtimes: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, runtimes) + } + + headers := []string{"ID", "NAME", "MODE", "PROVIDER", "STATUS", "LAST_SEEN"} + rows := make([][]string, 0, len(runtimes)) + for _, rt := range runtimes { + rows = append(rows, []string{ + strVal(rt, "id"), + strVal(rt, "name"), + strVal(rt, "runtime_mode"), + strVal(rt, "provider"), + strVal(rt, "status"), + strVal(rt, "last_seen_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runRuntimeUsage(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + days, _ := cmd.Flags().GetInt("days") + if days < 1 || days > 365 { + return fmt.Errorf("--days must be between 1 and 365") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var usage []map[string]any + path := fmt.Sprintf("/api/runtimes/%s/usage?days=%d", args[0], days) + if err := client.GetJSON(ctx, path, &usage); err != nil { + return fmt.Errorf("get runtime usage: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, usage) + } + + headers := []string{"DATE", "PROVIDER", "MODEL", "INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE"} + rows := make([][]string, 0, len(usage)) + for _, u := range usage { + rows = append(rows, []string{ + strVal(u, "date"), + strVal(u, "provider"), + strVal(u, "model"), + strVal(u, "input_tokens"), + strVal(u, "output_tokens"), + strVal(u, "cache_read_tokens"), + strVal(u, "cache_write_tokens"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runRuntimeActivity(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var activity []map[string]any + if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/activity", &activity); err != nil { + return fmt.Errorf("get runtime activity: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, activity) + } + + headers := []string{"HOUR", "COUNT"} + rows := make([][]string, 0, len(activity)) + for _, a := range activity { + rows = append(rows, []string{ + strVal(a, "hour"), + strVal(a, "count"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runRuntimePing(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + // Initiate ping. + var ping map[string]any + if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/ping", nil, &ping); err != nil { + return fmt.Errorf("initiate ping: %w", err) + } + + wait, _ := cmd.Flags().GetBool("wait") + if !wait { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, ping) + } + fmt.Printf("Ping initiated: %s (status: %s)\n", strVal(ping, "id"), strVal(ping, "status")) + return nil + } + + // Poll until completed/failed/timeout. + pingID := strVal(ping, "id") + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for ping (last status: %s)", strVal(ping, "status")) + case <-time.After(1 * time.Second): + } + + if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/ping/"+pingID, &ping); err != nil { + return fmt.Errorf("get ping status: %w", err) + } + + status := strVal(ping, "status") + if status == "completed" || status == "failed" || status == "timeout" { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, ping) + } + if status == "completed" { + fmt.Printf("Ping completed in %sms\n", strVal(ping, "duration_ms")) + } else { + fmt.Printf("Ping %s: %s\n", status, strVal(ping, "error")) + } + return nil + } + } +} + +func runRuntimeUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + targetVersion, _ := cmd.Flags().GetString("target-version") + if targetVersion == "" { + return fmt.Errorf("--target-version is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + + body := map[string]any{ + "target_version": targetVersion, + } + + var update map[string]any + if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/update", body, &update); err != nil { + return fmt.Errorf("initiate update: %w", err) + } + + wait, _ := cmd.Flags().GetBool("wait") + if !wait { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, update) + } + fmt.Printf("Update initiated: %s (status: %s)\n", strVal(update, "id"), strVal(update, "status")) + return nil + } + + // Poll until completed/failed/timeout. + updateID := strVal(update, "id") + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for update (last status: %s)", strVal(update, "status")) + case <-time.After(2 * time.Second): + } + + if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/update/"+updateID, &update); err != nil { + return fmt.Errorf("get update status: %w", err) + } + + status := strVal(update, "status") + if status == "completed" || status == "failed" || status == "timeout" { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, update) + } + if status == "completed" { + fmt.Printf("Update completed: %s\n", strVal(update, "output")) + } else { + fmt.Printf("Update %s: %s\n", status, strVal(update, "error")) + } + return nil + } + } +} diff --git a/server/cmd/multica/cmd_skill.go b/server/cmd/multica/cmd_skill.go new file mode 100644 index 00000000..f6de394a --- /dev/null +++ b/server/cmd/multica/cmd_skill.go @@ -0,0 +1,450 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var skillCmd = &cobra.Command{ + Use: "skill", + Short: "Manage skills", +} + +var skillListCmd = &cobra.Command{ + Use: "list", + Short: "List skills in the workspace", + RunE: runSkillList, +} + +var skillGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get skill details (includes files)", + Args: cobra.ExactArgs(1), + RunE: runSkillGet, +} + +var skillCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new skill", + RunE: runSkillCreate, +} + +var skillUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillUpdate, +} + +var skillDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillDelete, +} + +var skillImportCmd = &cobra.Command{ + Use: "import", + Short: "Import a skill from a URL (clawhub.ai or skills.sh)", + RunE: runSkillImport, +} + +// Skill file subcommands. + +var skillFilesCmd = &cobra.Command{ + Use: "files", + Short: "Manage skill files", +} + +var skillFilesListCmd = &cobra.Command{ + Use: "list ", + Short: "List files for a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillFilesList, +} + +var skillFilesUpsertCmd = &cobra.Command{ + Use: "upsert ", + Short: "Create or update a skill file", + Args: cobra.ExactArgs(1), + RunE: runSkillFilesUpsert, +} + +var skillFilesDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a skill file", + Args: cobra.ExactArgs(2), + RunE: runSkillFilesDelete, +} + +func init() { + skillCmd.AddCommand(skillListCmd) + skillCmd.AddCommand(skillGetCmd) + skillCmd.AddCommand(skillCreateCmd) + skillCmd.AddCommand(skillUpdateCmd) + skillCmd.AddCommand(skillDeleteCmd) + skillCmd.AddCommand(skillImportCmd) + skillCmd.AddCommand(skillFilesCmd) + + skillFilesCmd.AddCommand(skillFilesListCmd) + skillFilesCmd.AddCommand(skillFilesUpsertCmd) + skillFilesCmd.AddCommand(skillFilesDeleteCmd) + + // skill list + skillListCmd.Flags().String("output", "table", "Output format: table or json") + + // skill get + skillGetCmd.Flags().String("output", "json", "Output format: table or json") + + // skill create + skillCreateCmd.Flags().String("name", "", "Skill name (required)") + 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("output", "json", "Output format: table or json") + + // skill update + skillUpdateCmd.Flags().String("name", "", "New name") + skillUpdateCmd.Flags().String("description", "", "New description") + skillUpdateCmd.Flags().String("content", "", "New content") + skillUpdateCmd.Flags().String("config", "", "New config as JSON string") + skillUpdateCmd.Flags().String("output", "json", "Output format: table or json") + + // skill delete + skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt") + + // skill import + skillImportCmd.Flags().String("url", "", "URL to import from (required)") + skillImportCmd.Flags().String("output", "json", "Output format: table or json") + + // skill files list + skillFilesListCmd.Flags().String("output", "table", "Output format: table or json") + + // skill files upsert + skillFilesUpsertCmd.Flags().String("path", "", "File path within the skill (required)") + skillFilesUpsertCmd.Flags().String("content", "", "File content (required)") + skillFilesUpsertCmd.Flags().String("output", "json", "Output format: table or json") +} + +// --------------------------------------------------------------------------- +// Skill commands +// --------------------------------------------------------------------------- + +func runSkillList(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var skills []map[string]any + if err := client.GetJSON(ctx, "/api/skills", &skills); err != nil { + return fmt.Errorf("list skills: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, skills) + } + + headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"} + rows := make([][]string, 0, len(skills)) + for _, s := range skills { + rows = append(rows, []string{ + strVal(s, "id"), + strVal(s, "name"), + strVal(s, "description"), + strVal(s, "created_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runSkillGet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var skill map[string]any + if err := client.GetJSON(ctx, "/api/skills/"+args[0], &skill); err != nil { + return fmt.Errorf("get skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, skill) + } + + headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"} + rows := [][]string{{ + strVal(skill, "id"), + strVal(skill, "name"), + strVal(skill, "description"), + strVal(skill, "created_at"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runSkillCreate(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + if name == "" { + return fmt.Errorf("--name is required") + } + + body := map[string]any{ + "name": name, + } + if v, _ := cmd.Flags().GetString("description"); v != "" { + body["description"] = v + } + if v, _ := cmd.Flags().GetString("content"); v != "" { + body["content"] = v + } + if cmd.Flags().Changed("config") { + v, _ := cmd.Flags().GetString("config") + var config any + if err := json.Unmarshal([]byte(v), &config); err != nil { + return fmt.Errorf("--config must be valid JSON: %w", err) + } + body["config"] = config + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/skills", body, &result); err != nil { + return fmt.Errorf("create skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill created: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runSkillUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + body := map[string]any{} + 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 + } + if cmd.Flags().Changed("content") { + v, _ := cmd.Flags().GetString("content") + body["content"] = v + } + if cmd.Flags().Changed("config") { + v, _ := cmd.Flags().GetString("config") + var config any + if err := json.Unmarshal([]byte(v), &config); err != nil { + return fmt.Errorf("--config must be valid JSON: %w", err) + } + body["config"] = config + } + + if len(body) == 0 { + return fmt.Errorf("no fields to update; use --name, --description, --content, or --config") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PutJSON(ctx, "/api/skills/"+args[0], body, &result); err != nil { + return fmt.Errorf("update skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runSkillDelete(cmd *cobra.Command, args []string) error { + yes, _ := cmd.Flags().GetBool("yes") + if !yes { + fmt.Printf("Are you sure you want to delete skill %s? This cannot be undone. [y/N] ", args[0]) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]); err != nil { + return fmt.Errorf("delete skill: %w", err) + } + + fmt.Printf("Skill deleted: %s\n", args[0]) + return nil +} + +func runSkillImport(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + importURL, _ := cmd.Flags().GetString("url") + if importURL == "" { + return fmt.Errorf("--url is required") + } + + body := map[string]any{ + "url": importURL, + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil { + return fmt.Errorf("import skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +// --------------------------------------------------------------------------- +// Skill file subcommands +// --------------------------------------------------------------------------- + +func runSkillFilesList(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var files []map[string]any + if err := client.GetJSON(ctx, "/api/skills/"+args[0]+"/files", &files); err != nil { + return fmt.Errorf("list skill files: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, files) + } + + headers := []string{"ID", "PATH", "CREATED_AT", "UPDATED_AT"} + rows := make([][]string, 0, len(files)) + for _, f := range files { + rows = append(rows, []string{ + strVal(f, "id"), + strVal(f, "path"), + strVal(f, "created_at"), + strVal(f, "updated_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runSkillFilesUpsert(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + filePath, _ := cmd.Flags().GetString("path") + if filePath == "" { + return fmt.Errorf("--path is required") + } + content, _ := cmd.Flags().GetString("content") + if content == "" { + return fmt.Errorf("--content is required") + } + + body := map[string]any{ + "path": filePath, + "content": content, + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PutJSON(ctx, "/api/skills/"+args[0]+"/files", body, &result); err != nil { + return fmt.Errorf("upsert skill file: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill file upserted: %s (%s)\n", strVal(result, "path"), strVal(result, "id")) + return nil +} + +func runSkillFilesDelete(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]+"/files/"+args[1]); err != nil { + return fmt.Errorf("delete skill file: %w", err) + } + + fmt.Printf("Skill file deleted: %s\n", args[1]) + return nil +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 75e7120a..53cc903f 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -36,6 +36,8 @@ func init() { rootCmd.AddCommand(repoCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(updateCmd) + rootCmd.AddCommand(skillCmd) + rootCmd.AddCommand(runtimeCmd) } func main() {