Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan Zhang
69509d870b docs(cli): list workspace update in the en + zh top-level reference
Mirrors the existing zh-only entry under apps/docs/content/docs/cli/
into the English overview so the new command is discoverable from
both locales.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 06:41:56 +08:00
Jiayuan Zhang
124c137d90 fix(cli): address review on workspace update command
- Reject `--issue-prefix ""` (and whitespace-only) explicitly. The
  server handler silently skips empty prefixes, so the previous
  behavior was a 200 OK with no actual change — exactly the kind of
  invisible no-op Emacs flagged in review.
- Restore the `## Issues` H2 in the zh CLI reference. The earlier
  edit dropped it, leaving issue commands nested under the Workspaces
  section.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 06:22:51 +08:00
Jiayuan Zhang
d9c2c4a696 feat(cli): add multica workspace update to edit workspace metadata
Closes the CLI-side gap for #2178: the `PATCH /api/workspaces/{id}`
endpoint and TS client method already exist, only the CLI subcommand
was missing. Supports partial updates of name, description, context,
and issue_prefix; long fields accept stdin via `--description-stdin` /
`--context-stdin`. `slug` stays immutable, `settings`/`repos` are out
of scope (deferred). Empty PATCH is rejected locally so we don't fire
a no-op `EventWorkspaceUpdated` broadcast. Permission gate is
unchanged (server-side admin/owner middleware).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 06:15:26 +08:00
5 changed files with 286 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View 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)
}
})
}