Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
14771f3fa1 feat(cli): add workspace switch + current commands (MUL-2386)
`multica workspace switch <id|slug>` is the product-semantic entry point for
changing the default workspace on the current profile. It looks the target up
in the user's accessible workspace list (an access check by construction —
the server only returns workspaces the user is a member of), persists the
chosen UUID via the existing CLI config layer, and prints the resolved name.
`config set workspace_id` stays as the low-level escape hatch.

`multica workspace switch` resolves the workspace before saving, so an
unknown id or slug fails fast and leaves the previous default intact.

`multica workspace current` and a `*` marker in `multica workspace list`
expose which workspace commands without --workspace-id/MULTICA_WORKSPACE_ID
will target. `multica login` reuses the same marker when listing discovered
workspaces and points multi-workspace users at switch.

Docs gain a "Working with multiple workspaces" section spelling out the
resolution priority (--workspace-id flag > env > profile default) and
calling out config set workspace_id as low-level.

Addresses GitHub#2750.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:21:17 +08:00
5 changed files with 366 additions and 15 deletions

View File

@@ -269,21 +269,45 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
## Workspaces
### Working with multiple workspaces
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
1. `--workspace-id <id>` flag on the command
2. `MULTICA_WORKSPACE_ID` environment variable
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
### List Workspaces
```bash
multica workspace list
multica workspace list --output json
```
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
The current default workspace is marked with `*`.
### Watch / Unwatch
### Show Current Workspace
```bash
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
multica workspace current
multica workspace current --output json
```
Prints the workspace that commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` would target.
### Switch Default Workspace
```bash
multica workspace switch <workspace-id>
multica workspace switch <slug>
```
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
### Get Details
```bash
@@ -508,6 +532,8 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).

View File

