From e2c861d50570e3121f601db4dae24e11c73f00e3 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 13 Jul 2026 17:45:46 +0100 Subject: [PATCH 1/4] feat(cli): workspace management verbs (create/add/remove/rename/update/delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mutation verbs to `cix ws`, calling the server's existing endpoints (POST/PATCH/DELETE /workspaces and POST/DELETE /workspaces/{id}/projects). The CLI was previously read-only (list/describe/repos/search); workspace management was dashboard-only. client: CreateWorkspace, UpdateWorkspace, DeleteWorkspace, LinkProjectToWorkspace, UnlinkProjectFromWorkspace. cmd: name-first grammar gains create / add(link) / remove(unlink) / rename / update / delete. `create` is a reserved leading keyword (like `list`). - add/remove resolve a project identifier (absolute path / host_path / 16-hex path_hash) to its indexed path_hash; a local project resolves via the re-derived hash because the server stores its host_path as the namespaced identity key `local:{machine_id}:{path}`, not the bare path. - add/remove are best-effort over several projects, defaulting to the cwd, and return a non-zero exit on any failure. - delete prompts on a TTY and refuses on a pipe without --yes. - SilenceUsage/SilenceErrors on the ws command so errors read as one line. 12 new tests; verified end-to-end against a live server (create → add → dup(409) → unknown → list → describe → rename → update → remove → delete). Co-Authored-By: Claude Opus 4.8 --- cli/README.md | 2 +- cli/cmd/workspace.go | 337 ++++++++++++++++++++++- cli/cmd/workspace_test.go | 443 +++++++++++++++++++++++++++++++ cli/internal/client/workspace.go | 80 ++++++ 4 files changed, 853 insertions(+), 9 deletions(-) diff --git a/cli/README.md b/cli/README.md index a9afd5a..cb60fb7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -30,7 +30,7 @@ cli/ │ ├── config.go — `cix config show/set/unset/path` (+ multi-server keys) │ ├── config_keys.go — `cix config keys` (schema-driven key listing) │ ├── config_edit.go — `cix config edit` / `cix config init` (huh-driven TUI) -│ ├── workspace.go — `cix workspace …` (cross-repo, name-first) +│ ├── workspace.go — `cix workspace …` (cross-repo search + create/add/remove/rename/delete, name-first) │ └── version.go — `cix version` ├── internal/ │ ├── client/ — HTTP client to cix-server diff --git a/cli/cmd/workspace.go b/cli/cmd/workspace.go index 952c920..b615261 100644 --- a/cli/cmd/workspace.go +++ b/cli/cmd/workspace.go @@ -1,10 +1,12 @@ package cmd import ( + "bufio" "encoding/json" "errors" "fmt" "os" + "path/filepath" "strings" "time" @@ -17,44 +19,76 @@ import ( // // cix ws → list workspaces (default) // cix ws list → list workspaces (alternate) +// cix ws create → create a workspace // cix ws → describe workspace (list repos + status) // cix ws list → list repos in the workspace // cix ws repos → list repos (alias) // cix ws describe → describe (same as `cix ws `) // cix ws search → two-stage workspace search +// cix ws add → link indexed project(s) +// cix ws remove → unlink project(s) +// cix ws rename → rename the workspace +// cix ws update [flags] → patch name / description +// cix ws delete → delete the workspace // // We deliberately roll the dispatch by hand instead of using cobra // subcommands so the workspace NAME can sit in the first positional // slot — cobra can't recognise a dynamic value (workspace name) as a // command name. The trade-off is no auto-completion on ``; in // exchange the surface reads the way operators think about workspaces. +// +// `create` is a reserved leading keyword (like `list`): the workspace it +// makes does not exist yet, so it cannot slot into the name-first grammar. +// This shadows describing a workspace literally named "create". var workspaceCmd = &cobra.Command{ Use: "workspace [name] [verb] [args...]", Aliases: []string{"ws"}, - Short: "Cross-project semantic search via workspaces", - Long: `Workspaces group GitHub repositories for cross-project semantic search. + Short: "Cross-project semantic search + workspace management", + Long: `Workspaces group repositories for cross-project semantic search. Argument grammar — name-first: cix ws list workspaces visible to me cix ws list list workspaces (alternate form) + cix ws create create a workspace (--description optional) cix ws describe a workspace (repos + status) cix ws list list repos in cix ws repos same as list cix ws search two-stage semantic search in + cix ws add link one or more indexed projects + cix ws remove unlink one or more projects + cix ws rename rename the workspace + cix ws update [flags] patch --name / --description + cix ws delete delete the workspace (prompts; -y to skip) + +A is any indexed project, addressed by its absolute path, its +host_path (e.g. github.com/owner/repo@main), or its 16-hex path_hash — run +'cix list' to see them. 'add'/'remove' with no project default to the +current directory. Linking requires the project to be fully indexed. Examples: cix ws - cix ws platform - cix ws platform list + cix ws create platform --description "core platform repos" + cix ws platform add . + cix ws platform add github.com/owner/repo@main /Users/me/svc + cix ws platform remove a1b2c3d4e5f60718 cix ws platform search "JWT validation" - cix ws platform search "rate limiting" --top-projects 8 --top-chunks 30 --json + cix ws platform delete -y Workspace identifiers accept the opaque id OR the (case-insensitive) -name. Repository attachment, GitHub token management, and the -detailed dashboard view all live at /dashboard on the cix-server.`, +name. Registering a brand-new GitHub repo (clone + first index) and +GitHub token management still live at /dashboard on the cix-server; +'add' here links a project that is already indexed.`, Args: cobra.ArbitraryArgs, RunE: runWorkspace, + // The verb errors returned by runWorkspace already carry an actionable + // message (e.g. `unknown verb "x" — use one of: …`), and the best-effort + // add/remove paths print per-project `✗` lines of their own. Dumping the + // full usage block on top of that is pure noise, so suppress it. We also + // silence cobra's own error print because root.go's Execute already prints + // `Error: %v` — without this the message shows up twice. + SilenceUsage: true, + SilenceErrors: true, } var ( @@ -65,6 +99,13 @@ var ( // wsSearchMinScore < 0 means "unset" — omit the param so the server // applies its default (0.4). 0 is a valid explicit value (broad sweep). wsSearchMinScore float64 + // wsDescription / wsNewName back the create + update verbs. `update` + // distinguishes "flag unset" from "flag set to empty" via + // cmd.Flags().Changed, so an empty --description explicitly clears it. + wsDescription string + wsNewName string + // wsYes skips the delete confirmation prompt. + wsYes bool ) func init() { @@ -77,6 +118,9 @@ func init() { workspaceCmd.Flags().IntVar(&wsSearchTopProjects, "top-projects", 10, "Search: top-N projects in the projects panel (1-50)") workspaceCmd.Flags().IntVar(&wsSearchTopChunks, "top-chunks", 20, "Search: top-K chunks returned overall (1-200)") workspaceCmd.Flags().Float64Var(&wsSearchMinScore, "min-score", -1, "Search: minimum relevance 0.0-1.0 (omit for server default 0.4; pass 0 for a broad cross-cutting sweep)") + workspaceCmd.Flags().StringVar(&wsDescription, "description", "", "Description for create / update (empty on update clears it)") + workspaceCmd.Flags().StringVar(&wsNewName, "name", "", "New name for update") + workspaceCmd.Flags().BoolVarP(&wsYes, "yes", "y", false, "Skip the confirmation prompt when deleting") } func runWorkspace(cmd *cobra.Command, args []string) error { @@ -85,6 +129,13 @@ func runWorkspace(cmd *cobra.Command, args []string) error { return err } + // `create` is a reserved leading keyword — see the workspaceCmd doc + // comment. It must be handled before the name-first arms because the + // workspace it names does not exist yet. + if len(args) >= 1 && strings.EqualFold(args[0], "create") { + return cmdCreateWorkspace(cli, args[1:]) + } + switch { case len(args) == 0: return cmdListWorkspaces(cli) @@ -117,8 +168,27 @@ func runWorkspace(cmd *cobra.Command, args []string) error { } query := strings.Join(rest, " ") return cmdWorkspaceSearch(cli, name, query) + case "add", "link": + return cmdAddProjects(cli, name, rest) + case "remove", "unlink": + return cmdRemoveProjects(cli, name, rest) + case "rename": + if len(rest) != 1 { + return errors.New("rename needs exactly one new name (cix ws rename )") + } + return cmdRenameWorkspace(cli, name, rest[0]) + case "update": + if len(rest) > 0 { + return errors.New("update takes no positional arguments — use --name / --description") + } + return cmdUpdateWorkspace(cmd, cli, name) + case "delete": + if len(rest) > 0 { + return errors.New("delete takes no extra arguments") + } + return cmdDeleteWorkspace(cli, name) default: - return fmt.Errorf("unknown verb %q — use one of: list, repos, describe, search", verb) + return fmt.Errorf("unknown verb %q — use one of: list, repos, describe, search, add, remove, rename, update, delete", verb) } } @@ -334,6 +404,257 @@ func resolveWorkspaceID(cli *client.Client, identifier string) (string, error) { return "", fmt.Errorf("workspace %q not found (run `cix ws list`)", identifier) } +// --------------------------------------------------------------------------- +// `cix ws create ` +// --------------------------------------------------------------------------- + +func cmdCreateWorkspace(cli *client.Client, args []string) error { + if len(args) != 1 { + return errors.New("create needs exactly one workspace name (cix ws create [--description \"...\"])") + } + ws, err := cli.CreateWorkspace(args[0], wsDescription) + if err != nil { + return err + } + if wsJSON { + return emitJSON(ws) + } + fmt.Printf("created workspace %s (%s)\n", ws.Name, ws.ID) + fmt.Fprintf(os.Stderr, "add projects with: cix ws %s add \n", ws.Name) + return nil +} + +// --------------------------------------------------------------------------- +// `cix ws rename ` / ` update [flags]` +// --------------------------------------------------------------------------- + +func cmdRenameWorkspace(cli *client.Client, identifier, newName string) error { + id, err := resolveWorkspaceID(cli, identifier) + if err != nil { + return err + } + ws, err := cli.UpdateWorkspace(id, &newName, nil) + if err != nil { + return err + } + if wsJSON { + return emitJSON(ws) + } + fmt.Printf("renamed workspace to %s (%s)\n", ws.Name, ws.ID) + return nil +} + +// cmdUpdateWorkspace patches whichever of name/description was set on the +// command line. We rely on cmd.Flags().Changed rather than the zero value so +// `--description ""` clears the description instead of being treated as unset. +func cmdUpdateWorkspace(cmd *cobra.Command, cli *client.Client, identifier string) error { + var namePtr, descPtr *string + if cmd.Flags().Changed("name") { + namePtr = &wsNewName + } + if cmd.Flags().Changed("description") { + descPtr = &wsDescription + } + if namePtr == nil && descPtr == nil { + return errors.New("update needs at least one of --name or --description") + } + id, err := resolveWorkspaceID(cli, identifier) + if err != nil { + return err + } + ws, err := cli.UpdateWorkspace(id, namePtr, descPtr) + if err != nil { + return err + } + if wsJSON { + return emitJSON(ws) + } + fmt.Printf("updated workspace %s (%s)\n", ws.Name, ws.ID) + return nil +} + +// --------------------------------------------------------------------------- +// `cix ws delete` +// --------------------------------------------------------------------------- + +func cmdDeleteWorkspace(cli *client.Client, identifier string) error { + id, err := resolveWorkspaceID(cli, identifier) + if err != nil { + return err + } + if !wsYes { + if !isInteractive() { + return fmt.Errorf("refusing to delete workspace %q without confirmation — pass --yes to proceed non-interactively", identifier) + } + fmt.Fprintf(os.Stderr, "Delete workspace %q? This removes the workspace and its project links (the projects themselves are kept). [y/N] ", identifier) + if !readAffirmative() { + fmt.Fprintln(os.Stderr, "aborted") + return nil + } + } + if err := cli.DeleteWorkspace(id); err != nil { + return err + } + fmt.Printf("deleted workspace %s\n", identifier) + return nil +} + +// --------------------------------------------------------------------------- +// `cix ws add ` / ` remove ` +// --------------------------------------------------------------------------- + +func cmdAddProjects(cli *client.Client, identifier string, projectArgs []string) error { + return mutateProjects(cli, identifier, projectArgs, "add") +} + +func cmdRemoveProjects(cli *client.Client, identifier string, projectArgs []string) error { + return mutateProjects(cli, identifier, projectArgs, "remove") +} + +// mutateProjects links or unlinks the given projects. It resolves each +// identifier to a known project's path_hash first (so a typo yields an +// actionable local error before any HTTP call), then applies the op. It is +// best-effort: one failing project is reported to stderr and does not abort +// the others. Returns a non-nil error when at least one op failed so the +// process exit code reflects it. +func mutateProjects(cli *client.Client, identifier string, projectArgs []string, op string) error { + id, err := resolveWorkspaceID(cli, identifier) + if err != nil { + return err + } + targets, err := projectTargets(projectArgs) + if err != nil { + return err + } + projList, err := cli.ListProjects() + if err != nil { + return fmt.Errorf("list projects: %w", err) + } + + failures := 0 + for _, t := range targets { + hash, hostPath, rerr := resolveProjectHash(projList, t) + if rerr != nil { + fmt.Fprintf(os.Stderr, "✗ %s: %v\n", t, rerr) + failures++ + continue + } + var opErr error + var verb string + if op == "add" { + opErr = cli.LinkProjectToWorkspace(id, hash) + verb = "added" + } else { + opErr = cli.UnlinkProjectFromWorkspace(id, hash) + verb = "removed" + } + if opErr != nil { + fmt.Fprintf(os.Stderr, "✗ %s: %v\n", hostPath, opErr) + failures++ + continue + } + fmt.Printf("✓ %s %s\n", verb, hostPath) + } + if failures > 0 { + return fmt.Errorf("%d of %d project(s) failed to %s", failures, len(targets), op) + } + return nil +} + +// projectTargets returns the project identifiers to act on. An empty list +// defaults to the current working directory (absolute), mirroring how +// `cix init` operates on the cwd when given no path. +func projectTargets(args []string) ([]string, error) { + if len(args) > 0 { + return args, nil + } + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("resolve current directory: %w", err) + } + return []string{cwd}, nil +} + +// resolveProjectHash maps a user-supplied project identifier to the +// path_hash of a server-registered project, returning the hash and the +// project's host_path for display. Accepted forms, in priority order: +// +// 1. an exact 16-hex path_hash +// 2. an exact host_path (e.g. github.com/owner/repo@main, or an abs path) +// 3. a filesystem path (".", relative, ~-expanded) → abs → host_path match +// +// Resolving against the live project list (rather than blindly hashing the +// input) lets the CLI reject unknown projects locally with an actionable +// hint, and sidesteps the local-vs-external hash-derivation split. +func resolveProjectHash(projects []client.Project, identifier string) (hash, hostPath string, err error) { + // 1. exact path_hash. + if isHex16(identifier) { + for _, p := range projects { + if p.PathHash == identifier { + return p.PathHash, p.HostPath, nil + } + } + } + // 2. exact host_path — external projects (github.com/owner/repo@branch) + // store their identifier verbatim as host_path. + for _, p := range projects { + if p.HostPath == identifier { + return p.PathHash, p.HostPath, nil + } + } + // 3. filesystem path → derive the local project's path_hash the same way + // the server + client do (sha1 of "local:{machine_id}:{abs_path}") and + // match on path_hash. Local projects store host_path as that full + // identity key rather than the bare path, so a host_path string compare + // won't hit — the derived hash is the reliable join. + if abs, aerr := filepath.Abs(identifier); aerr == nil { + derived := client.EncodeProjectPath(abs) + for _, p := range projects { + if p.PathHash == derived { + return p.PathHash, p.HostPath, nil + } + } + } + return "", "", fmt.Errorf("no indexed project matches %q — run `cix list` to see known projects", identifier) +} + +// isHex16 reports whether s is exactly 16 lowercase hex chars (a path_hash). +func isHex16(s string) bool { + if len(s) != 16 { + return false + } + for _, r := range s { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return false + } + } + return true +} + +// isInteractive reports whether stdin is a TTY. Dependency-free char-device +// check (mirrors indexer.isTerminal) so destructive verbs can prompt when a +// human is driving and hard-fail when the input is piped. +func isInteractive() bool { + stat, err := os.Stdin.Stat() + if err != nil { + return false + } + return (stat.Mode() & os.ModeCharDevice) != 0 +} + +// readAffirmative reads one line from stdin and reports whether it is a +// yes/y (case-insensitive). Anything else — including a read error/EOF — is +// treated as a no. +func readAffirmative() bool { + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true + default: + return false + } +} + func renderSearch(resp *client.WorkspaceSearchResponse) error { switch resp.Status { case "empty": diff --git a/cli/cmd/workspace_test.go b/cli/cmd/workspace_test.go index ea0c979..e30de95 100644 --- a/cli/cmd/workspace_test.go +++ b/cli/cmd/workspace_test.go @@ -1,9 +1,14 @@ package cmd import ( + "encoding/json" + "io" "net/http" + "os" "strings" "testing" + + "github.com/anthropics/code-index/cli/internal/client" ) // TestListWorkspaceProjects_DecodesPayload locks the acceptance from @@ -292,6 +297,444 @@ func TestResolveWorkspaceID_ByName(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Mutation verbs: create / delete / add / remove / update. +// --------------------------------------------------------------------------- + +// TestCreateWorkspace verifies `cix ws create --description ...` POSTs +// the right body and renders the created id/name. The description flag is +// threaded through the global the same way cobra sets it in real use. +func TestCreateWorkspace(t *testing.T) { + var gotBody map[string]any + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v1/workspaces" { + json.NewDecoder(r.Body).Decode(&gotBody) + writeJSON(w, http.StatusCreated, map[string]any{ + "id": "ws_new", "name": "platform", "description": "core repos", + }) + return + } + http.NotFound(w, r) + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + prev := wsDescription + wsDescription = "core repos" + t.Cleanup(func() { wsDescription = prev }) + + out, err := captureOutput(func() error { return cmdCreateWorkspace(cli, []string{"platform"}) }) + if err != nil { + t.Fatalf("cmdCreateWorkspace: %v", err) + } + if gotBody["name"] != "platform" { + t.Errorf("expected name=platform in body, got: %v", gotBody) + } + if gotBody["description"] != "core repos" { + t.Errorf("expected description in body, got: %v", gotBody) + } + if !strings.Contains(out, "created workspace platform") || !strings.Contains(out, "ws_new") { + t.Errorf("expected created confirmation with id, got:\n%s", out) + } +} + +// TestCreateWorkspace_ArgCount pins the arity guard — create needs exactly +// one positional name; zero or many is a usage error (never a silent POST). +func TestCreateWorkspace_ArgCount(t *testing.T) { + cli := &client.Client{} + for _, args := range [][]string{{}, {"a", "b"}} { + if err := cmdCreateWorkspace(cli, args); err == nil { + t.Errorf("cmdCreateWorkspace(%v): expected arity error, got nil", args) + } + } +} + +// TestDeleteWorkspace_WithYes confirms `--yes` skips the prompt and the +// resolved id is DELETEd. The 204 must be treated as success. +func TestDeleteWorkspace_WithYes(t *testing.T) { + var deletedPath string + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/workspaces": + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, + "total": 1, + }) + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/workspaces/ws_1": + deletedPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + prev := wsYes + wsYes = true + t.Cleanup(func() { wsYes = prev }) + + out, err := captureOutput(func() error { return cmdDeleteWorkspace(cli, "platform") }) + if err != nil { + t.Fatalf("cmdDeleteWorkspace: %v", err) + } + if deletedPath != "/api/v1/workspaces/ws_1" { + t.Errorf("expected DELETE on resolved id ws_1, got %q", deletedPath) + } + if !strings.Contains(out, "deleted workspace platform") { + t.Errorf("expected deletion confirmation, got:\n%s", out) + } +} + +// TestDeleteWorkspace_NonInteractiveRequiresYes locks the safety gate: with +// no TTY and no --yes, delete must refuse rather than block reading stdin. +// We force a non-interactive stdin (a pipe) so isInteractive() is +// deterministically false regardless of how the test harness is launched. +func TestDeleteWorkspace_NonInteractiveRequiresYes(t *testing.T) { + withPipedStdin(t, "") + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/workspaces" { + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, + "total": 1, + }) + return + } + // A DELETE reaching the server here would be a bug — the gate must + // stop before any HTTP mutation. + t.Errorf("unexpected request %s %s — delete gate should have aborted", r.Method, r.URL.Path) + http.NotFound(w, r) + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + prev := wsYes + wsYes = false + t.Cleanup(func() { wsYes = prev }) + + _, err = captureOutput(func() error { return cmdDeleteWorkspace(cli, "platform") }) + if err == nil { + t.Fatal("expected refusal error without --yes on non-interactive stdin") + } + if !strings.Contains(err.Error(), "--yes") { + t.Errorf("error should point at --yes, got: %v", err) + } +} + +// TestAddProjects_ResolvesAndLinks covers the happy path: an identifier is +// resolved to a path_hash via the live project list, then POSTed as +// {project_hash}. We exercise all three identifier forms in one run. +func TestAddProjects_ResolvesAndLinks(t *testing.T) { + var linkedHashes []string + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, + "total": 1, + }) + case r.URL.Path == "/api/v1/projects" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "projects": []map[string]any{ + {"path_hash": "a1b2c3d4e5f60718", "host_path": "github.com/owner/repo@main", "status": "indexed"}, + }, + "total": 1, + }) + case r.URL.Path == "/api/v1/workspaces/ws_1/projects" && r.Method == http.MethodPost: + var body struct { + ProjectHash string `json:"project_hash"` + } + json.NewDecoder(r.Body).Decode(&body) + linkedHashes = append(linkedHashes, body.ProjectHash) + writeJSON(w, http.StatusCreated, map[string]any{ + "workspace_id": "ws_1", "project_path": "github.com/owner/repo@main", + }) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + // Both the host_path form and the raw hash form must resolve to the + // same path_hash and produce two link calls. + out, err := captureOutput(func() error { + return cmdAddProjects(cli, "platform", []string{"github.com/owner/repo@main", "a1b2c3d4e5f60718"}) + }) + if err != nil { + t.Fatalf("cmdAddProjects: %v", err) + } + if len(linkedHashes) != 2 { + t.Fatalf("expected 2 link calls, got %d (%v)", len(linkedHashes), linkedHashes) + } + for _, h := range linkedHashes { + if h != "a1b2c3d4e5f60718" { + t.Errorf("expected project_hash a1b2c3d4e5f60718, got %q", h) + } + } + if strings.Count(out, "✓ added") != 2 { + t.Errorf("expected two success lines, got:\n%s", out) + } +} + +// TestAddProjects_NotIndexedSurfacesError checks the 422 path: the server +// rejects a not-yet-indexed project and the CLI reports a per-project +// failure plus a non-nil aggregate error (non-zero exit). +func TestAddProjects_NotIndexedSurfacesError(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, + "total": 1, + }) + case r.URL.Path == "/api/v1/projects" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "projects": []map[string]any{ + {"path_hash": "a1b2c3d4e5f60718", "host_path": "github.com/owner/repo@main", "status": "indexing"}, + }, + "total": 1, + }) + case r.URL.Path == "/api/v1/workspaces/ws_1/projects" && r.Method == http.MethodPost: + apiError(w, http.StatusUnprocessableEntity, + "project is not yet indexed — wait for indexing to complete before linking") + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + _, err = captureOutput(func() error { + return cmdAddProjects(cli, "platform", []string{"github.com/owner/repo@main"}) + }) + if err == nil { + t.Fatal("expected aggregate error when a link fails") + } + if !strings.Contains(err.Error(), "failed to add") { + t.Errorf("expected aggregate 'failed to add' error, got: %v", err) + } +} + +// TestAddProjects_UnknownProject asserts the local resolution guard: an +// identifier absent from the project list fails before any POST, with an +// actionable hint. +func TestAddProjects_UnknownProject(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces": + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, + "total": 1, + }) + case r.URL.Path == "/api/v1/projects": + writeJSON(w, 200, map[string]any{"projects": []map[string]any{}, "total": 0}) + case strings.HasPrefix(r.URL.Path, "/api/v1/workspaces/ws_1/projects"): + t.Errorf("unexpected link POST for an unresolved project") + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + _, err = captureOutput(func() error { + return cmdAddProjects(cli, "platform", []string{"github.com/nope/nope@main"}) + }) + if err == nil { + t.Fatal("expected failure for an unknown project") + } +} + +// TestRemoveProjects_Unlinks confirms remove resolves the hash and issues a +// DELETE at the {id}/projects/{hash} path. +func TestRemoveProjects_Unlinks(t *testing.T) { + var deletedPath string + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, + "total": 1, + }) + case r.URL.Path == "/api/v1/projects" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "projects": []map[string]any{ + {"path_hash": "a1b2c3d4e5f60718", "host_path": "github.com/owner/repo@main", "status": "indexed"}, + }, + "total": 1, + }) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/api/v1/workspaces/ws_1/projects/"): + deletedPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + out, err := captureOutput(func() error { + return cmdRemoveProjects(cli, "platform", []string{"a1b2c3d4e5f60718"}) + }) + if err != nil { + t.Fatalf("cmdRemoveProjects: %v", err) + } + if deletedPath != "/api/v1/workspaces/ws_1/projects/a1b2c3d4e5f60718" { + t.Errorf("expected unlink DELETE on the hash path, got %q", deletedPath) + } + if !strings.Contains(out, "✓ removed") { + t.Errorf("expected removal confirmation, got:\n%s", out) + } +} + +// TestResolveProjectHash exercises the three-tier identifier resolver +// directly (pure over a fixture list) so the priority order and the +// not-found error are pinned without HTTP. +func TestResolveProjectHash(t *testing.T) { + projects := []client.Project{ + {PathHash: "a1b2c3d4e5f60718", HostPath: "github.com/owner/repo@main"}, + {PathHash: "7f3e2c1a0d4b5e69", HostPath: "/Users/me/local-proj"}, + } + cases := []struct { + in string + wantHash string + wantErr bool + }{ + {"a1b2c3d4e5f60718", "a1b2c3d4e5f60718", false}, // raw hash + {"github.com/owner/repo@main", "a1b2c3d4e5f60718", false}, // external host_path + {"/Users/me/local-proj", "7f3e2c1a0d4b5e69", false}, // absolute host_path + {"deadbeefdeadbeef", "", true}, // hex but unknown + {"nope", "", true}, // no match + } + for _, c := range cases { + got, _, err := resolveProjectHash(projects, c.in) + if c.wantErr { + if err == nil { + t.Errorf("resolveProjectHash(%q): expected error, got hash=%q", c.in, got) + } + continue + } + if err != nil { + t.Errorf("resolveProjectHash(%q): unexpected error: %v", c.in, err) + continue + } + if got != c.wantHash { + t.Errorf("resolveProjectHash(%q) = %q, want %q", c.in, got, c.wantHash) + } + } +} + +// TestResolveProjectHash_LocalDerived locks the local-project path, which a +// naive host_path string compare misses: the server stores a local project's +// host_path as the full "local:{machine_id}:{path}" identity key, so an +// absolute path must be resolved by re-deriving the path_hash (matching the +// server's key derivation), not by matching the bare path against host_path. +// This is the exact regression the E2E run surfaced. +func TestResolveProjectHash_LocalDerived(t *testing.T) { + absPath := "/Users/me/some/local/project" + derived := client.EncodeProjectPath(absPath) // sha1("local::") + projects := []client.Project{ + {PathHash: derived, HostPath: "local:machineabc:" + absPath}, + } + got, hp, err := resolveProjectHash(projects, absPath) + if err != nil { + t.Fatalf("resolveProjectHash(%q): unexpected error: %v", absPath, err) + } + if got != derived { + t.Errorf("resolveProjectHash(%q) = %q, want derived hash %q", absPath, got, derived) + } + if hp != "local:machineabc:"+absPath { + t.Errorf("expected the stored host_path back, got %q", hp) + } +} + +// TestReadAffirmative pins the yes-parsing used by the delete prompt. +func TestReadAffirmative(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"y\n", true}, {"yes\n", true}, {"Y\n", true}, {"YES\n", true}, + {"n\n", false}, {"\n", false}, {"", false}, {"nope\n", false}, + } + for _, c := range cases { + withPipedStdin(t, c.in) + if got := readAffirmative(); got != c.want { + t.Errorf("readAffirmative(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// TestCreateKeywordRouting proves `cix ws create ` routes to create +// (a POST) rather than being read as "describe the workspace named create". +func TestCreateKeywordRouting(t *testing.T) { + posted := false + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v1/workspaces" { + posted = true + writeJSON(w, http.StatusCreated, map[string]any{"id": "ws_x", "name": "newws"}) + return + } + http.NotFound(w, r) + }) + useAPI(t, srv) + + prev := wsDescription + wsDescription = "" + t.Cleanup(func() { wsDescription = prev }) + + _, err := captureOutput(func() error { return runWorkspace(workspaceCmd, []string{"create", "newws"}) }) + if err != nil { + t.Fatalf("runWorkspace create: %v", err) + } + if !posted { + t.Error("expected `ws create newws` to POST a new workspace") + } +} + +// withPipedStdin swaps os.Stdin for the read end of a pipe pre-filled with +// input, making isInteractive() deterministically false and feeding +// readAffirmative(). Restored on cleanup. +func withPipedStdin(t *testing.T, input string) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + old := os.Stdin + os.Stdin = r + go func() { + io.WriteString(w, input) + w.Close() + }() + t.Cleanup(func() { os.Stdin = old }) +} + // defaultWorkspaceHandler returns the standard 2-project fixture used // by every test in this file. Factored out to avoid copy-pasting the // JSON literal across handlers. diff --git a/cli/internal/client/workspace.go b/cli/internal/client/workspace.go index 0f12a71..df07434 100644 --- a/cli/internal/client/workspace.go +++ b/cli/internal/client/workspace.go @@ -105,6 +105,86 @@ func (c *Client) ListWorkspaceProjects(workspaceID string) (*WorkspaceProjectLis return &out, nil } +// CreateWorkspace — POST /api/v1/workspaces. Creates a workspace owned by +// the caller. An empty description is omitted from the body. The server +// also stamps timestamps + owner_user_id on the returned payload, which the +// minimal Workspace struct simply ignores. +func (c *Client) CreateWorkspace(name, description string) (*Workspace, error) { + body := map[string]any{"name": name} + if description != "" { + body["description"] = description + } + resp, err := c.do("POST", "/api/v1/workspaces", body) + if err != nil { + return nil, err + } + var out Workspace + if err := parseResponse(resp, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateWorkspace — PATCH /api/v1/workspaces/{id}. Both fields are optional: +// a nil pointer omits the field so the server leaves it unchanged; a non-nil +// value is sent verbatim (an empty description string clears it). Callers +// should pass at least one non-nil pointer or the request is a no-op. +func (c *Client) UpdateWorkspace(id string, name, description *string) (*Workspace, error) { + body := map[string]any{} + if name != nil { + body["name"] = *name + } + if description != nil { + body["description"] = *description + } + resp, err := c.do("PATCH", "/api/v1/workspaces/"+url.PathEscape(id), body) + if err != nil { + return nil, err + } + var out Workspace + if err := parseResponse(resp, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteWorkspace — DELETE /api/v1/workspaces/{id}. Removes the workspace and +// its project-membership rows; the projects themselves are untouched. +// Returns nil on 204 and a formatted error otherwise (including 404 when the +// workspace is already gone). +func (c *Client) DeleteWorkspace(id string) error { + resp, err := c.do("DELETE", "/api/v1/workspaces/"+url.PathEscape(id), nil) + if err != nil { + return err + } + return parseResponse(resp, nil) +} + +// LinkProjectToWorkspace — POST /api/v1/workspaces/{id}/projects. Adds an +// already-indexed project (addressed by its 16-hex path_hash) to the +// workspace. The server rejects not-yet-indexed projects (422) and +// duplicates (409); both surface as formatted errors from parseResponse. +func (c *Client) LinkProjectToWorkspace(id, projectHash string) error { + body := map[string]string{"project_hash": projectHash} + resp, err := c.do("POST", "/api/v1/workspaces/"+url.PathEscape(id)+"/projects", body) + if err != nil { + return err + } + return parseResponse(resp, nil) +} + +// UnlinkProjectFromWorkspace — DELETE +// /api/v1/workspaces/{id}/projects/{hash}. Removes the membership row; the +// project itself is untouched. A 404 (unknown project, or not linked to this +// workspace) surfaces as a formatted error. +func (c *Client) UnlinkProjectFromWorkspace(id, projectHash string) error { + resp, err := c.do("DELETE", "/api/v1/workspaces/"+url.PathEscape(id)+"/projects/"+url.PathEscape(projectHash), nil) + if err != nil { + return err + } + return parseResponse(resp, nil) +} + // WorkspaceSearch — GET /api/v1/workspaces/{id}/search. id is the // workspace's opaque ULID/UUID returned by ListWorkspaces. minScore is // optional: nil omits the parameter so the server applies its default From 56c03bea1837d2a9138f60b5949454a972f6deba Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 13 Jul 2026 17:45:57 +0100 Subject: [PATCH 2/4] docs(workspace): document CLI management verbs; keep MCP read-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the doc/skill gaps for `cix ws` management, which was previously undocumented (dashboard-only). MCP is intentionally left read-only — no new tools; docs just clarify that management lives in the CLI/dashboard. - CLI_REFERENCE + skills/cix (and its plugin copy): add a Workspaces "Manage" block (create/add/remove/rename/update/delete). - workspaces.md + WORKSPACES.md: document the CLI link-existing flow, kept clearly distinct from the dashboard clone-new (`git-repos`) flow. - cix-workspace skill: prerequisite note on creating/populating a workspace. - COWORK_MCP + cowork cix-workspace skill: clarify the cix_* MCP tools are read-only and management is done via the CLI or dashboard. Canonical skills mirrored into plugins/cix via sync-skills.sh (--check clean); plugin bats suite green. Co-Authored-By: Claude Opus 4.8 --- doc/CLI_REFERENCE.md | 22 ++++++++++++ doc/COWORK_MCP.md | 7 ++++ doc/WORKSPACES.md | 8 +++++ .../cix-cowork/skills/cix-workspace/SKILL.md | 6 ++++ plugins/cix/skills/cix-workspace/SKILL.md | 7 ++++ plugins/cix/skills/cix/SKILL.md | 33 +++++++++++++++++ skills/cix-workspace/SKILL.md | 7 ++++ skills/cix/SKILL.md | 33 +++++++++++++++++ workspaces.md | 36 +++++++++++++++++-- 9 files changed, 157 insertions(+), 2 deletions(-) diff --git a/doc/CLI_REFERENCE.md b/doc/CLI_REFERENCE.md index b5dc3ed..4b8e131 100644 --- a/doc/CLI_REFERENCE.md +++ b/doc/CLI_REFERENCE.md @@ -45,6 +45,8 @@ and how to tune `--min-score`. ## Workspaces (cross-repo) +Read / search (`ws` is an alias for `workspace`): + ```bash cix workspace list # all workspaces cix workspace "" # describe (default verb) @@ -53,6 +55,26 @@ cix workspace "" repos # list repos in the cix workspace "" search "" [--limit ] # hybrid BM25 + dense ``` +Manage (owner/admin): + +```bash +cix ws create "" [--description ""] # create a workspace +cix ws "" add # link indexed project(s) +cix ws "" remove # unlink project(s) +cix ws "" rename "" # rename +cix ws "" update [--name ""] [--description ""] # patch fields +cix ws "" delete [-y] # delete (prompts; -y skips) +``` + +A `` is any **already-indexed** project, addressed by its absolute +path, its host_path (e.g. `github.com/owner/repo@main`), or its 16-hex +`path_hash` — run `cix list` to see them. `add`/`remove` accept several at +once, and with no project default to the current directory. `add` links an +existing index; it does **not** clone. To clone and index a *new* GitHub +repo into a workspace, use the dashboard's **Add repo** flow (see +[`WORKSPACES.md`](WORKSPACES.md)). `delete` removes the workspace and its +membership links only — the underlying projects are untouched. + The CLI uses a name-first grammar so an agent doesn't need to juggle workspace ids. See [`../workspaces.md`](../workspaces.md) for the agent contract. diff --git a/doc/COWORK_MCP.md b/doc/COWORK_MCP.md index c264c97..5bc3531 100644 --- a/doc/COWORK_MCP.md +++ b/doc/COWORK_MCP.md @@ -110,6 +110,13 @@ returns an error listing the valid choices, so the agent self-corrects. In `cix_search`, relative `in`/`exclude` paths resolve against the **repository root**, never a working directory. +> **Read-only surface.** These MCP tools list, search, and read — they never +> create, delete, or link anything. Workspace *management* (creating a +> workspace, linking/unlinking projects, deleting one) is done with the `cix` +> CLI (`cix ws create` / `add` / `remove` / `delete`) or the dashboard; an MCP +> host consumes workspaces that already exist. Full verbs: +> [`CLI_REFERENCE.md`](CLI_REFERENCE.md#workspaces-cross-repo). + ## Configuration precedence `cix mcp` resolves its target server exactly like every other cix command, plus diff --git a/doc/WORKSPACES.md b/doc/WORKSPACES.md index 3e85b9b..0c400bc 100644 --- a/doc/WORKSPACES.md +++ b/doc/WORKSPACES.md @@ -56,6 +56,14 @@ and the table rename in `e433fee`. and runs the existing indexer pipeline against it. Status transitions visible on the workspace detail page: `created → indexing → indexed`. +> **Linking an already-indexed project (no clone).** Steps 4–5 clone a +> *new* GitHub repo. To instead group projects that are **already +> indexed** — local `cix init` projects, or repos cloned earlier — link +> them without a second clone via the dashboard's **Link existing +> project** button or the CLI: `cix ws "" add ` (plus +> `cix ws create` / `remove` / `rename` / `delete` for the rest). Full +> verb list: [`CLI_REFERENCE.md`](CLI_REFERENCE.md#workspaces-cross-repo). + ## Environment variables | Variable | Default | Purpose | diff --git a/plugins/cix-cowork/skills/cix-workspace/SKILL.md b/plugins/cix-cowork/skills/cix-workspace/SKILL.md index d99c3f6..e6376ca 100644 --- a/plugins/cix-cowork/skills/cix-workspace/SKILL.md +++ b/plugins/cix-cowork/skills/cix-workspace/SKILL.md @@ -28,6 +28,12 @@ There is no "current repo" here (Cowork has no opened working directory). A workspace groups several indexed repos on a server; infer the task's **anchor repo** from the request, and let the workspace supply the surrounding repos. +> **This skill only searches.** The `cix_*` MCP tools are read-only — there is +> no MCP tool to create a workspace or link/unlink repos. If the workspace you +> need doesn't exist yet, an operator sets it up with the `cix` CLI +> (`cix ws create` / `add`) or the dashboard; here you consume workspaces that +> already exist. + ## First: which server hosts the workspace? A cix connection may reach more than one **server** (a local box, a remote diff --git a/plugins/cix/skills/cix-workspace/SKILL.md b/plugins/cix/skills/cix-workspace/SKILL.md index 5288299..790d1ad 100644 --- a/plugins/cix/skills/cix-workspace/SKILL.md +++ b/plugins/cix/skills/cix-workspace/SKILL.md @@ -29,6 +29,13 @@ named workspace at once and tells you: Those three questions are the *goal* of using this skill. Don't jump to implementation before you can answer all three with evidence. +> **Prerequisite: a populated workspace.** This skill assumes the +> workspace already exists and its repos are indexed. If it doesn't, +> create and populate one first (owner/admin): `cix ws create ""`, +> then `cix ws "" add ` for each already-indexed repo — or +> clone new GitHub repos in via the dashboard. `cix ws` lists what's +> available; the main `cix` skill has the full management verb reference. + --- ## First: which server hosts the workspace? diff --git a/plugins/cix/skills/cix/SKILL.md b/plugins/cix/skills/cix/SKILL.md index 7b7549c..c354d10 100644 --- a/plugins/cix/skills/cix/SKILL.md +++ b/plugins/cix/skills/cix/SKILL.md @@ -211,6 +211,39 @@ specific server. Only add `--server ` when the task explicitly targets that named backend; never guess an alias — run `cix config show` to see the configured names if unsure. +### Workspaces — cross-repo search + management + +A **workspace** groups several indexed repos into one corpus for +cross-project search. List / describe / search: + +```bash +cix ws # list workspaces +cix ws "" # describe (repos + status) +cix ws "" search "" # hybrid cross-repo search +``` + +Management (owner/admin): + +```bash +cix ws create "" [--description "…"] # create +cix ws "" add # link an indexed project (local or external) +cix ws "" remove # unlink +cix ws "" rename "" # rename +cix ws "" update [--name ""] [--description "…"] +cix ws "" delete [-y] # delete (prompts; -y skips) +``` + +`add`/`remove` take a project's absolute path, host_path +(`github.com/owner/repo@main`), or 16-hex `path_hash` (see `cix list`); no +arg defaults to the current directory. `add` links an **already-indexed** +project — it does not clone (to clone a new GitHub repo into a workspace, +use the dashboard). `delete` drops the workspace + its links, not the +projects. + +For the full cross-project *research* workflow — which repos to trust, how +to read the hybrid bm25/dense scores — use the dedicated **`cix-workspace`** +skill (`/cix-workspace `). + --- ## Search quality — what scores mean diff --git a/skills/cix-workspace/SKILL.md b/skills/cix-workspace/SKILL.md index 5288299..790d1ad 100644 --- a/skills/cix-workspace/SKILL.md +++ b/skills/cix-workspace/SKILL.md @@ -29,6 +29,13 @@ named workspace at once and tells you: Those three questions are the *goal* of using this skill. Don't jump to implementation before you can answer all three with evidence. +> **Prerequisite: a populated workspace.** This skill assumes the +> workspace already exists and its repos are indexed. If it doesn't, +> create and populate one first (owner/admin): `cix ws create ""`, +> then `cix ws "" add ` for each already-indexed repo — or +> clone new GitHub repos in via the dashboard. `cix ws` lists what's +> available; the main `cix` skill has the full management verb reference. + --- ## First: which server hosts the workspace? diff --git a/skills/cix/SKILL.md b/skills/cix/SKILL.md index 8913107..b35f50b 100644 --- a/skills/cix/SKILL.md +++ b/skills/cix/SKILL.md @@ -181,6 +181,39 @@ specific server. Only add `--server ` when the task explicitly targets that named backend; never guess an alias — run `cix config show` to see the configured names if unsure. +### Workspaces — cross-repo search + management + +A **workspace** groups several indexed repos into one corpus for +cross-project search. List / describe / search: + +```bash +cix ws # list workspaces +cix ws "" # describe (repos + status) +cix ws "" search "" # hybrid cross-repo search +``` + +Manage (owner/admin): + +```bash +cix ws create "" [--description "…"] # create +cix ws "" add # link an indexed project (local or external) +cix ws "" remove # unlink +cix ws "" rename "" # rename +cix ws "" update [--name ""] [--description "…"] +cix ws "" delete [-y] # delete (prompts; -y skips) +``` + +`add`/`remove` take a project's absolute path, host_path +(`github.com/owner/repo@main`), or 16-hex `path_hash` (see `cix list`); no +arg defaults to the current directory. `add` links an **already-indexed** +project — it does not clone (to clone a new GitHub repo into a workspace, +use the dashboard). `delete` drops the workspace + its links, not the +projects. + +For the full cross-project *research* workflow — which repos to trust, how +to read the hybrid bm25/dense scores — use the dedicated **`cix-workspace`** +skill (`/cix-workspace `). + --- ## Search quality — what scores mean diff --git a/workspaces.md b/workspaces.md index 72e1ddd..e6be3e3 100644 --- a/workspaces.md +++ b/workspaces.md @@ -56,8 +56,9 @@ cross-project search endpoint. every push to the tracked branch. - **Dashboard UI.** Browser-facing CRUD for workspaces, repos, tokens, and a two-stage search interface. -- **CLI integration.** `cix ws` for listing workspaces, describing - them, and running cross-project search from the terminal. +- **CLI integration.** `cix ws` from the terminal: list/describe + workspaces and run cross-project search, plus manage them — create a + workspace, link/unlink already-indexed projects, rename, and delete. - **Agent skill.** A `cix-workspace` skill teaches AI agents how to use the workspace search responsibly, with a dedicated `cix-workspace-investigator` sub-agent for parallel per-repo @@ -281,6 +282,34 @@ clone metadata, then `POST /api/v1/workspaces/{id}/projects` to link the resulting `path_hash` into the workspace. The clone + index job runs in the background. +### From the CLI + +`cix ws` manages workspaces and their membership from the terminal — the +counterpart to the dashboard's **Link existing project** button. It links +projects that are **already indexed** (local `cix init` projects or +previously-cloned GitHub repos); it does **not** clone. To clone and index +a *new* GitHub repo, use the dashboard or `POST /git-repos` (below). + +```bash +# Create a workspace +cix ws create "platform" --description "core platform repos" + +# Link an already-indexed project — by absolute path, host_path, or path_hash +cix ws platform add /Users/me/svc-a +cix ws platform add github.com/owner/repo@main +cix ws platform add . # the current directory + +# Unlink / rename / delete +cix ws platform remove github.com/owner/repo@main +cix ws platform rename "platform-core" +cix ws platform delete # prompts; -y to skip +``` + +Linking requires the project to be in `indexed` status; a still-cloning +project returns a "not yet indexed" error until the pipeline finishes. See +the [CLI reference](doc/CLI_REFERENCE.md#workspaces-cross-repo) for the full +verb list. + ### From the API Registering a GitHub-cloned project is a two-step flow: create the @@ -366,6 +395,9 @@ curl -X DELETE http://localhost:21847/api/v1/workspaces//projects/" remove ` (addressed by path, +host_path, or path_hash). + ### Deleting a project entirely Removes the `projects` row along with its `git_repos` peer (if any), From f3db77c9f281af377c829f99d8feff982b3198d3 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 13 Jul 2026 18:01:52 +0100 Subject: [PATCH 3/4] fix(cli): --json parity, irrelevant-flag guards, reserved-name reject; test rename/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback on the workspace management verbs: - add/remove/delete now honour --json (structured stdout instead of the human ✓ lines), so all verbs are consistent for agent consumption. - guardVerbFlags rejects a management flag set for a verb it doesn't apply to (e.g. `cix ws create foo --name bar`) instead of silently ignoring it. - create refuses `list`/`create` as names — they'd be unaddressable by the name-first grammar — and quotes the workspace name in its hint. - resolveProjectHash comment corrected: tier 3 matches on the re-derived path_hash, not host_path, and there is no ~ expansion. - new tests cover rename, update's clear-on-empty --description and no-flags error (the cmd.Flags().Changed semantics), the flag guard, the reserved-name reject, and --json for add/delete. Co-Authored-By: Claude Opus 4.8 --- cli/cmd/workspace.go | 89 ++++++++++++-- cli/cmd/workspace_test.go | 247 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 9 deletions(-) diff --git a/cli/cmd/workspace.go b/cli/cmd/workspace.go index b615261..118d7c4 100644 --- a/cli/cmd/workspace.go +++ b/cli/cmd/workspace.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -133,6 +134,9 @@ func runWorkspace(cmd *cobra.Command, args []string) error { // comment. It must be handled before the name-first arms because the // workspace it names does not exist yet. if len(args) >= 1 && strings.EqualFold(args[0], "create") { + if err := guardVerbFlags(cmd, "create"); err != nil { + return err + } return cmdCreateWorkspace(cli, args[1:]) } @@ -151,6 +155,10 @@ func runWorkspace(cmd *cobra.Command, args []string) error { verb := strings.ToLower(args[1]) rest := args[2:] + if err := guardVerbFlags(cmd, verb); err != nil { + return err + } + switch verb { case "list", "repos": if len(rest) > 0 { @@ -412,7 +420,15 @@ func cmdCreateWorkspace(cli *client.Client, args []string) error { if len(args) != 1 { return errors.New("create needs exactly one workspace name (cix ws create [--description \"...\"])") } - ws, err := cli.CreateWorkspace(args[0], wsDescription) + name := args[0] + // `list` and `create` are reserved by the name-first grammar: a workspace + // with either name would be unaddressable (`cix ws list` lists workspaces, + // `cix ws create` starts a create). Reject them up front rather than let + // the user make a workspace they can only reach by opaque id. + if lower := strings.ToLower(name); lower == "list" || lower == "create" { + return fmt.Errorf("%q is reserved and can't be a workspace name — it would be unreachable via the `cix ws` grammar", name) + } + ws, err := cli.CreateWorkspace(name, wsDescription) if err != nil { return err } @@ -420,7 +436,7 @@ func cmdCreateWorkspace(cli *client.Client, args []string) error { return emitJSON(ws) } fmt.Printf("created workspace %s (%s)\n", ws.Name, ws.ID) - fmt.Fprintf(os.Stderr, "add projects with: cix ws %s add \n", ws.Name) + fmt.Fprintf(os.Stderr, "add projects with: cix ws %q add \n", ws.Name) return nil } @@ -495,6 +511,9 @@ func cmdDeleteWorkspace(cli *client.Client, identifier string) error { if err := cli.DeleteWorkspace(id); err != nil { return err } + if wsJSON { + return emitJSON(map[string]any{"deleted": true, "workspace": identifier, "id": id}) + } fmt.Printf("deleted workspace %s\n", identifier) return nil } @@ -531,29 +550,47 @@ func mutateProjects(cli *client.Client, identifier string, projectArgs []string, return fmt.Errorf("list projects: %w", err) } + doneVerb := "added" + if op != "add" { + doneVerb = "removed" + } + results := make([]projectMutationResult, 0, len(targets)) failures := 0 for _, t := range targets { hash, hostPath, rerr := resolveProjectHash(projList, t) if rerr != nil { - fmt.Fprintf(os.Stderr, "✗ %s: %v\n", t, rerr) + results = append(results, projectMutationResult{Project: t, Status: "failed", Error: rerr.Error()}) + if !wsJSON { + fmt.Fprintf(os.Stderr, "✗ %s: %v\n", t, rerr) + } failures++ continue } var opErr error - var verb string if op == "add" { opErr = cli.LinkProjectToWorkspace(id, hash) - verb = "added" } else { opErr = cli.UnlinkProjectFromWorkspace(id, hash) - verb = "removed" } if opErr != nil { - fmt.Fprintf(os.Stderr, "✗ %s: %v\n", hostPath, opErr) + results = append(results, projectMutationResult{Project: t, HostPath: hostPath, Status: "failed", Error: opErr.Error()}) + if !wsJSON { + fmt.Fprintf(os.Stderr, "✗ %s: %v\n", hostPath, opErr) + } failures++ continue } - fmt.Printf("✓ %s %s\n", verb, hostPath) + results = append(results, projectMutationResult{Project: t, HostPath: hostPath, Status: doneVerb}) + if !wsJSON { + fmt.Printf("✓ %s %s\n", doneVerb, hostPath) + } + } + // In --json mode the machine-readable summary goes to stdout; a partial + // failure still returns an error so the exit code (and stderr) reflect it. + if wsJSON { + if err := emitJSON(map[string]any{"workspace": identifier, "results": results, "failed": failures}); err != nil { + return err + } } if failures > 0 { return fmt.Errorf("%d of %d project(s) failed to %s", failures, len(targets), op) @@ -561,6 +598,15 @@ func mutateProjects(cli *client.Client, identifier string, projectArgs []string, return nil } +// projectMutationResult is one row of the --json output for add / remove. +// Status is "added", "removed", or "failed"; Error is set only on failure. +type projectMutationResult struct { + Project string `json:"project"` + HostPath string `json:"host_path,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + // projectTargets returns the project identifiers to act on. An empty list // defaults to the current working directory (absolute), mirroring how // `cix init` operates on the cwd when given no path. @@ -581,7 +627,9 @@ func projectTargets(args []string) ([]string, error) { // // 1. an exact 16-hex path_hash // 2. an exact host_path (e.g. github.com/owner/repo@main, or an abs path) -// 3. a filesystem path (".", relative, ~-expanded) → abs → host_path match +// 3. a filesystem path (".", or a relative path) → abs → derived path_hash +// (a local project's host_path is the namespaced identity key, not the +// bare path, so tier 3 matches on the re-derived hash, not host_path) // // Resolving against the live project list (rather than blindly hashing the // input) lets the CLI reject unknown projects locally with an actionable @@ -631,6 +679,29 @@ func isHex16(s string) bool { return true } +// guardVerbFlags rejects a management flag (--name / --description / --yes) +// that was set on the command line but is meaningless for the chosen verb — +// cheap protection against a silently-ignored flag, e.g. +// `cix ws create foo --name bar` (--name is update-only). The read/search +// flags (--json, --verbose, --top-*, --min-score) are never touched here. +func guardVerbFlags(cmd *cobra.Command, verb string) error { + appliesTo := map[string][]string{ + "name": {"update"}, + "description": {"create", "update"}, + "yes": {"delete"}, + } + for flag, verbs := range appliesTo { + f := cmd.Flags().Lookup(flag) + if f == nil || !f.Changed { + continue + } + if !slices.Contains(verbs, verb) { + return fmt.Errorf("--%s is not valid for `cix ws %s`", flag, verb) + } + } + return nil +} + // isInteractive reports whether stdin is a TTY. Dependency-free char-device // check (mirrors indexer.isTerminal) so destructive verbs can prompt when a // human is driving and hard-fail when the input is piped. diff --git a/cli/cmd/workspace_test.go b/cli/cmd/workspace_test.go index e30de95..3108ca4 100644 --- a/cli/cmd/workspace_test.go +++ b/cli/cmd/workspace_test.go @@ -717,6 +717,253 @@ func TestCreateKeywordRouting(t *testing.T) { } } +// TestRenameWorkspace confirms `rename` PATCHes only the name (no description +// key) and renders the confirmation. +func TestRenameWorkspace(t *testing.T) { + var gotBody map[string]any + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, "total": 1, + }) + case r.URL.Path == "/api/v1/workspaces/ws_1" && r.Method == http.MethodPatch: + json.NewDecoder(r.Body).Decode(&gotBody) + writeJSON(w, 200, map[string]any{"id": "ws_1", "name": "platform-core"}) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + out, err := captureOutput(func() error { return cmdRenameWorkspace(cli, "platform", "platform-core") }) + if err != nil { + t.Fatalf("cmdRenameWorkspace: %v", err) + } + if gotBody["name"] != "platform-core" { + t.Errorf("expected name=platform-core in PATCH body, got %v", gotBody) + } + if _, hasDesc := gotBody["description"]; hasDesc { + t.Errorf("rename must not send a description key, got %v", gotBody) + } + if !strings.Contains(out, "renamed workspace to platform-core") { + t.Errorf("expected rename confirmation, got:\n%s", out) + } +} + +// TestUpdateWorkspace_ClearsDescription is the key regression guard for the +// cmd.Flags().Changed semantics: `--description ""` must send an explicit +// empty description (clear it), NOT drop the field — and must not send a name +// key when only --description was given. A future refactor that switches to a +// zero-value check would silently break the clear-on-empty behavior. +func TestUpdateWorkspace_ClearsDescription(t *testing.T) { + var raw []byte + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, "total": 1, + }) + case r.URL.Path == "/api/v1/workspaces/ws_1" && r.Method == http.MethodPatch: + raw, _ = io.ReadAll(r.Body) + writeJSON(w, 200, map[string]any{"id": "ws_1", "name": "platform", "description": ""}) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + setWSFlag(t, "description", "") // --description "" → Changed=true, clears + + _, err = captureOutput(func() error { return cmdUpdateWorkspace(workspaceCmd, cli, "platform") }) + if err != nil { + t.Fatalf("cmdUpdateWorkspace: %v", err) + } + var body map[string]any + if e := json.Unmarshal(raw, &body); e != nil { + t.Fatalf("decode PATCH body: %v", e) + } + d, hasDesc := body["description"] + if !hasDesc { + t.Errorf("expected an explicit description key (clear), got %v", body) + } + if d != "" { + t.Errorf("expected empty description, got %v", d) + } + if _, hasName := body["name"]; hasName { + t.Errorf("did not expect a name key when only --description was set, got %v", body) + } +} + +// TestUpdateWorkspace_NoFlags: with neither flag changed, update errors before +// any HTTP call and names both flags. +func TestUpdateWorkspace_NoFlags(t *testing.T) { + f := workspaceCmd.Flags() + nc, dc := f.Lookup("name").Changed, f.Lookup("description").Changed + f.Lookup("name").Changed = false + f.Lookup("description").Changed = false + t.Cleanup(func() { f.Lookup("name").Changed = nc; f.Lookup("description").Changed = dc }) + + err := cmdUpdateWorkspace(workspaceCmd, &client.Client{}, "platform") + if err == nil { + t.Fatal("expected error when neither --name nor --description is given") + } + if !strings.Contains(err.Error(), "--name") || !strings.Contains(err.Error(), "--description") { + t.Errorf("error should mention both flags, got: %v", err) + } +} + +// TestGuardVerbFlags locks the irrelevant-flag guard: --name (update-only) is +// rejected for create/delete but accepted for update. +func TestGuardVerbFlags(t *testing.T) { + setWSFlag(t, "name", "x") + if err := guardVerbFlags(workspaceCmd, "create"); err == nil { + t.Error("expected --name rejected for create") + } else if !strings.Contains(err.Error(), "--name") { + t.Errorf("error should mention --name, got: %v", err) + } + if err := guardVerbFlags(workspaceCmd, "delete"); err == nil { + t.Error("expected --name rejected for delete") + } + if err := guardVerbFlags(workspaceCmd, "update"); err != nil { + t.Errorf("--name must be valid for update, got: %v", err) + } +} + +// TestCreateWorkspace_RejectsReservedName pins that `list`/`create` (any case) +// are refused as workspace names — they'd be unaddressable by the name-first +// grammar. +func TestCreateWorkspace_RejectsReservedName(t *testing.T) { + cli := &client.Client{} + for _, name := range []string{"list", "create", "LIST", "Create"} { + if err := cmdCreateWorkspace(cli, []string{name}); err == nil { + t.Errorf("cmdCreateWorkspace(%q): expected reserved-name error, got nil", name) + } + } +} + +// TestAddProjects_JSON verifies --json emits a machine-readable summary on +// stdout (and no ✓ lines) for the add path. +func TestAddProjects_JSON(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, "total": 1, + }) + case r.URL.Path == "/api/v1/projects" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "projects": []map[string]any{ + {"path_hash": "a1b2c3d4e5f60718", "host_path": "github.com/owner/repo@main", "status": "indexed"}, + }, "total": 1, + }) + case r.URL.Path == "/api/v1/workspaces/ws_1/projects" && r.Method == http.MethodPost: + writeJSON(w, http.StatusCreated, map[string]any{"workspace_id": "ws_1"}) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + prev := wsJSON + wsJSON = true + t.Cleanup(func() { wsJSON = prev }) + + out, err := captureOutput(func() error { + return cmdAddProjects(cli, "platform", []string{"github.com/owner/repo@main"}) + }) + if err != nil { + t.Fatalf("cmdAddProjects: %v", err) + } + if strings.Contains(out, "✓") { + t.Errorf("JSON mode must not print ✓ lines, got:\n%s", out) + } + var parsed struct { + Workspace string `json:"workspace"` + Failed int `json:"failed"` + Results []struct { + HostPath string `json:"host_path"` + Status string `json:"status"` + } `json:"results"` + } + if e := json.Unmarshal([]byte(out), &parsed); e != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", e, out) + } + if parsed.Failed != 0 || len(parsed.Results) != 1 || parsed.Results[0].Status != "added" { + t.Errorf("unexpected JSON result: %+v", parsed) + } +} + +// TestDeleteWorkspace_JSON verifies --json emits a structured deletion record. +func TestDeleteWorkspace_JSON(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/workspaces" && r.Method == http.MethodGet: + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, "total": 1, + }) + case r.URL.Path == "/api/v1/workspaces/ws_1" && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + prevJSON, prevYes := wsJSON, wsYes + wsJSON, wsYes = true, true + t.Cleanup(func() { wsJSON, wsYes = prevJSON, prevYes }) + + out, err := captureOutput(func() error { return cmdDeleteWorkspace(cli, "platform") }) + if err != nil { + t.Fatalf("cmdDeleteWorkspace: %v", err) + } + var parsed struct { + Deleted bool `json:"deleted"` + Workspace string `json:"workspace"` + } + if e := json.Unmarshal([]byte(out), &parsed); e != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", e, out) + } + if !parsed.Deleted || parsed.Workspace != "platform" { + t.Errorf("unexpected JSON: %+v", parsed) + } +} + +// setWSFlag sets a workspaceCmd flag (marking it Changed) for the duration of +// the test, restoring both the value and the Changed bit afterward so flag +// state never leaks across tests. +func setWSFlag(t *testing.T, name, value string) { + t.Helper() + f := workspaceCmd.Flags() + lk := f.Lookup(name) + if lk == nil { + t.Fatalf("unknown flag %q", name) + } + prevChanged := lk.Changed + prevVal := lk.Value.String() + if err := f.Set(name, value); err != nil { + t.Fatalf("set flag %s: %v", name, err) + } + t.Cleanup(func() { + f.Set(name, prevVal) + lk.Changed = prevChanged + }) +} + // withPipedStdin swaps os.Stdin for the read end of a pipe pre-filled with // input, making isInteractive() deterministically false and feeding // readAffirmative(). Restored on cleanup. From 51998c320aeb7e4edb4c470c140f50d0cb92ab9a Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 13 Jul 2026 18:01:52 +0100 Subject: [PATCH 4/4] docs(workspace): correct the DELETE workspace cascade description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST table claimed `DELETE /workspaces/{id}` "cascades to repos + clones", contradicting the rest of the doc. Service.Delete only removes the `workspaces` row; the ON DELETE CASCADE on `workspace_projects` clears membership rows only — projects, their `git_repos` peers, and on-disk clones are untouched. Co-Authored-By: Claude Opus 4.8 --- workspaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces.md b/workspaces.md index e6be3e3..bc82c39 100644 --- a/workspaces.md +++ b/workspaces.md @@ -822,7 +822,7 @@ GET /api/v1/workspaces list POST /api/v1/workspaces create (body: {name, description}) GET /api/v1/workspaces/{id} detail PATCH /api/v1/workspaces/{id} rename / update description -DELETE /api/v1/workspaces/{id} remove (cascades to repos + clones) +DELETE /api/v1/workspaces/{id} delete (removes the workspace + membership links only; projects, git_repos, and clones are untouched) ``` ### Workspace project membership