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
This commit is contained in:
CAVIN
2026-07-15 13:49:56 +08:00
committed by GitHub
parent 51e4afc480
commit 7f12380f05
10 changed files with 351 additions and 33 deletions

View File

@@ -38,6 +38,7 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
| コマンド | 用途 |
|---|---|
| `multica workspace list` | アクセスできるすべてのワークスペースを一覧 |
| `multica workspace create --name "..." --slug "..." [--issue-prefix "..."] [--description "..."] [--context "..."]` | ワークスペースを作成(作成者が owner になります)。`--name` と `--slug` は必須で、slug は作成後に変更できません。現在のワークスペースは切り替わりません。 |
| `multica workspace get <slug>` | 1 つのワークスペースの詳細を表示 |
| `multica workspace member list` | 現在のワークスペースのメンバーを一覧 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | ワークスペースのメタデータを更新admin/owner。長いフィールドは `--description-stdin` / `--context-stdin` を使用できます。 |

View File

@@ -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 <slug>` | 워크스페이스 하나의 상세 정보 표시 |
| `multica workspace member list` | 현재 워크스페이스의 멤버 나열 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 워크스페이스 메타데이터 업데이트(admin/owner). 긴 필드는 `--description-stdin` / `--context-stdin`을 사용할 수 있습니다. |

View File

@@ -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 <slug>` | Show details for one workspace |
| `multica workspace member list` | 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`. |

View File

@@ -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 <slug>` | 查看一个工作区的详情 |
| `multica workspace member list` | 列出当前工作区的成员 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |

View File

@@ -19,10 +19,10 @@ import { Callout } from "fumadocs-ui/components/callout";
**イシュー接頭辞は変更しないでください。** イシュー番号は現在の接頭辞でレンダリングされるため、接頭辞を変更すると `MUL-5` がただちに `NEW-5` になります。すべての外部リンク、Slack のメンション、コメント内の過去の参照が古い番号と合わなくなります。イシュー接頭辞は「作成時に決め、決して触らない」値として扱ってください。
</Callout>
ワークスペースは Web UI から作成することも、コマンドラインから作成することもできます。
ワークスペースは Web UI から作成することも、コマンドラインから作成することもできます。`--name` と `--slug` はどちらも必須で、作成しても現在のワークスペースは**切り替わりません**。作成したワークスペースで作業したい場合は、続けて `multica workspace switch <slug>` を実行してください。
```bash
multica workspace create
multica workspace create --name "Support Team" --slug support-team --issue-prefix SUP
```
## イシュー番号

View File

@@ -19,10 +19,10 @@ import { Callout } from "fumadocs-ui/components/callout";
**이슈 접두사는 변경하지 마세요.** 이슈 번호는 현재 접두사로 렌더링되므로, 접두사를 변경하면 `MUL-5`가 즉시 `NEW-5`가 됩니다. 모든 외부 링크, Slack 멘션, 댓글 속 과거 참조가 기존 번호와 맞지 않게 됩니다. 이슈 접두사는 "생성 시 정하고 절대 건드리지 않는" 값으로 다루세요.
</Callout>
워크스페이스는 웹 UI에서 생성할 수도 있고, 커맨드 라인에서 생성할 수도 있습니다.
워크스페이스는 웹 UI에서 생성할 수도 있고, 커맨드 라인에서 생성할 수도 있습니다. `--name`과 `--slug`는 모두 필수이며, 생성해도 현재 워크스페이스는 **전환되지 않습니다**. 생성한 워크스페이스에서 작업하려면 이어서 `multica workspace switch <slug>`를 실행하세요.
```bash
multica workspace create
multica workspace create --name "Support Team" --slug support-team --issue-prefix SUP
```
## 이슈 번호

View File

@@ -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."
</Callout>
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 <slug>` 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

View File

@@ -19,10 +19,10 @@ import { Callout } from "fumadocs-ui/components/callout";
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。
</Callout>
你可以通过 Web 界面创建工作区,也可以用命令行:
你可以通过 Web 界面创建工作区,也可以用命令行。`--name` 和 `--slug` 都是必填项,创建后**不会**切换你当前的工作区——如果想进去操作,创建后再执行 `multica workspace switch <slug>`
```bash
multica workspace create
multica workspace create --name "Support Team" --slug support-team --issue-prefix SUP
```
## Issue 编号

View File

@@ -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 <slug>' 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 {

View File

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