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.
This commit is contained in:
Jiang Bohan
2026-04-09 15:57:05 +08:00
parent d7a37f60b5
commit 072ccc90aa
3 changed files with 387 additions and 0 deletions

View File

@@ -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

View File

@@ -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 <id>",
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 <id>",
Short: "Update a project",
Args: exactArgs(1),
RunE: runProjectUpdate,
}
var projectDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a project",
Args: exactArgs(1),
RunE: runProjectDelete,
}
var projectStatusCmd = &cobra.Command{
Use: "status <id> <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)
}

View File

@@ -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)