mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69509d870b | ||
|
|
124c137d90 | ||
|
|
d9c2c4a696 |
@@ -40,6 +40,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica workspace list` | List every workspace you can access |
|
||||
| `multica workspace get <slug>` | Show details for one workspace |
|
||||
| `multica workspace members` | List members of the current workspace |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
|
||||
|
||||
## Issues and projects
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `multica workspace members` | 列出当前工作区的成员 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据(admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
|
||||
|
||||
## Issue 和 Project
|
||||
|
||||
|
||||
@@ -213,6 +213,28 @@ multica workspace get <workspace-id> --output json
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
### Update Workspace
|
||||
|
||||
需要 admin 或 owner 权限。所有字段都是部分更新(PATCH 语义):未传的字段保持不变。
|
||||
|
||||
```bash
|
||||
multica workspace update <workspace-id> --name "Acme Eng"
|
||||
multica workspace update <workspace-id> \
|
||||
--description "Engineering team workspace" \
|
||||
--issue-prefix ENG
|
||||
```
|
||||
|
||||
长文本走 stdin(保留换行/反斜杠):
|
||||
|
||||
```bash
|
||||
cat <<'CTX' | multica workspace update <workspace-id> --context-stdin
|
||||
我们是一支 5 人 AI-native 团队。
|
||||
工作语言:中文 + 英文混合。
|
||||
CTX
|
||||
```
|
||||
|
||||
可编辑字段:`--name`、`--description` / `--description-stdin`、`--context` / `--context-stdin`、`--issue-prefix`。`slug` 创建后只读,不暴露在 CLI。`--description` 与 `--description-stdin`(以及 `context` 同名对)互斥。未传任何字段 flag 时命令拒绝执行,避免空 PATCH 触发无意义的 workspace 更新事件。`--issue-prefix ""` 也会被拒绝:当前后端在 prefix 为空时静默跳过该字段,CLI 在本地拦下避免“看似成功的 no-op”。
|
||||
|
||||
## Issues
|
||||
|
||||
### List Issues
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -38,13 +39,29 @@ var workspaceMembersCmd = &cobra.Command{
|
||||
RunE: runWorkspaceMembers,
|
||||
}
|
||||
|
||||
var workspaceUpdateCmd = &cobra.Command{
|
||||
Use: "update [workspace-id]",
|
||||
Short: "Update workspace metadata (admin/owner only)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runWorkspaceUpdate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
workspaceCmd.AddCommand(workspaceListCmd)
|
||||
workspaceCmd.AddCommand(workspaceGetCmd)
|
||||
workspaceCmd.AddCommand(workspaceMembersCmd)
|
||||
workspaceCmd.AddCommand(workspaceUpdateCmd)
|
||||
|
||||
workspaceGetCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
workspaceMembersCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
workspaceUpdateCmd.Flags().String("name", "", "New workspace name")
|
||||
workspaceUpdateCmd.Flags().String("description", "", "New description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)")
|
||||
workspaceUpdateCmd.Flags().Bool("description-stdin", false, "Read description from stdin (preserves multi-line content verbatim)")
|
||||
workspaceUpdateCmd.Flags().String("context", "", "New workspace context (decodes \\n, \\r, \\t, \\\\; pipe via --context-stdin to preserve literal backslashes)")
|
||||
workspaceUpdateCmd.Flags().Bool("context-stdin", false, "Read context from stdin (preserves multi-line content verbatim)")
|
||||
workspaceUpdateCmd.Flags().String("issue-prefix", "", "New issue prefix (uppercased server-side)")
|
||||
workspaceUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
func runWorkspaceList(cmd *cobra.Command, _ []string) error {
|
||||
@@ -132,6 +149,97 @@ func runWorkspaceGet(cmd *cobra.Command, args []string) error {
|
||||
return cli.PrintJSON(os.Stdout, ws)
|
||||
}
|
||||
|
||||
// buildWorkspaceUpdateBody assembles the PATCH payload from the flags the
|
||||
// caller actually set, mirroring server/internal/handler/workspace.go's
|
||||
// UpdateWorkspaceRequest. Only fields whose flag is Changed() are emitted, so
|
||||
// the caller cannot accidentally clobber a field they did not pass.
|
||||
func buildWorkspaceUpdateBody(cmd *cobra.Command) (map[string]any, error) {
|
||||
body := map[string]any{}
|
||||
if cmd.Flags().Changed("name") {
|
||||
v, _ := cmd.Flags().GetString("name")
|
||||
body["name"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-stdin") {
|
||||
desc, _, err := resolveTextFlag(cmd, "description")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["description"] = desc
|
||||
}
|
||||
if cmd.Flags().Changed("context") || cmd.Flags().Changed("context-stdin") {
|
||||
ctxText, _, err := resolveTextFlag(cmd, "context")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["context"] = ctxText
|
||||
}
|
||||
if cmd.Flags().Changed("issue-prefix") {
|
||||
v, _ := cmd.Flags().GetString("issue-prefix")
|
||||
// The handler silently skips an empty prefix (workspace.go:274), so
|
||||
// `--issue-prefix ""` would otherwise return 200 without changing
|
||||
// anything. Reject it here so the failure is visible.
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil, fmt.Errorf("--issue-prefix cannot be empty; clearing the prefix is not supported")
|
||||
}
|
||||
body["issue_prefix"] = v
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func runWorkspaceUpdate(cmd *cobra.Command, args []string) error {
|
||||
wsID := workspaceIDFromArgs(cmd, args)
|
||||
if wsID == "" {
|
||||
return fmt.Errorf("workspace ID is required: pass as argument or set MULTICA_WORKSPACE_ID")
|
||||
}
|
||||
|
||||
body, err := buildWorkspaceUpdateBody(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --context, or --issue-prefix")
|
||||
}
|
||||
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var ws map[string]any
|
||||
if err := client.PatchJSON(ctx, "/api/workspaces/"+wsID, body, &ws); err != nil {
|
||||
return fmt.Errorf("update workspace: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "table" {
|
||||
desc := strVal(ws, "description")
|
||||
if utf8.RuneCountInString(desc) > 60 {
|
||||
runes := []rune(desc)
|
||||
desc = string(runes[:57]) + "..."
|
||||
}
|
||||
wsContext := strVal(ws, "context")
|
||||
if utf8.RuneCountInString(wsContext) > 60 {
|
||||
runes := []rune(wsContext)
|
||||
wsContext = string(runes[:57]) + "..."
|
||||
}
|
||||
headers := []string{"ID", "NAME", "SLUG", "DESCRIPTION", "CONTEXT"}
|
||||
rows := [][]string{{
|
||||
strVal(ws, "id"),
|
||||
strVal(ws, "name"),
|
||||
strVal(ws, "slug"),
|
||||
desc,
|
||||
wsContext,
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.PrintJSON(os.Stdout, ws)
|
||||
}
|
||||
|
||||
func runWorkspaceMembers(cmd *cobra.Command, args []string) error {
|
||||
wsID := workspaceIDFromArgs(cmd, args)
|
||||
if wsID == "" {
|
||||
@@ -169,4 +277,3 @@ func runWorkspaceMembers(cmd *cobra.Command, args []string) error {
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
154
server/cmd/multica/cmd_workspace_test.go
Normal file
154
server/cmd/multica/cmd_workspace_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetWorkspaceUpdateFlags clears every flag on workspaceUpdateCmd and marks
|
||||
// each as not-Changed. The cobra.Command instance is a process-wide singleton,
|
||||
// so previous subtests leak state into the next one without this guard.
|
||||
func resetWorkspaceUpdateFlags(t *testing.T) {
|
||||
t.Helper()
|
||||
flags := workspaceUpdateCmd.Flags()
|
||||
for _, name := range []string{"name", "description", "context", "issue-prefix"} {
|
||||
_ = flags.Set(name, "")
|
||||
if f := flags.Lookup(name); f != nil {
|
||||
f.Changed = false
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"description-stdin", "context-stdin"} {
|
||||
_ = flags.Set(name, "false")
|
||||
if f := flags.Lookup(name); f != nil {
|
||||
f.Changed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setStringFlag(t *testing.T, name, value string) {
|
||||
t.Helper()
|
||||
if err := workspaceUpdateCmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func setBoolFlag(t *testing.T, name string, value bool) {
|
||||
t.Helper()
|
||||
v := "false"
|
||||
if value {
|
||||
v = "true"
|
||||
}
|
||||
if err := workspaceUpdateCmd.Flags().Set(name, v); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkspaceUpdateBody(t *testing.T) {
|
||||
t.Run("only changed flags appear in body", func(t *testing.T) {
|
||||
resetWorkspaceUpdateFlags(t)
|
||||
setStringFlag(t, "name", "Acme Eng")
|
||||
|
||||
body, err := buildWorkspaceUpdateBody(workspaceUpdateCmd)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got, _ := body["name"].(string); got != "Acme Eng" {
|
||||
t.Errorf("name = %v, want Acme Eng", body["name"])
|
||||
}
|
||||
for _, key := range []string{"description", "context", "issue_prefix"} {
|
||||
if _, present := body[key]; present {
|
||||
t.Errorf("%s should not appear when its flag was not set, got %v", key, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple fields combine into one PATCH body", func(t *testing.T) {
|
||||
resetWorkspaceUpdateFlags(t)
|
||||
setStringFlag(t, "name", "Acme")
|
||||
setStringFlag(t, "description", `line1\nline2`)
|
||||
setStringFlag(t, "issue-prefix", "ENG")
|
||||
|
||||
body, err := buildWorkspaceUpdateBody(workspaceUpdateCmd)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if body["name"] != "Acme" {
|
||||
t.Errorf("name = %v, want Acme", body["name"])
|
||||
}
|
||||
// resolveTextFlag decodes \n in inline values.
|
||||
if body["description"] != "line1\nline2" {
|
||||
t.Errorf("description = %q, want decoded newline", body["description"])
|
||||
}
|
||||
if body["issue_prefix"] != "ENG" {
|
||||
t.Errorf("issue_prefix = %v, want ENG", body["issue_prefix"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline + stdin is rejected for description", func(t *testing.T) {
|
||||
resetWorkspaceUpdateFlags(t)
|
||||
setStringFlag(t, "description", "inline")
|
||||
setBoolFlag(t, "description-stdin", true)
|
||||
|
||||
if _, err := buildWorkspaceUpdateBody(workspaceUpdateCmd); err == nil {
|
||||
t.Fatalf("expected mutually-exclusive error for --description and --description-stdin")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("context-stdin reads from stdin", func(t *testing.T) {
|
||||
resetWorkspaceUpdateFlags(t)
|
||||
setBoolFlag(t, "context-stdin", true)
|
||||
|
||||
stdinBody := "first\nsecond line with literal \\n\n"
|
||||
var got map[string]any
|
||||
pipeStdin(t, stdinBody, func() {
|
||||
b, err := buildWorkspaceUpdateBody(workspaceUpdateCmd)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
got = b
|
||||
})
|
||||
want := "first\nsecond line with literal \\n"
|
||||
if got["context"] != want {
|
||||
t.Errorf("context = %q, want %q", got["context"], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty issue-prefix is rejected", func(t *testing.T) {
|
||||
resetWorkspaceUpdateFlags(t)
|
||||
setStringFlag(t, "issue-prefix", "")
|
||||
// Force Changed=true so the flag is treated as "explicitly passed".
|
||||
if f := workspaceUpdateCmd.Flags().Lookup("issue-prefix"); f != nil {
|
||||
f.Changed = true
|
||||
}
|
||||
|
||||
_, err := buildWorkspaceUpdateBody(workspaceUpdateCmd)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when --issue-prefix is empty")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot be empty") {
|
||||
t.Errorf("error = %q, want it to mention 'cannot be empty'", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace-only issue-prefix is rejected", func(t *testing.T) {
|
||||
resetWorkspaceUpdateFlags(t)
|
||||
setStringFlag(t, "issue-prefix", " ")
|
||||
if f := workspaceUpdateCmd.Flags().Lookup("issue-prefix"); f != nil {
|
||||
f.Changed = true
|
||||
}
|
||||
if _, err := buildWorkspaceUpdateBody(workspaceUpdateCmd); err == nil {
|
||||
t.Fatalf("expected error when --issue-prefix is whitespace-only")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no flags set produces empty body", func(t *testing.T) {
|
||||
resetWorkspaceUpdateFlags(t)
|
||||
body, err := buildWorkspaceUpdateBody(workspaceUpdateCmd)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(body) != 0 {
|
||||
t.Errorf("body = %v, want empty", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user