mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 07:59:30 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14771f3fa1 |
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user