@@ -142,6 +142,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica workspace list` | List your workspaces (current is marked with `*`) |
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |

View File

@@ -117,7 +117,14 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
fmt.Fprintf(os.Stderr, "\nFound %d workspace(s):\n", len(workspaces))
for _, ws := range workspaces {
fmt.Fprintf(os.Stderr, " • %s (%s)\n", ws.Name, ws.ID)
marker := " "
if ws.ID == cfg.WorkspaceID {
marker = "* "
}
fmt.Fprintf(os.Stderr, "%s%s (%s)\n", marker, ws.Name, ws.ID)
}
if len(workspaces) > 1 {
fmt.Fprintln(os.Stderr, "\nUse 'multica workspace switch <id|slug>' to change the default workspace.")
}
return nil

View File

@@ -46,14 +46,38 @@ var workspaceUpdateCmd = &cobra.Command{
RunE: runWorkspaceUpdate,
}
var workspaceSwitchCmd = &cobra.Command{
Use: "switch <workspace-id|slug>",
Short: "Set the default workspace for this profile",
Long: "Sets the default workspace for the current profile after verifying you " +
"have access to it. Subsequent commands without --workspace-id or " +
"MULTICA_WORKSPACE_ID will target this workspace.\n\n" +
"Resolution priority (highest to lowest): --workspace-id flag, " +
"MULTICA_WORKSPACE_ID env, profile default (set by this command).\n\n" +
"For low-level use, 'multica config set workspace_id <id>' writes the " +
"same setting without verification.",
Args: exactArgs(1),
RunE: runWorkspaceSwitch,
}
var workspaceCurrentCmd = &cobra.Command{
Use: "current",
Short: "Show the current default workspace",
RunE: runWorkspaceCurrent,
}
func init() {
workspaceCmd.AddCommand(workspaceListCmd)
workspaceCmd.AddCommand(workspaceGetCmd)
workspaceCmd.AddCommand(workspaceMembersCmd)
workspaceCmd.AddCommand(workspaceUpdateCmd)
workspaceCmd.AddCommand(workspaceSwitchCmd)
workspaceCmd.AddCommand(workspaceCurrentCmd)
workspaceListCmd.Flags().String("output", "table", "Output format: table or json")
workspaceGetCmd.Flags().String("output", "json", "Output format: table or json")
workspaceMembersCmd.Flags().String("output", "table", "Output format: table or json")
workspaceCurrentCmd.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)")
@@ -64,23 +88,45 @@ func init() {
workspaceUpdateCmd.Flags().String("output", "json", "Output format: table or json")
}
func runWorkspaceList(cmd *cobra.Command, _ []string) error {
// workspaceSummary is the subset of fields the CLI needs from /api/workspaces
// to drive list/switch/current. Keeping it here (instead of using the full
// WorkspaceResponse) avoids a dependency on the handler package.
type workspaceSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
// fetchWorkspaces lists all workspaces the authenticated user belongs to. It
// is shared by `list`, `switch`, and `current` so all three see the same
// access-controlled view of workspaces.
func fetchWorkspaces(ctx context.Context, cmd *cobra.Command) ([]workspaceSummary, error) {
serverURL := resolveServerURL(cmd)
token := resolveToken(cmd)
if token == "" {
return fmt.Errorf("not authenticated: run 'multica login' first")
return nil, fmt.Errorf("not authenticated: run 'multica login' first")
}
client := cli.NewAPIClient(serverURL, "", token)
var workspaces []workspaceSummary
if err := client.GetJSON(ctx, "/api/workspaces", &workspaces); err != nil {
return nil, fmt.Errorf("list workspaces: %w", err)
}
return workspaces, nil
}
func runWorkspaceList(cmd *cobra.Command, _ []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var workspaces []struct {
ID string `json:"id"`
Name string `json:"name"`
workspaces, err := fetchWorkspaces(ctx, cmd)
if err != nil {
return err
}
if err := client.GetJSON(ctx, "/api/workspaces", &workspaces); err != nil {
return fmt.Errorf("list workspaces: %w", err)
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, workspaces)
}
if len(workspaces) == 0 {
@@ -88,12 +134,114 @@ func runWorkspaceList(cmd *cobra.Command, _ []string) error {
return nil
}
currentID := resolveWorkspaceID(cmd)
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME")
fmt.Fprintln(w, "\tID\tNAME\tSLUG")
for _, ws := range workspaces {
fmt.Fprintf(w, "%s\t%s\n", ws.ID, ws.Name)
marker := " "
if ws.ID == currentID {
marker = "*"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", marker, ws.ID, ws.Name, ws.Slug)
}
return w.Flush()
if err := w.Flush(); err != nil {
return err
}
if currentID != "" {
fmt.Fprintln(os.Stderr, "\n* = current default workspace (use 'multica workspace switch <id|slug>' to change)")
} else {
fmt.Fprintln(os.Stderr, "\nNo default workspace set. Use 'multica workspace switch <id|slug>' to pick one.")
}
return nil
}
// resolveWorkspaceByIDOrSlug looks up a workspace in the caller's accessible
// list by either UUID or slug. It returns an error if no workspace matches,
// which doubles as the "access denied / does not exist" check — the server
// only returns workspaces the user is a member of, so a match implies access.
func resolveWorkspaceByIDOrSlug(workspaces []workspaceSummary, target string) (workspaceSummary, error) {
target = strings.TrimSpace(target)
if target == "" {
return workspaceSummary{}, fmt.Errorf("workspace id or slug is required")
}
// Slug comparison is case-insensitive (slugs are stored lowercase on the
// server, but tolerate user-typed uppercase). UUIDs are also case-
// insensitive in canonical form, so the lowering is safe for both.
lowered := strings.ToLower(target)
for _, ws := range workspaces {
if ws.ID == target || strings.ToLower(ws.ID) == lowered {
return ws, nil
}
if ws.Slug != "" && strings.ToLower(ws.Slug) == lowered {
return ws, nil
}
}
return workspaceSummary{}, fmt.Errorf("workspace %q not found or you do not have access; run 'multica workspace list' to see options", target)
}
func runWorkspaceSwitch(cmd *cobra.Command, args []string) error {
target := args[0]
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
workspaces, err := fetchWorkspaces(ctx, cmd)
if err != nil {
return err
}
ws, err := resolveWorkspaceByIDOrSlug(workspaces, target)
if err != nil {
return err
}
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return err
}
cfg.WorkspaceID = ws.ID
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "Switched to workspace: %s (%s)\n", ws.Name, ws.ID)
return nil
}
func runWorkspaceCurrent(cmd *cobra.Command, _ []string) error {
currentID := resolveWorkspaceID(cmd)
if currentID == "" {
return fmt.Errorf("no default workspace set: use 'multica workspace switch <id|slug>' to pick one")
}
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.GetJSON(ctx, "/api/workspaces/"+currentID, &ws); err != nil {
return fmt.Errorf("get workspace: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ws)
}
headers := []string{"ID", "NAME", "SLUG", "ISSUE PREFIX"}
rows := [][]string{{
strVal(ws, "id"),
strVal(ws, "name"),
strVal(ws, "slug"),
strVal(ws, "issue_prefix"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func workspaceIDFromArgs(cmd *cobra.Command, args []string) string {

View File

@@ -1,10 +1,178 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// newWorkspaceSwitchTestCmd builds a standalone cobra command with the flags
// runWorkspaceSwitch reads. We can't reuse the real workspaceSwitchCmd because
// it has no parent root carrying --workspace-id / --profile / --server-url.
func newWorkspaceSwitchTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "switch"}
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("server-url", "", "")
return cmd
}
func TestRunWorkspaceSwitch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/workspaces" {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode([]map[string]any{
{"id": "11111111-1111-1111-1111-111111111111", "name": "Alpha", "slug": "alpha"},
{"id": "22222222-2222-2222-2222-222222222222", "name": "Beta", "slug": "beta"},
})
}))
defer srv.Close()
// Isolate HOME so the test never touches the developer's ~/.multica.
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "")
t.Run("switches by slug and persists workspace_id", func(t *testing.T) {
cmd := newWorkspaceSwitchTestCmd()
if err := runWorkspaceSwitch(cmd, []string{"beta"}); err != nil {
t.Fatalf("runWorkspaceSwitch: %v", err)
}
cfg, err := cli.LoadCLIConfig()
if err != nil {
t.Fatalf("LoadCLIConfig: %v", err)
}
if cfg.WorkspaceID != "22222222-2222-2222-2222-222222222222" {
t.Errorf("workspace_id = %q, want Beta's id", cfg.WorkspaceID)
}
})
t.Run("rejects unknown workspace and leaves config untouched", func(t *testing.T) {
// Seed a known workspace_id so we can verify it is NOT clobbered on
// failure — the issue's acceptance criteria explicitly call this out.
if err := cli.SaveCLIConfig(cli.CLIConfig{WorkspaceID: "11111111-1111-1111-1111-111111111111"}); err != nil {
t.Fatalf("seed config: %v", err)
}
cmd := newWorkspaceSwitchTestCmd()
err := runWorkspaceSwitch(cmd, []string{"does-not-exist"})
if err == nil {
t.Fatal("expected error for unknown workspace")
}
cfg, _ := cli.LoadCLIConfig()
if cfg.WorkspaceID != "11111111-1111-1111-1111-111111111111" {
t.Errorf("workspace_id = %q, expected it to stay on Alpha's id when switch fails", cfg.WorkspaceID)
}
})
t.Run("isolates by profile", func(t *testing.T) {
cmd := newWorkspaceSwitchTestCmd()
_ = cmd.Flags().Set("profile", "staging")
if err := runWorkspaceSwitch(cmd, []string{"alpha"}); err != nil {
t.Fatalf("runWorkspaceSwitch: %v", err)
}
// The staging profile picked up Alpha; the default profile (touched
// earlier in this test) must remain unaffected.
stagingCfg, err := cli.LoadCLIConfigForProfile("staging")
if err != nil {
t.Fatalf("load staging config: %v", err)
}
if stagingCfg.WorkspaceID != "11111111-1111-1111-1111-111111111111" {
t.Errorf("staging workspace_id = %q, want Alpha's id", stagingCfg.WorkspaceID)
}
// Verify the staging profile config landed in the expected path.
path, _ := cli.CLIConfigPathForProfile("staging")
wantSuffix := filepath.Join(".multica", "profiles", "staging", "config.json")
if !strings.HasSuffix(path, wantSuffix) {
t.Errorf("staging config path = %q, want suffix %q", path, wantSuffix)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("expected staging config file at %s, got %v", path, err)
}
})
}
func TestResolveWorkspaceByIDOrSlug(t *testing.T) {
workspaces := []workspaceSummary{
{ID: "11111111-1111-1111-1111-111111111111", Name: "Alpha", Slug: "alpha"},
{ID: "22222222-2222-2222-2222-222222222222", Name: "Beta", Slug: "beta"},
}
t.Run("matches by exact UUID", func(t *testing.T) {
ws, err := resolveWorkspaceByIDOrSlug(workspaces, "22222222-2222-2222-2222-222222222222")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws.Name != "Beta" {
t.Errorf("got %q, want Beta", ws.Name)
}
})
t.Run("matches by slug", func(t *testing.T) {
ws, err := resolveWorkspaceByIDOrSlug(workspaces, "alpha")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws.ID != "11111111-1111-1111-1111-111111111111" {
t.Errorf("got id %q, want alpha's id", ws.ID)
}
})
t.Run("slug match is case-insensitive", func(t *testing.T) {
ws, err := resolveWorkspaceByIDOrSlug(workspaces, "ALPHA")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws.Slug != "alpha" {
t.Errorf("got %q, want alpha", ws.Slug)
}
})
t.Run("unknown target returns access-style error", func(t *testing.T) {
_, err := resolveWorkspaceByIDOrSlug(workspaces, "gamma")
if err == nil {
t.Fatal("expected error for unknown workspace")
}
// The error should hint at running 'workspace list' so the user has an
// actionable next step. We treat it as a soft contract because it is
// the message users see when they typo a slug.
if !strings.Contains(err.Error(), "workspace list") {
t.Errorf("error %q should reference 'workspace list'", err)
}
})
t.Run("empty target is rejected", func(t *testing.T) {
_, err := resolveWorkspaceByIDOrSlug(workspaces, " ")
if err == nil {
t.Fatal("expected error for empty target")
}
})
t.Run("whitespace-padded target is trimmed", func(t *testing.T) {
ws, err := resolveWorkspaceByIDOrSlug(workspaces, " beta ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws.Name != "Beta" {
t.Errorf("got %q, want Beta", ws.Name)
}
})
}
// 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.