From 7f12380f05c7f32c4e7f76554478b0d95e4c8b2a Mon Sep 17 00:00:00 2001 From: CAVIN <35207870+zzz163519@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:49:56 +0800 Subject: [PATCH] feat(cli): add workspace create command (#5062) Adds `multica workspace create` (--name/--slug/--description/--context/--issue-prefix, JSON/table output). Creation does not switch the current workspace. Both --name and --slug are required to match the server contract, and the slug is immutable after creation. Docs (en/zh/ja/ko) and the CLI reference now show the executable command. Closes #5055 --- apps/docs/content/docs/cli.ja.mdx | 1 + apps/docs/content/docs/cli.ko.mdx | 1 + apps/docs/content/docs/cli.mdx | 1 + apps/docs/content/docs/cli.zh.mdx | 1 + apps/docs/content/docs/workspaces.ja.mdx | 4 +- apps/docs/content/docs/workspaces.ko.mdx | 4 +- apps/docs/content/docs/workspaces.mdx | 4 +- apps/docs/content/docs/workspaces.zh.mdx | 4 +- server/cmd/multica/cmd_workspace.go | 123 +++++++++--- server/cmd/multica/cmd_workspace_test.go | 241 +++++++++++++++++++++++ 10 files changed, 351 insertions(+), 33 deletions(-) diff --git a/apps/docs/content/docs/cli.ja.mdx b/apps/docs/content/docs/cli.ja.mdx index ed67266cf4..efbb571fd5 100644 --- a/apps/docs/content/docs/cli.ja.mdx +++ b/apps/docs/content/docs/cli.ja.mdx @@ -38,6 +38,7 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき | コマンド | 用途 | |---|---| | `multica workspace list` | アクセスできるすべてのワークスペースを一覧 | +| `multica workspace create --name "..." --slug "..." [--issue-prefix "..."] [--description "..."] [--context "..."]` | ワークスペースを作成(作成者が owner になります)。`--name` と `--slug` は必須で、slug は作成後に変更できません。現在のワークスペースは切り替わりません。 | | `multica workspace get ` | 1 つのワークスペースの詳細を表示 | | `multica workspace member list` | 現在のワークスペースのメンバーを一覧 | | `multica workspace update --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | ワークスペースのメタデータを更新(admin/owner)。長いフィールドは `--description-stdin` / `--context-stdin` を使用できます。 | diff --git a/apps/docs/content/docs/cli.ko.mdx b/apps/docs/content/docs/cli.ko.mdx index 5b35e119f5..7990279da9 100644 --- a/apps/docs/content/docs/cli.ko.mdx +++ b/apps/docs/content/docs/cli.ko.mdx @@ -38,6 +38,7 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹 | 명령어 | 용도 | |---|---| | `multica workspace list` | 접근할 수 있는 모든 워크스페이스 나열 | +| `multica workspace create --name "..." --slug "..." [--issue-prefix "..."] [--description "..."] [--context "..."]` | 워크스페이스 생성(생성자가 owner가 됩니다). `--name`과 `--slug`는 필수이며 slug는 생성 후 변경할 수 없습니다. 현재 워크스페이스는 전환되지 않습니다. | | `multica workspace get ` | 워크스페이스 하나의 상세 정보 표시 | | `multica workspace member list` | 현재 워크스페이스의 멤버 나열 | | `multica workspace update --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 워크스페이스 메타데이터 업데이트(admin/owner). 긴 필드는 `--description-stdin` / `--context-stdin`을 사용할 수 있습니다. | diff --git a/apps/docs/content/docs/cli.mdx b/apps/docs/content/docs/cli.mdx index 683d565910..f6b059386a 100644 --- a/apps/docs/content/docs/cli.mdx +++ b/apps/docs/content/docs/cli.mdx @@ -38,6 +38,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to | Command | Purpose | |---|---| | `multica workspace list` | List every workspace you can access | +| `multica workspace create --name "..." --slug "..." [--issue-prefix "..."] [--description "..."] [--context "..."]` | Create a workspace (you become its owner). `--name` and `--slug` are required; the slug is permanent. Does not switch your current workspace. | | `multica workspace get ` | Show details for one workspace | | `multica workspace member list` | List members of the current workspace | | `multica workspace update --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. | diff --git a/apps/docs/content/docs/cli.zh.mdx b/apps/docs/content/docs/cli.zh.mdx index bc516dee7b..b66686fc9c 100644 --- a/apps/docs/content/docs/cli.zh.mdx +++ b/apps/docs/content/docs/cli.zh.mdx @@ -38,6 +38,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。 | 命令 | 用途 | |---|---| | `multica workspace list` | 列出你有权访问的所有工作区 | +| `multica workspace create --name "..." --slug "..." [--issue-prefix "..."] [--description "..."] [--context "..."]` | 创建工作区(你会成为 owner)。`--name` 和 `--slug` 必填,slug 创建后不可修改;不会切换你当前的工作区。 | | `multica workspace get ` | 查看一个工作区的详情 | | `multica workspace member list` | 列出当前工作区的成员 | | `multica workspace update --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据(admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 | diff --git a/apps/docs/content/docs/workspaces.ja.mdx b/apps/docs/content/docs/workspaces.ja.mdx index d9acf50468..b7ea12db59 100644 --- a/apps/docs/content/docs/workspaces.ja.mdx +++ b/apps/docs/content/docs/workspaces.ja.mdx @@ -19,10 +19,10 @@ import { Callout } from "fumadocs-ui/components/callout"; **イシュー接頭辞は変更しないでください。** イシュー番号は現在の接頭辞でレンダリングされるため、接頭辞を変更すると `MUL-5` がただちに `NEW-5` になります。すべての外部リンク、Slack のメンション、コメント内の過去の参照が古い番号と合わなくなります。イシュー接頭辞は「作成時に決め、決して触らない」値として扱ってください。 -ワークスペースは Web UI から作成することも、コマンドラインから作成することもできます。 +ワークスペースは Web UI から作成することも、コマンドラインから作成することもできます。`--name` と `--slug` はどちらも必須で、作成しても現在のワークスペースは**切り替わりません**。作成したワークスペースで作業したい場合は、続けて `multica workspace switch ` を実行してください。 ```bash -multica workspace create +multica workspace create --name "Support Team" --slug support-team --issue-prefix SUP ``` ## イシュー番号 diff --git a/apps/docs/content/docs/workspaces.ko.mdx b/apps/docs/content/docs/workspaces.ko.mdx index e74474e9e9..f844efb1dc 100644 --- a/apps/docs/content/docs/workspaces.ko.mdx +++ b/apps/docs/content/docs/workspaces.ko.mdx @@ -19,10 +19,10 @@ import { Callout } from "fumadocs-ui/components/callout"; **이슈 접두사는 변경하지 마세요.** 이슈 번호는 현재 접두사로 렌더링되므로, 접두사를 변경하면 `MUL-5`가 즉시 `NEW-5`가 됩니다. 모든 외부 링크, Slack 멘션, 댓글 속 과거 참조가 기존 번호와 맞지 않게 됩니다. 이슈 접두사는 "생성 시 정하고 절대 건드리지 않는" 값으로 다루세요. -워크스페이스는 웹 UI에서 생성할 수도 있고, 커맨드 라인에서 생성할 수도 있습니다. +워크스페이스는 웹 UI에서 생성할 수도 있고, 커맨드 라인에서 생성할 수도 있습니다. `--name`과 `--slug`는 모두 필수이며, 생성해도 현재 워크스페이스는 **전환되지 않습니다**. 생성한 워크스페이스에서 작업하려면 이어서 `multica workspace switch `를 실행하세요. ```bash -multica workspace create +multica workspace create --name "Support Team" --slug support-team --issue-prefix SUP ``` ## 이슈 번호 diff --git a/apps/docs/content/docs/workspaces.mdx b/apps/docs/content/docs/workspaces.mdx index eac41c8952..ab53e21e62 100644 --- a/apps/docs/content/docs/workspaces.mdx +++ b/apps/docs/content/docs/workspaces.mdx @@ -19,10 +19,10 @@ Three things get decided when you create a workspace: **Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched." -You can create a workspace from the web UI or from the command line: +You can create a workspace from the web UI, or from the command line. Both `--name` and `--slug` are required, and creating a workspace does **not** switch your current one — run `multica workspace switch ` afterward if you want to work in it: ```bash -multica workspace create +multica workspace create --name "Support Team" --slug support-team --issue-prefix SUP ``` ## Issue numbers diff --git a/apps/docs/content/docs/workspaces.zh.mdx b/apps/docs/content/docs/workspaces.zh.mdx index 18f4d65300..8c73fa0d83 100644 --- a/apps/docs/content/docs/workspaces.zh.mdx +++ b/apps/docs/content/docs/workspaces.zh.mdx @@ -19,10 +19,10 @@ import { Callout } from "fumadocs-ui/components/callout"; **尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。 -你可以通过 Web 界面创建工作区,也可以用命令行: +你可以通过 Web 界面创建工作区,也可以用命令行。`--name` 和 `--slug` 都是必填项,创建后**不会**切换你当前的工作区——如果想进去操作,创建后再执行 `multica workspace switch `: ```bash -multica workspace create +multica workspace create --name "Support Team" --slug support-team --issue-prefix SUP ``` ## Issue 编号 diff --git a/server/cmd/multica/cmd_workspace.go b/server/cmd/multica/cmd_workspace.go index df72e4440d..17b9cacbf3 100644 --- a/server/cmd/multica/cmd_workspace.go +++ b/server/cmd/multica/cmd_workspace.go @@ -24,6 +24,20 @@ var workspaceListCmd = &cobra.Command{ RunE: runWorkspaceList, } +var workspaceCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a workspace", + Long: "Creates a new workspace and adds you as its owner. Both --name and " + + "--slug are required; the slug is permanent (lowercase letters, digits, " + + "and hyphens) and cannot be changed after creation.\n\n" + + "Creating a workspace does NOT change the current default workspace for " + + "this profile — run 'multica workspace switch ' afterward if you " + + "want subsequent commands to target the new workspace.", + Example: " multica workspace create --name \"Support Team\" --slug support-team --issue-prefix SUP", + Args: cobra.NoArgs, + RunE: runWorkspaceCreate, +} + var workspaceGetCmd = &cobra.Command{ Use: "get [workspace-id|slug|prefix]", Short: "Get workspace details", @@ -86,6 +100,7 @@ var workspaceSwitchCmd = &cobra.Command{ func init() { workspaceCmd.AddCommand(workspaceListCmd) + workspaceCmd.AddCommand(workspaceCreateCmd) workspaceCmd.AddCommand(workspaceGetCmd) workspaceCmd.AddCommand(workspaceMemberCmd) workspaceMemberCmd.AddCommand(workspaceMemberListCmd) @@ -95,6 +110,14 @@ func init() { workspaceListCmd.Flags().String("output", "table", "Output format: table or json") workspaceListCmd.Flags().Bool("full-id", false, "Show full UUIDs in table output") + workspaceCreateCmd.Flags().String("name", "", "Workspace name") + workspaceCreateCmd.Flags().String("slug", "", "Workspace slug") + workspaceCreateCmd.Flags().String("description", "", "Workspace description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)") + workspaceCreateCmd.Flags().Bool("description-stdin", false, "Read description from stdin (preserves multi-line content verbatim)") + workspaceCreateCmd.Flags().String("context", "", "Workspace context (decodes \\n, \\r, \\t, \\\\; pipe via --context-stdin to preserve literal backslashes)") + workspaceCreateCmd.Flags().Bool("context-stdin", false, "Read context from stdin (preserves multi-line content verbatim)") + workspaceCreateCmd.Flags().String("issue-prefix", "", "Issue prefix (uppercased server-side)") + workspaceCreateCmd.Flags().String("output", "json", "Output format: table or json") workspaceGetCmd.Flags().String("output", "json", "Output format: table or json") workspaceMemberListCmd.Flags().String("output", "table", "Output format: table or json") workspaceMemberInviteCmd.Flags().String("role", "member", "Member role to grant: member or admin (owner is not allowed)") @@ -178,6 +201,76 @@ func runWorkspaceList(cmd *cobra.Command, _ []string) error { return nil } +func buildWorkspaceCreateBody(cmd *cobra.Command) (map[string]any, error) { + name, _ := cmd.Flags().GetString("name") + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("--name is required") + } + + // The server requires both name and slug (POST /api/workspaces returns 400 + // without them) and the slug is immutable after creation, so it must be + // chosen explicitly here rather than silently omitted. + slug, _ := cmd.Flags().GetString("slug") + if strings.TrimSpace(slug) == "" { + return nil, fmt.Errorf("--slug is required") + } + + // A single stdin stream cannot feed two fields: whichever field reads first + // drains it and the other gets EOF. Reject the ambiguous combination up + // front instead of surfacing a misleading "content is empty" error. + descStdin, _ := cmd.Flags().GetBool("description-stdin") + ctxStdin, _ := cmd.Flags().GetBool("context-stdin") + if descStdin && ctxStdin { + return nil, fmt.Errorf("--description-stdin and --context-stdin cannot be combined; a single stdin cannot feed both fields — pass one of them inline") + } + + body := map[string]any{"name": name, "slug": slug} + 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") + if strings.TrimSpace(v) == "" { + return nil, fmt.Errorf("--issue-prefix cannot be empty; omit it to use the server-generated prefix") + } + body["issue_prefix"] = v + } + return body, nil +} + +func runWorkspaceCreate(cmd *cobra.Command, _ []string) error { + body, err := buildWorkspaceCreateBody(cmd) + if err != nil { + return err + } + + client, err := newAPIClient(cmd) + if err != nil { + return err + } + client.WorkspaceID = "" + + ctx, cancel := cli.APIContext(context.Background()) + defer cancel() + + var ws map[string]any + if err := client.PostJSON(ctx, "/api/workspaces", body, &ws); err != nil { + return fmt.Errorf("create workspace: %w", err) + } + return printWorkspace(cmd, ws) +} + // resolveWorkspaceByIDOrSlug looks up a workspace in the caller's accessible // list by full UUID, slug (case-insensitive), or short UUID prefix (≥4 hex // chars). The matching order is exact UUID → exact slug → prefix, so a slug @@ -327,6 +420,10 @@ func runWorkspaceGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("get workspace: %w", err) } + return printWorkspace(cmd, ws) +} + +func printWorkspace(cmd *cobra.Command, ws map[string]any) error { output, _ := cmd.Flags().GetString("output") if output == "table" { desc := strVal(ws, "description") @@ -421,31 +518,7 @@ func runWorkspaceUpdate(cmd *cobra.Command, args []string) error { 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) + return printWorkspace(cmd, ws) } func runWorkspaceMembers(cmd *cobra.Command, args []string) error { diff --git a/server/cmd/multica/cmd_workspace_test.go b/server/cmd/multica/cmd_workspace_test.go index 1a511e638c..a212bff855 100644 --- a/server/cmd/multica/cmd_workspace_test.go +++ b/server/cmd/multica/cmd_workspace_test.go @@ -25,6 +25,247 @@ func newWorkspaceSwitchTestCmd() *cobra.Command { return cmd } +func newWorkspaceCreateTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "create"} + cmd.Flags().String("workspace-id", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flags().String("server-url", "", "") + cmd.Flags().String("name", "", "") + cmd.Flags().String("slug", "", "") + cmd.Flags().String("description", "", "") + cmd.Flags().Bool("description-stdin", false, "") + cmd.Flags().String("context", "", "") + cmd.Flags().Bool("context-stdin", false, "") + cmd.Flags().String("issue-prefix", "", "") + cmd.Flags().String("output", "json", "") + return cmd +} + +// decodeWorkspaceCreateBody mirrors the real POST /api/workspaces contract: +// it rejects a body missing name or slug with 400 (as CreateWorkspace does) so +// a CLI regression that drops slug surfaces as a failing request instead of +// being masked by a mock that fabricates the field. +func decodeWorkspaceCreateBody(t *testing.T, w http.ResponseWriter, r *http.Request) (map[string]any, bool) { + t.Helper() + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + name, _ := body["name"].(string) + slug, _ := body["slug"].(string) + if strings.TrimSpace(name) == "" || strings.TrimSpace(slug) == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "name and slug are required"}) + return nil, false + } + return body, true +} + +func TestRunWorkspaceCreatePostsWorkspaceAndDoesNotSwitchDefault(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/workspaces" { + http.NotFound(w, r) + return + } + if r.Header.Get("X-Workspace-ID") != "" { + t.Fatalf("X-Workspace-ID = %q, want empty for workspace creation", r.Header.Get("X-Workspace-ID")) + } + body, ok := decodeWorkspaceCreateBody(t, w, r) + if !ok { + return + } + gotBody = body + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": "33333333-3333-3333-3333-333333333333", + "name": "Growth Team", + "slug": "growth-team", + "description": "Handles GTM work", + "context": "Launch notes", + "issue_prefix": "GRO", + }) + })) + defer srv.Close() + + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_SERVER_URL", srv.URL) + t.Setenv("MULTICA_TOKEN", "test-token") + t.Setenv("MULTICA_WORKSPACE_ID", "existing-workspace") + if err := cli.SaveCLIConfig(cli.CLIConfig{WorkspaceID: "existing-workspace"}); err != nil { + t.Fatalf("seed config: %v", err) + } + + cmd := newWorkspaceCreateTestCmd() + for name, value := range map[string]string{ + "name": "Growth Team", + "slug": "growth-team", + "description": `Handles\nGTM work`, + "context": "Launch notes", + "issue-prefix": "GRO", + } { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + + out, err := captureStdout(t, func() error { + return runWorkspaceCreate(cmd, nil) + }) + if err != nil { + t.Fatalf("runWorkspaceCreate: %v", err) + } + + if gotBody["name"] != "Growth Team" { + t.Errorf("name = %v, want Growth Team", gotBody["name"]) + } + if gotBody["slug"] != "growth-team" { + t.Errorf("slug = %v, want growth-team", gotBody["slug"]) + } + if gotBody["description"] != "Handles\nGTM work" { + t.Errorf("description = %q, want decoded newline", gotBody["description"]) + } + if gotBody["context"] != "Launch notes" { + t.Errorf("context = %v, want Launch notes", gotBody["context"]) + } + if gotBody["issue_prefix"] != "GRO" { + t.Errorf("issue_prefix = %v, want GRO", gotBody["issue_prefix"]) + } + + var printed map[string]any + if err := json.Unmarshal([]byte(out), &printed); err != nil { + t.Fatalf("decode stdout JSON %q: %v", out, err) + } + if printed["id"] != "33333333-3333-3333-3333-333333333333" { + t.Errorf("printed id = %v, want created workspace id", printed["id"]) + } + + cfg, err := cli.LoadCLIConfig() + if err != nil { + t.Fatalf("LoadCLIConfig: %v", err) + } + if cfg.WorkspaceID != "existing-workspace" { + t.Errorf("workspace_id = %q, want existing-workspace (create must not auto-switch)", cfg.WorkspaceID) + } +} + +func TestRunWorkspaceCreateRequiresName(t *testing.T) { + cmd := newWorkspaceCreateTestCmd() + err := runWorkspaceCreate(cmd, nil) + if err == nil { + t.Fatal("expected missing --name error") + } + if !strings.Contains(err.Error(), "--name is required") { + t.Fatalf("error = %q, want --name is required", err) + } +} + +func TestRunWorkspaceCreateRequiresSlug(t *testing.T) { + cmd := newWorkspaceCreateTestCmd() + if err := cmd.Flags().Set("name", "Growth Team"); err != nil { + t.Fatalf("set --name: %v", err) + } + err := runWorkspaceCreate(cmd, nil) + if err == nil { + t.Fatal("expected missing --slug error") + } + if !strings.Contains(err.Error(), "--slug is required") { + t.Fatalf("error = %q, want --slug is required", err) + } +} + +func TestRunWorkspaceCreateRejectsDualStdin(t *testing.T) { + cmd := newWorkspaceCreateTestCmd() + for name, value := range map[string]string{ + "name": "Growth Team", + "slug": "growth-team", + "description-stdin": "true", + "context-stdin": "true", + } { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + _, err := buildWorkspaceCreateBody(cmd) + if err == nil { + t.Fatal("expected mutually-exclusive stdin error") + } + if !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("error = %q, want stdin combination rejection", err) + } +} + +func TestRunWorkspaceCreateReadsDescriptionFromStdin(t *testing.T) { + cmd := newWorkspaceCreateTestCmd() + for name, value := range map[string]string{ + "name": "Growth Team", + "slug": "growth-team", + "description-stdin": "true", + } { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + + var got map[string]any + pipeStdin(t, "line1\nline2\n", func() { + b, err := buildWorkspaceCreateBody(cmd) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + got = b + }) + if got["description"] != "line1\nline2" { + t.Errorf("description = %q, want stdin content", got["description"]) + } + if got["slug"] != "growth-team" { + t.Errorf("slug = %v, want growth-team (must still be sent)", got["slug"]) + } +} + +func TestRunWorkspaceCreatePrintsTable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/workspaces" { + http.NotFound(w, r) + return + } + if _, ok := decodeWorkspaceCreateBody(t, w, r); !ok { + return + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": "44444444-4444-4444-4444-444444444444", + "name": "Support Team", + "slug": "support-team", + "description": "A workspace for support operations", + "context": "Customer support workflows", + }) + })) + defer srv.Close() + + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_SERVER_URL", srv.URL) + t.Setenv("MULTICA_TOKEN", "test-token") + + cmd := newWorkspaceCreateTestCmd() + _ = cmd.Flags().Set("name", "Support Team") + _ = cmd.Flags().Set("slug", "support-team") + _ = cmd.Flags().Set("output", "table") + + out, err := captureStdout(t, func() error { + return runWorkspaceCreate(cmd, nil) + }) + if err != nil { + t.Fatalf("runWorkspaceCreate: %v", err) + } + + for _, want := range []string{"ID", "NAME", "SLUG", "Support Team", "support-team"} { + if !strings.Contains(out, want) { + t.Errorf("table output %q does not contain %q", out, want) + } + } +} + func TestRunWorkspaceSwitch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/workspaces" {