From 072ccc90aac00a7cd8ba76489606560240e7a57f Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 9 Apr 2026 15:57:05 +0800 Subject: [PATCH] feat(cli): add project commands and --project flag for issues Add `multica project` CLI commands (list, get, create, update, delete, status) so agents can manage projects. Also add --project flag to `issue create` and `issue update` for associating issues with projects. --- server/cmd/multica/cmd_issue.go | 9 + server/cmd/multica/cmd_project.go | 376 ++++++++++++++++++++++++++++++ server/cmd/multica/main.go | 2 + 3 files changed, 387 insertions(+) create mode 100644 server/cmd/multica/cmd_project.go diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index e026e7c07..82f1dc5a9 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -147,6 +147,7 @@ func init() { issueCreateCmd.Flags().String("priority", "", "Issue priority") issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent)") issueCreateCmd.Flags().String("parent", "", "Parent issue ID") + issueCreateCmd.Flags().String("project", "", "Project ID") issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)") issueCreateCmd.Flags().String("output", "json", "Output format: table or json") issueCreateCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)") @@ -157,6 +158,7 @@ func init() { issueUpdateCmd.Flags().String("status", "", "New status") issueUpdateCmd.Flags().String("priority", "", "New priority") issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)") + issueUpdateCmd.Flags().String("project", "", "Project ID") issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)") issueUpdateCmd.Flags().String("output", "json", "Output format: table or json") @@ -340,6 +342,9 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error { if v, _ := cmd.Flags().GetString("parent"); v != "" { body["parent_issue_id"] = v } + if v, _ := cmd.Flags().GetString("project"); v != "" { + body["project_id"] = v + } if v, _ := cmd.Flags().GetString("due-date"); v != "" { body["due_date"] = v } @@ -412,6 +417,10 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error { v, _ := cmd.Flags().GetString("priority") body["priority"] = v } + if cmd.Flags().Changed("project") { + v, _ := cmd.Flags().GetString("project") + body["project_id"] = v + } if cmd.Flags().Changed("due-date") { v, _ := cmd.Flags().GetString("due-date") body["due_date"] = v diff --git a/server/cmd/multica/cmd_project.go b/server/cmd/multica/cmd_project.go new file mode 100644 index 000000000..e867ff85c --- /dev/null +++ b/server/cmd/multica/cmd_project.go @@ -0,0 +1,376 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var projectCmd = &cobra.Command{ + Use: "project", + Short: "Work with projects", +} + +var projectListCmd = &cobra.Command{ + Use: "list", + Short: "List projects in the workspace", + RunE: runProjectList, +} + +var projectGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get project details", + Args: exactArgs(1), + RunE: runProjectGet, +} + +var projectCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new project", + RunE: runProjectCreate, +} + +var projectUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a project", + Args: exactArgs(1), + RunE: runProjectUpdate, +} + +var projectDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a project", + Args: exactArgs(1), + RunE: runProjectDelete, +} + +var projectStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Change project status", + Args: exactArgs(2), + RunE: runProjectStatus, +} + +var validProjectStatuses = []string{ + "planned", "in_progress", "paused", "completed", "cancelled", +} + +func init() { + projectCmd.AddCommand(projectListCmd) + projectCmd.AddCommand(projectGetCmd) + projectCmd.AddCommand(projectCreateCmd) + projectCmd.AddCommand(projectUpdateCmd) + projectCmd.AddCommand(projectDeleteCmd) + projectCmd.AddCommand(projectStatusCmd) + + // project list + projectListCmd.Flags().String("output", "table", "Output format: table or json") + projectListCmd.Flags().String("status", "", "Filter by status") + + // project get + projectGetCmd.Flags().String("output", "json", "Output format: table or json") + + // project create + projectCreateCmd.Flags().String("title", "", "Project title (required)") + projectCreateCmd.Flags().String("description", "", "Project description") + projectCreateCmd.Flags().String("status", "", "Project status") + projectCreateCmd.Flags().String("icon", "", "Project icon (emoji)") + projectCreateCmd.Flags().String("lead", "", "Lead name (member or agent)") + projectCreateCmd.Flags().String("output", "json", "Output format: table or json") + + // project update + projectUpdateCmd.Flags().String("title", "", "New title") + projectUpdateCmd.Flags().String("description", "", "New description") + projectUpdateCmd.Flags().String("status", "", "New status") + projectUpdateCmd.Flags().String("icon", "", "New icon (emoji)") + projectUpdateCmd.Flags().String("lead", "", "New lead name (member or agent)") + projectUpdateCmd.Flags().String("output", "json", "Output format: table or json") + + // project delete + projectDeleteCmd.Flags().String("output", "json", "Output format: table or json") + + // project status + projectStatusCmd.Flags().String("output", "table", "Output format: table or json") +} + +// --------------------------------------------------------------------------- +// Project commands +// --------------------------------------------------------------------------- + +func runProjectList(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() + + params := url.Values{} + if client.WorkspaceID != "" { + params.Set("workspace_id", client.WorkspaceID) + } + if v, _ := cmd.Flags().GetString("status"); v != "" { + params.Set("status", v) + } + + path := "/api/projects" + if len(params) > 0 { + path += "?" + params.Encode() + } + + var result map[string]any + if err := client.GetJSON(ctx, path, &result); err != nil { + return fmt.Errorf("list projects: %w", err) + } + + projectsRaw, _ := result["projects"].([]any) + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, projectsRaw) + } + + headers := []string{"ID", "TITLE", "STATUS", "LEAD", "CREATED"} + rows := make([][]string, 0, len(projectsRaw)) + for _, raw := range projectsRaw { + p, ok := raw.(map[string]any) + if !ok { + continue + } + lead := formatLead(p) + created := strVal(p, "created_at") + if len(created) >= 10 { + created = created[:10] + } + rows = append(rows, []string{ + truncateID(strVal(p, "id")), + strVal(p, "title"), + strVal(p, "status"), + lead, + created, + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runProjectGet(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 project map[string]any + if err := client.GetJSON(ctx, "/api/projects/"+args[0], &project); err != nil { + return fmt.Errorf("get project: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + lead := formatLead(project) + headers := []string{"ID", "TITLE", "STATUS", "LEAD", "DESCRIPTION"} + rows := [][]string{{ + truncateID(strVal(project, "id")), + strVal(project, "title"), + strVal(project, "status"), + lead, + strVal(project, "description"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + return cli.PrintJSON(os.Stdout, project) +} + +func runProjectCreate(cmd *cobra.Command, _ []string) error { + title, _ := cmd.Flags().GetString("title") + if title == "" { + return fmt.Errorf("--title is required") + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{"title": title} + if v, _ := cmd.Flags().GetString("description"); v != "" { + body["description"] = v + } + if v, _ := cmd.Flags().GetString("status"); v != "" { + body["status"] = v + } + if v, _ := cmd.Flags().GetString("icon"); v != "" { + body["icon"] = v + } + if v, _ := cmd.Flags().GetString("lead"); v != "" { + aType, aID, resolveErr := resolveAssignee(ctx, client, v) + if resolveErr != nil { + return fmt.Errorf("resolve lead: %w", resolveErr) + } + body["lead_type"] = aType + body["lead_id"] = aID + } + + var result map[string]any + if err := client.PostJSON(ctx, "/api/projects", body, &result); err != nil { + return fmt.Errorf("create project: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + headers := []string{"ID", "TITLE", "STATUS"} + rows := [][]string{{ + truncateID(strVal(result, "id")), + strVal(result, "title"), + strVal(result, "status"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + return cli.PrintJSON(os.Stdout, result) +} + +func runProjectUpdate(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() + + body := map[string]any{} + if cmd.Flags().Changed("title") { + v, _ := cmd.Flags().GetString("title") + body["title"] = v + } + if cmd.Flags().Changed("description") { + v, _ := cmd.Flags().GetString("description") + body["description"] = v + } + if cmd.Flags().Changed("status") { + v, _ := cmd.Flags().GetString("status") + body["status"] = v + } + if cmd.Flags().Changed("icon") { + v, _ := cmd.Flags().GetString("icon") + body["icon"] = v + } + if cmd.Flags().Changed("lead") { + v, _ := cmd.Flags().GetString("lead") + aType, aID, resolveErr := resolveAssignee(ctx, client, v) + if resolveErr != nil { + return fmt.Errorf("resolve lead: %w", resolveErr) + } + body["lead_type"] = aType + body["lead_id"] = aID + } + + if len(body) == 0 { + return fmt.Errorf("no fields to update; use flags like --title, --status, --description, --icon, --lead") + } + + var result map[string]any + if err := client.PutJSON(ctx, "/api/projects/"+args[0], body, &result); err != nil { + return fmt.Errorf("update project: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + headers := []string{"ID", "TITLE", "STATUS"} + rows := [][]string{{ + truncateID(strVal(result, "id")), + strVal(result, "title"), + strVal(result, "status"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + return cli.PrintJSON(os.Stdout, result) +} + +func runProjectDelete(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/projects/"+args[0]); err != nil { + return fmt.Errorf("delete project: %w", err) + } + + fmt.Fprintf(os.Stderr, "Project %s deleted.\n", truncateID(args[0])) + return nil +} + +func runProjectStatus(cmd *cobra.Command, args []string) error { + id := args[0] + status := args[1] + + valid := false + for _, s := range validProjectStatuses { + if s == status { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid status %q; valid values: %s", status, strings.Join(validProjectStatuses, ", ")) + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + body := map[string]any{"status": status} + var result map[string]any + if err := client.PutJSON(ctx, "/api/projects/"+id, body, &result); err != nil { + return fmt.Errorf("update status: %w", err) + } + + fmt.Fprintf(os.Stderr, "Project %s status changed to %s.\n", truncateID(id), status) + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func formatLead(project map[string]any) string { + lType := strVal(project, "lead_type") + lID := strVal(project, "lead_id") + if lType == "" || lID == "" { + return "" + } + return lType + ":" + truncateID(lID) +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 04d2bdfc5..c75fbe3d2 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -27,6 +27,7 @@ func init() { // Core commands issueCmd.GroupID = groupCore + projectCmd.GroupID = groupCore agentCmd.GroupID = groupCore workspaceCmd.GroupID = groupCore repoCmd.GroupID = groupCore @@ -45,6 +46,7 @@ func init() { versionCmd.GroupID = groupAdditional rootCmd.AddCommand(issueCmd) + rootCmd.AddCommand(projectCmd) rootCmd.AddCommand(agentCmd) rootCmd.AddCommand(workspaceCmd) rootCmd.AddCommand(repoCmd)