diff --git a/cli/README.md b/cli/README.md index a9afd5a5..cb60fb70 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/watch.go b/cli/cmd/watch.go index fee8d916..898ed4a2 100644 --- a/cli/cmd/watch.go +++ b/cli/cmd/watch.go @@ -10,6 +10,7 @@ import ( "github.com/anthropics/code-index/cli/internal/config" "github.com/anthropics/code-index/cli/internal/daemon" "github.com/anthropics/code-index/cli/internal/watcher" + "github.com/anthropics/code-index/cli/internal/watchtui" "github.com/spf13/cobra" ) @@ -34,7 +35,8 @@ Examples: cix watch stop # Stop daemon for current directory cix watch stop --all # Stop all daemons cix watch status # Check daemon for current directory - cix watch list # List all running daemons`, + cix watch list # List all running daemons + cix watch manage # Interactive manager (start/stop/restart, TUI)`, Args: cobra.MaximumNArgs(1), RunE: runWatch, } @@ -66,11 +68,41 @@ var watchListCmd = &cobra.Command{ RunE: runWatchList, } +var watchManageCmd = &cobra.Command{ + Use: "manage", + Aliases: []string{"ui"}, + Short: "Interactively manage all file watchers (TUI)", + Long: `Open a full-screen manager listing every known project and its watcher +state. Navigate and stop / restart / start individual watchers, start all +auto_watch projects, stop everything, or tail a watcher's log — all from +one screen. + +The list unites the local project list (~/.cix/config.yaml) with the +running watcher daemons, so it works offline for listing and stopping; +starting a watcher requires a reachable server. + +Keys (press ? for the full table): + ↑/k ↓/j move + s stop selected + r restart selected + S start selected + a/space toggle the project's auto_watch flag + A start all auto_watch projects + x stop all + d delete project (server index + local; asks to confirm) + l/enter tail log + g refresh + q/esc quit`, + Args: cobra.NoArgs, + RunE: runWatchManage, +} + func init() { rootCmd.AddCommand(watchCmd) watchCmd.AddCommand(watchStopCmd) watchCmd.AddCommand(watchStatusCmd) watchCmd.AddCommand(watchListCmd) + watchCmd.AddCommand(watchManageCmd) watchCmd.Flags().BoolVarP(&watchForeground, "foreground", "f", false, "Run in foreground instead of daemon") watchCmd.Flags().BoolVar(&watchDaemonMode, "daemon-mode", false, "") @@ -105,18 +137,11 @@ func runWatchDaemon(projectPath string) error { return err } - if err := apiClient.Health(); err != nil { - return fmt.Errorf("API server not reachable: %w", err) - } - - _, err = apiClient.GetProject(projectPath) + // GuardedStart runs the same preflight (server reachable + project + // registered) the interactive manager uses, then starts the daemon. + pid, err := watchtui.GuardedStart(apiClient, projectPath) if err != nil { - return fmt.Errorf("project not found — run 'cix init %s' first: %w", projectPath, err) - } - - pid, err := daemon.Start(projectPath) - if err != nil { - return fmt.Errorf("start daemon: %w", err) + return err } logFile, _ := daemon.LogFilePath(projectPath) @@ -253,6 +278,19 @@ func runWatchList(cmd *cobra.Command, args []string) error { return nil } +func runWatchManage(cmd *cobra.Command, args []string) error { + // Needs a real terminal — refuse cleanly when piped or called by agents + // (bubbletea would otherwise error opening the TTY). + if fi, err := os.Stdout.Stat(); err != nil || (fi.Mode()&os.ModeCharDevice) == 0 { + return fmt.Errorf("`cix watch manage` needs an interactive terminal") + } + + // Tolerate a missing/unreachable server: listing and stopping work + // offline; only starting a watcher needs the server (surfaced in-TUI). + apiClient, _ := getClient() + return watchtui.Run(watchtui.NewDaemonManager(apiClient)) +} + func resolveProjectPath(args []string) (string, error) { path := "." if len(args) > 0 { diff --git a/cli/cmd/workspace.go b/cli/cmd/workspace.go index 952c920a..118d7c4c 100644 --- a/cli/cmd/workspace.go +++ b/cli/cmd/workspace.go @@ -1,10 +1,13 @@ package cmd import ( + "bufio" "encoding/json" "errors" "fmt" "os" + "path/filepath" + "slices" "strings" "time" @@ -17,44 +20,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 +100,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 +119,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 +130,16 @@ 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") { + if err := guardVerbFlags(cmd, "create"); err != nil { + return err + } + return cmdCreateWorkspace(cli, args[1:]) + } + switch { case len(args) == 0: return cmdListWorkspaces(cli) @@ -100,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 { @@ -117,8 +176,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 +412,320 @@ 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 \"...\"])") + } + 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 + } + 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 %q 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 + } + if wsJSON { + return emitJSON(map[string]any{"deleted": true, "workspace": identifier, "id": id}) + } + 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) + } + + 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 { + 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 + if op == "add" { + opErr = cli.LinkProjectToWorkspace(id, hash) + } else { + opErr = cli.UnlinkProjectFromWorkspace(id, hash) + } + if opErr != nil { + 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 + } + 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) + } + 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. +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 (".", 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 +// 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 +} + +// 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. +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 ea0c979b..3108ca4a 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,691 @@ 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") + } +} + +// 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. +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/projects.go b/cli/internal/client/projects.go index f4477484..96fd7205 100644 --- a/cli/internal/client/projects.go +++ b/cli/internal/client/projects.go @@ -1,10 +1,17 @@ package client import ( + "errors" "fmt" + "net/http" "time" ) +// ErrProjectGone reports that the server already had no such project (404). +// The delete is a server-side no-op, so idempotent callers can treat it as +// success and proceed with local cleanup. +var ErrProjectGone = errors.New("project not found on server") + // Project represents a code project type Project struct { PathHash string `json:"path_hash"` @@ -96,3 +103,22 @@ func (c *Client) CreateProject(path string) (*Project, error) { return &project, nil } +// DeleteProject deletes a project and its server-side index (symbols, refs, +// embeddings). Requires the project owner or an admin. A 404 is reported as +// ErrProjectGone so callers can treat an already-deleted project as success. +func (c *Client) DeleteProject(path string) error { + resp, err := c.do("DELETE", fmt.Sprintf("/api/v1/projects/%s", encodeProjectPath(path)), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNoContent, http.StatusOK: + return nil + case http.StatusNotFound: + return ErrProjectGone + default: + return parseResponse(resp, nil) + } +} diff --git a/cli/internal/client/workspace.go b/cli/internal/client/workspace.go index 0f12a71b..df074349 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 diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index a1ba6649..bfb06600 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -519,10 +519,12 @@ func AddProject(path string, autoWatch bool) error { return err } - for _, p := range cfg.Projects { - if p.Path == path { - if p.AutoWatch != autoWatch { - p.AutoWatch = autoWatch + // Index the slice: `for _, p := range` yields a copy, so assigning to + // p.AutoWatch would update nothing and Save would write the old value. + for i := range cfg.Projects { + if cfg.Projects[i].Path == path { + if cfg.Projects[i].AutoWatch != autoWatch { + cfg.Projects[i].AutoWatch = autoWatch return Save(cfg) } return nil diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index dc866835..ab910864 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -386,6 +386,34 @@ func TestAddProject(t *testing.T) { } } +// TestAddProject_UpdatesAutoWatchOnExisting guards the upsert path: calling +// AddProject for a path already in the config must persist a changed +// auto_watch flag (it used to mutate a loop-variable copy and save nothing). +func TestAddProject_UpdatesAutoWatchOnExisting(t *testing.T) { + isolateHome(t) + + if _, err := Load(); err != nil { + t.Fatal(err) + } + if err := AddProject("/srv/proj", false); err != nil { + t.Fatal(err) + } + if err := AddProject("/srv/proj", true); err != nil { + t.Fatalf("AddProject update error = %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if len(cfg.Projects) != 1 { + t.Fatalf("Projects = %v, want exactly one entry", cfg.Projects) + } + if !cfg.Projects[0].AutoWatch { + t.Error("AutoWatch = false, want true — the flag update was not persisted") + } +} + // TestAddProject_NoDuplicate verifies that adding the same path twice keeps // only one entry. func TestAddProject_NoDuplicate(t *testing.T) { diff --git a/cli/internal/watchtui/keys.go b/cli/internal/watchtui/keys.go new file mode 100644 index 00000000..0d93e1c9 --- /dev/null +++ b/cli/internal/watchtui/keys.go @@ -0,0 +1,107 @@ +package watchtui + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +// keymap groups every binding the manager responds to. Kept in one place so +// the help overlay and the Update switch agree on what's available. +type keymap struct { + Up key.Binding + Down key.Binding + + Stop key.Binding + Restart key.Binding + Start key.Binding + StartAuto key.Binding + StopAll key.Binding + Delete key.Binding + ToggleAut key.Binding + + Detail key.Binding + Refresh key.Binding + + Help key.Binding + Quit key.Binding +} + +func newKeymap() keymap { + return keymap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Stop: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "stop"), + ), + Restart: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "restart"), + ), + Start: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "start"), + ), + StartAuto: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "start all auto"), + ), + StopAll: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "stop all"), + ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete project"), + ), + ToggleAut: key.NewBinding( + key.WithKeys("a", " "), + key.WithHelp("a/space", "toggle auto_watch"), + ), + Detail: key.NewBinding( + key.WithKeys("l", "enter"), + key.WithHelp("l/enter", "log"), + ), + Refresh: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "refresh"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q/esc", "quit"), + ), + } +} + +// isAction reports whether msg is one of the mutating action keys — the set +// gated by Model.busy while a background action is in flight. +func (k keymap) isAction(msg tea.KeyMsg) bool { + return key.Matches(msg, k.Stop, k.Restart, k.Start, k.StartAuto, k.StopAll, k.Delete, k.ToggleAut) +} + +// shortHelp returns the keys shown in the always-on status bar. +func (k keymap) shortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Stop, k.Restart, k.Start, k.Delete, k.Help, k.Quit} +} + +// fullHelp returns all keys, grouped by purpose, for the ? overlay. +func (k keymap) fullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down}, + {k.Stop, k.Restart, k.Start}, + {k.StartAuto, k.StopAll}, + {k.ToggleAut, k.Delete}, + {k.Detail, k.Refresh}, + {k.Help, k.Quit}, + } +} diff --git a/cli/internal/watchtui/manager.go b/cli/internal/watchtui/manager.go new file mode 100644 index 00000000..ddf63042 --- /dev/null +++ b/cli/internal/watchtui/manager.go @@ -0,0 +1,330 @@ +package watchtui + +import ( + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + "time" + + "github.com/anthropics/code-index/cli/internal/client" + "github.com/anthropics/code-index/cli/internal/config" + "github.com/anthropics/code-index/cli/internal/daemon" +) + +// Item is one row in the manager: a known project (from the local config) +// and/or a running watcher daemon. Paths are resolved project roots, which +// is the common key between the config list, the daemon registry, and the +// server's host_path. +type Item struct { + Path string + Running bool + PID int + AutoWatch bool + LastIndexedAt *time.Time +} + +// Manager is the seam the TUI drives. Splitting it out keeps the daemon +// package free of a client import and lets tests inject a fake. +// +// List / Stop / StopAll / SetAutoWatch / LogPath are fully local (no +// network). Start / Restart / StartAllAuto preflight against the server +// because the spawned watcher child needs a registered project to sync to, +// and Delete confirms the server-side removal — so all of those can block +// for up to the client timeout and must be called off the TUI event loop. +// LastIndexed is best-effort enrichment. +type Manager interface { + List() ([]Item, error) + LastIndexed() (map[string]*time.Time, error) + Stop(path string) error + Start(path string) error + Restart(path string) error + StartAllAuto() (int, error) + StopAll() (int, error) + Delete(path string) error + SetAutoWatch(path string, on bool) error + LogPath(path string) (string, error) +} + +// DaemonManager is the production Manager backed by the local config, +// the daemon registry, and (optionally) a cix server client. +type DaemonManager struct { + client *client.Client +} + +// NewDaemonManager builds a Manager. c may be nil (no server configured); +// listing and stopping still work, only starting requires a reachable +// server. +func NewDaemonManager(c *client.Client) *DaemonManager { + return &DaemonManager{client: c} +} + +// List merges the local known-project list with the running-daemon set. +// No network — safe to call on every keystroke/tick. +func (d *DaemonManager) List() ([]Item, error) { + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + return mergeItems(cfg.Projects, daemon.ListAll()), nil +} + +// LastIndexed returns path → last-indexed time from the server, for +// best-effort enrichment. A nil client or an unreachable server yields a +// nil map and no error — enrichment is optional, never fatal. +func (d *DaemonManager) LastIndexed() (map[string]*time.Time, error) { + if d.client == nil { + return nil, nil + } + projects, err := d.client.ListProjects() + if err != nil { + return nil, err + } + out := make(map[string]*time.Time, len(projects)) + for _, p := range projects { + out[p.HostPath] = p.LastIndexedAt + } + return out, nil +} + +// Stop terminates the watcher for path. +func (d *DaemonManager) Stop(path string) error { return daemon.Stop(path) } + +// StopAll terminates every running watcher. +func (d *DaemonManager) StopAll() (int, error) { return daemon.StopAll() } + +// LogPath returns the log file path for path's watcher. +func (d *DaemonManager) LogPath(path string) (string, error) { return daemon.LogFilePath(path) } + +// Start launches a watcher for path, gated by the server preflight. +func (d *DaemonManager) Start(path string) error { + _, err := GuardedStart(d.client, path) + return err +} + +// Restart stops (if running) then starts the watcher for path. The +// preflight runs first so a server-down restart never leaves the user +// with nothing running. +func (d *DaemonManager) Restart(path string) error { + if err := Preflight(d.client, path); err != nil { + return err + } + if daemon.GetStatus(path).Running { + if err := daemon.Stop(path); err != nil { + return fmt.Errorf("stop old: %w", err) + } + } + _, err := daemon.Start(path) + return err +} + +// StartAllAuto starts a watcher for every project flagged auto_watch that +// isn't already running. Returns the count started and the first error. +func (d *DaemonManager) StartAllAuto() (int, error) { + cfg, err := config.Load() + if err != nil { + return 0, fmt.Errorf("load config: %w", err) + } + var pending []string + for _, p := range cfg.Projects { + if p.AutoWatch && !daemon.GetStatus(p.Path).Running { + pending = append(pending, p.Path) + } + } + if len(pending) == 0 { + return 0, nil + } + // One reachability probe for the whole batch: with the server down each + // probe can hang for the full client timeout, so probing per project + // would multiply that wait by the number of pending projects. + if err := serverReachable(d.client); err != nil { + return 0, err + } + started := 0 + var firstErr error + for _, path := range pending { + if err := projectRegistered(d.client, path); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if _, err := daemon.Start(path); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + started++ + } + return started, firstErr +} + +// SetAutoWatch flips the project's auto_watch flag in the local config. +// Purely local — no server involved. AddProject upserts, so toggling a +// running daemon whose path isn't yet in the config registers it too. +func (d *DaemonManager) SetAutoWatch(path string, on bool) error { + return config.AddProject(path, on) +} + +// Delete removes a project everywhere: it stops the watcher, deletes the +// project and its index on the server, then drops the local config entry and +// log. A server-side 404 (already deleted) is treated as success and the +// local cleanup still runs; any other server error aborts before touching +// local state so a live project is never orphaned. +func (d *DaemonManager) Delete(path string) error { + if d.client == nil { + return fmt.Errorf("no server configured — can't confirm server-side deletion") + } + // Stop the watcher first so a reindex can't re-create the project on the + // server between the delete and the local cleanup. + if daemon.GetStatus(path).Running { + _ = daemon.Stop(path) + } + if err := d.client.DeleteProject(path); err != nil && !errors.Is(err, client.ErrProjectGone) { + return err + } + if err := config.RemoveProject(path); err != nil { + return fmt.Errorf("remove from config: %w", err) + } + if logPath, err := daemon.LogFilePath(path); err == nil { + _ = os.Remove(logPath) + } + return nil +} + +// Preflight verifies a watcher can be usefully started for path: a server +// must be reachable and the project must be registered on it (the watcher +// child exits immediately otherwise). Shared by the TUI and `cix watch`. +func Preflight(c *client.Client, path string) error { + if err := serverReachable(c); err != nil { + return err + } + return projectRegistered(c, path) +} + +// serverReachable checks that a server is configured and answering /health. +func serverReachable(c *client.Client) error { + if c == nil { + return fmt.Errorf("no server configured — set one with `cix config`") + } + if err := c.Health(); err != nil { + return fmt.Errorf("server not reachable: %w", err) + } + return nil +} + +// projectRegistered checks that path is a registered project on the server. +func projectRegistered(c *client.Client, path string) error { + if _, err := c.GetProject(path); err != nil { + return fmt.Errorf("project not registered — run `cix init %s` first: %w", path, err) + } + return nil +} + +// GuardedStart runs Preflight then starts the daemon, returning its PID. +func GuardedStart(c *client.Client, path string) (int, error) { + if err := Preflight(c, path); err != nil { + return 0, err + } + return daemon.Start(path) +} + +// mergeItems unions the local known projects with the running daemons. +// Pure and deterministic: running rows first, then by path. Daemons whose +// path isn't in the config list are still surfaced. known carries the +// auto_watch flag. This is the unit-test anchor. +func mergeItems(known []config.ProjectEntry, daemons []daemon.Status) []Item { + idx := make(map[string]int, len(known)) + items := make([]Item, 0, len(known)) + + for _, p := range known { + idx[p.Path] = len(items) + items = append(items, Item{Path: p.Path, AutoWatch: p.AutoWatch}) + } + for _, s := range daemons { + if i, ok := idx[s.ProjectPath]; ok { + items[i].Running = true + items[i].PID = s.PID + continue + } + idx[s.ProjectPath] = len(items) + items = append(items, Item{Path: s.ProjectPath, Running: true, PID: s.PID}) + } + + sort.SliceStable(items, func(i, j int) bool { + if items[i].Running != items[j].Running { + return items[i].Running + } + return items[i].Path < items[j].Path + }) + return items +} + +// tailReadBudget caps how many bytes readTail loads. Watcher logs are +// append-only and never rotated, so they can grow unbounded; seeking to the +// tail keeps opening the detail overlay O(budget) instead of O(file size). +const tailReadBudget = 64 * 1024 + +// readTail returns the last n lines of a log file, or a single diagnostic +// line if it can't be read. Only the final tailReadBudget bytes are read. +func readTail(path string, n int) []string { + return readTailN(path, n, tailReadBudget) +} + +func readTailN(path string, n int, budget int64) []string { + f, err := os.Open(path) + if err != nil { + return []string{fmt.Sprintf("(no log: %v)", err)} + } + defer f.Close() + + st, err := f.Stat() + if err != nil { + return []string{fmt.Sprintf("(no log: %v)", err)} + } + var off int64 + if st.Size() > budget { + off = st.Size() - budget + } + buf := make([]byte, st.Size()-off) + read, err := f.ReadAt(buf, off) + if err != nil && !errors.Is(err, io.EOF) { + return []string{fmt.Sprintf("(no log: %v)", err)} + } + data := string(buf[:read]) + if off > 0 { + // We landed mid-line; drop the partial first line. + if i := strings.IndexByte(data, '\n'); i >= 0 { + data = data[i+1:] + } + } + + lines := strings.Split(strings.TrimRight(data, "\n"), "\n") + if len(lines) == 1 && lines[0] == "" { + return []string{"(log is empty)"} + } + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return lines +} + +// humanAge renders a coarse relative time for the last-indexed column. +func humanAge(t time.Time) string { + d := time.Since(t) + switch { + case d < 0: + return "just now" + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} diff --git a/cli/internal/watchtui/manager_test.go b/cli/internal/watchtui/manager_test.go new file mode 100644 index 00000000..d58c1937 --- /dev/null +++ b/cli/internal/watchtui/manager_test.go @@ -0,0 +1,237 @@ +package watchtui + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/anthropics/code-index/cli/internal/client" + "github.com/anthropics/code-index/cli/internal/config" + "github.com/anthropics/code-index/cli/internal/daemon" +) + +func paths(items []Item) []string { + out := make([]string, len(items)) + for i, it := range items { + out[i] = it.Path + } + return out +} + +func find(items []Item, path string) (Item, bool) { + for _, it := range items { + if it.Path == path { + return it, true + } + } + return Item{}, false +} + +func TestMergeItems_RunningFirstThenByPath(t *testing.T) { + known := []config.ProjectEntry{ + {Path: "/b", AutoWatch: false}, + {Path: "/a", AutoWatch: true}, + } + daemons := []daemon.Status{ + {Running: true, PID: 10, ProjectPath: "/a"}, + } + + got := paths(mergeItems(known, daemons)) + want := []string{"/a", "/b"} // /a running → first; /b stopped + if len(got) != len(want) { + t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("order[%d] = %q, want %q (full %v)", i, got[i], want[i], got) + } + } +} + +func TestMergeItems_DedupByPath(t *testing.T) { + known := []config.ProjectEntry{{Path: "/proj", AutoWatch: true}} + daemons := []daemon.Status{{Running: true, PID: 42, ProjectPath: "/proj"}} + + items := mergeItems(known, daemons) + if len(items) != 1 { + t.Fatalf("expected 1 merged row, got %d: %v", len(items), paths(items)) + } + it := items[0] + if !it.Running || it.PID != 42 { + t.Errorf("running/pid = %v/%d, want true/42", it.Running, it.PID) + } + if !it.AutoWatch { + t.Error("AutoWatch should be carried from the config entry") + } +} + +func TestMergeItems_DaemonNotInConfigStillShown(t *testing.T) { + known := []config.ProjectEntry{{Path: "/known"}} + daemons := []daemon.Status{{Running: true, PID: 7, ProjectPath: "/orphan"}} + + items := mergeItems(known, daemons) + if _, ok := find(items, "/orphan"); !ok { + t.Fatalf("orphan daemon row missing: %v", paths(items)) + } + orphan, _ := find(items, "/orphan") + if !orphan.Running || orphan.PID != 7 { + t.Errorf("orphan running/pid = %v/%d, want true/7", orphan.Running, orphan.PID) + } + if orphan.AutoWatch { + t.Error("orphan (no config entry) should not be auto_watch") + } +} + +func TestMergeItems_EmptyInputs(t *testing.T) { + if items := mergeItems(nil, nil); len(items) != 0 { + t.Errorf("expected no rows, got %v", paths(items)) + } +} + +// isolateHomeDir points HOME at a temp dir and resets the config singleton +// so DaemonManager tests never touch the real ~/.cix. +func isolateHomeDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("XDG_CONFIG_HOME", "") + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + return dir +} + +// TestStartAllAuto_SingleHealthProbe pins the batch-preflight contract: one +// /health probe for the whole run, not one per pending project — with the +// server down each probe can hang for the full client timeout, so probing +// per project would multiply that wait. +func TestStartAllAuto_SingleHealthProbe(t *testing.T) { + isolateHomeDir(t) + + var healthHits, otherHits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + healthHits.Add(1) + } else { + otherHits.Add(1) + } + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + for _, p := range []string{"/srv/auto-a", "/srv/auto-b", "/srv/auto-c"} { + if err := config.AddProject(p, true); err != nil { + t.Fatal(err) + } + } + + d := NewDaemonManager(client.New(srv.URL, "test-key")) + n, err := d.StartAllAuto() + if err == nil { + t.Fatal("expected an error while the server is unhealthy") + } + if n != 0 { + t.Errorf("started = %d, want 0", n) + } + if got := healthHits.Load(); got != 1 { + t.Errorf("health probes = %d, want exactly 1 for the whole batch", got) + } + if got := otherHits.Load(); got != 0 { + t.Errorf("no per-project requests may fire when the probe fails; got %d", got) + } +} + +func TestStartAllAuto_NothingPending_NoServerCalls(t *testing.T) { + isolateHomeDir(t) + + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + })) + defer srv.Close() + + if err := config.AddProject("/srv/manual", false); err != nil { // not auto_watch + t.Fatal(err) + } + d := NewDaemonManager(client.New(srv.URL, "test-key")) + n, err := d.StartAllAuto() + if err != nil || n != 0 { + t.Fatalf("StartAllAuto = (%d, %v), want (0, nil)", n, err) + } + if got := hits.Load(); got != 0 { + t.Errorf("no server calls expected with nothing to start; got %d", got) + } +} + +func writeLog(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "watcher.log") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestReadTail_LastNLines(t *testing.T) { + p := writeLog(t, "l1\nl2\nl3\nl4\n") + got := readTail(p, 2) + want := []string{"l3", "l4"} + if len(got) != len(want) { + t.Fatalf("readTail = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestReadTail_MissingFile(t *testing.T) { + got := readTail(filepath.Join(t.TempDir(), "absent.log"), 5) + if len(got) != 1 || !strings.HasPrefix(got[0], "(no log:") { + t.Errorf("readTail = %v, want a single (no log: …) diagnostic", got) + } +} + +func TestReadTail_EmptyFile(t *testing.T) { + p := writeLog(t, "") + got := readTail(p, 5) + if len(got) != 1 || got[0] != "(log is empty)" { + t.Errorf("readTail = %v, want [(log is empty)]", got) + } +} + +// TestReadTailN_SeeksTailAndDropsPartialLine pins the bounded-read contract: +// only the last `budget` bytes are read (the logs are append-only and never +// rotated, so they can be huge), and the partial line at the cut is dropped +// so every returned line is complete. +func TestReadTailN_SeeksTailAndDropsPartialLine(t *testing.T) { + var b strings.Builder + for i := range 100 { + fmt.Fprintf(&b, "line-%02d\n", i) // 8 bytes per line, 800 total + } + p := writeLog(t, b.String()) + + got := readTailN(p, 3, 60) // budget cuts mid-line inside line-92 + want := []string{"line-97", "line-98", "line-99"} + if len(got) != len(want) { + t.Fatalf("readTailN = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line[%d] = %q, want %q (partial first line must be dropped)", i, got[i], want[i]) + } + } +} + +func TestReadTailN_BudgetLandsMidLine_OnlyCompleteLines(t *testing.T) { + p := writeLog(t, "aaaa\nbbbb\ncccc\n") + got := readTailN(p, 10, 7) // tail bytes: "b\ncccc\n" → partial "b" dropped + if len(got) != 1 || got[0] != "cccc" { + t.Errorf("readTailN = %v, want [cccc]", got) + } +} diff --git a/cli/internal/watchtui/model.go b/cli/internal/watchtui/model.go new file mode 100644 index 00000000..ef63c683 --- /dev/null +++ b/cli/internal/watchtui/model.go @@ -0,0 +1,138 @@ +package watchtui + +import ( + "path/filepath" + "time" +) + +// Model is the full manager state. bubbletea is Elm-style: every message +// goes through Update(model, msg) → (model, cmd); View() is a pure read. +type Model struct { + mgr Manager + + items []Item + cursor int + + // Best-effort server enrichment (path → last-indexed), applied as an + // overlay so a stale/absent value never blocks the local row list. + enrich map[string]*time.Time + + // enrichInFlight is true while a LastIndexed load is running. The tick + // skips re-firing until the previous load lands (single-flight), so a + // stalled server can't accumulate one hung request per tick. + enrichInFlight bool + + // busy names the network-bound action currently running as a background + // command (e.g. "starting alpha"). While non-empty, mutating keys are + // rejected so two actions can't interleave daemon/config state; cleared + // by actionDoneMsg. + busy string + + // Detail (log tail) overlay. + detail bool + detailPath string + logLines []string + + // Delete confirmation. When confirmDelete is set, input is trapped: + // 'y' deletes confirmPath, any other key cancels. + confirmDelete bool + confirmPath string + + // Help overlay toggled with ?. + showHelp bool + + // Transient status line; cleared on the next keypress. + statusMsg string + statusErr bool + + width, height int + styles styles + keys keymap + + quitting bool +} + +// NewModel builds the initial Model and populates the row list from the +// local sources (config + daemons) synchronously, so the first frame is +// already correct before any async enrichment lands. +func NewModel(mgr Manager) Model { + m := Model{ + mgr: mgr, + styles: newStyles(), + keys: newKeymap(), + // Init fires the first enrichment load; mark it in flight here + // because Init has a value receiver and can't set the flag itself. + enrichInFlight: true, + } + m.refresh() + return m +} + +// refresh recomputes the local row backbone (config + running daemons) and +// re-applies any enrichment. Cheap and network-free — safe to call from +// Update and on every tick. +func (m *Model) refresh() { + items, err := m.mgr.List() + if err != nil { + m.setStatus("list: "+err.Error(), false) + return + } + m.items = items + m.applyEnrich() + m.clampCursor() +} + +// applyEnrich overlays the last-indexed times onto the current items. +func (m *Model) applyEnrich() { + if m.enrich == nil { + return + } + for i := range m.items { + if t, ok := m.enrich[m.items[i].Path]; ok { + m.items[i].LastIndexedAt = t + } + } +} + +// selected returns the item under the cursor, or ok=false when the list is +// empty. +func (m Model) selected() (Item, bool) { + if m.cursor < 0 || m.cursor >= len(m.items) { + return Item{}, false + } + return m.items[m.cursor], true +} + +// clampCursor keeps cursor inside [0, len(items)). +func (m *Model) clampCursor() { + n := len(m.items) + if n == 0 { + m.cursor = 0 + return + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= n { + m.cursor = n - 1 + } +} + +// setStatus replaces the transient status line. ok=false renders in red. +func (m *Model) setStatus(msg string, ok bool) { + m.statusMsg = msg + m.statusErr = !ok +} + +func (m *Model) clearStatus() { + m.statusMsg = "" + m.statusErr = false +} + +// label is the short name shown in status messages. +func label(path string) string { + if b := filepath.Base(path); b != "" && b != "." && b != "/" { + return b + } + return path +} diff --git a/cli/internal/watchtui/model_test.go b/cli/internal/watchtui/model_test.go new file mode 100644 index 00000000..df58fb80 --- /dev/null +++ b/cli/internal/watchtui/model_test.go @@ -0,0 +1,554 @@ +package watchtui + +import ( + "errors" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// autoCall records one SetAutoWatch invocation. +type autoCall struct { + path string + on bool +} + +// fakeManager records calls and returns canned data so the Model can be +// exercised without touching the daemon layer, the config, or a server. +type fakeManager struct { + items []Item + listErr error + + stopCalls []string + startCalls []string + restartCalls []string + deleteCalls []string + autoWatchCalls []autoCall + stopAllCalls int + autoCalls int + + stopErr error + startErr error + restartErr error + deleteErr error + autoWatchErr error + stopAllN int + autoN int + + lastIndexed map[string]*time.Time +} + +func (f *fakeManager) List() ([]Item, error) { return f.items, f.listErr } +func (f *fakeManager) LastIndexed() (map[string]*time.Time, error) { + return f.lastIndexed, nil +} +func (f *fakeManager) Stop(p string) error { + f.stopCalls = append(f.stopCalls, p) + return f.stopErr +} +func (f *fakeManager) Start(p string) error { + f.startCalls = append(f.startCalls, p) + return f.startErr +} +func (f *fakeManager) Restart(p string) error { + f.restartCalls = append(f.restartCalls, p) + return f.restartErr +} +func (f *fakeManager) StartAllAuto() (int, error) { f.autoCalls++; return f.autoN, nil } +func (f *fakeManager) StopAll() (int, error) { f.stopAllCalls++; return f.stopAllN, nil } +func (f *fakeManager) Delete(p string) error { + f.deleteCalls = append(f.deleteCalls, p) + return f.deleteErr +} +func (f *fakeManager) SetAutoWatch(p string, on bool) error { + f.autoWatchCalls = append(f.autoWatchCalls, autoCall{path: p, on: on}) + return f.autoWatchErr +} +func (f *fakeManager) LogPath(p string) (string, error) { + return "/nonexistent/watcher.log", nil +} + +// makeKey mimics a single-rune keypress. Same helper shape as the config +// editor's model_test.go. +func makeKey(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case " ": + return tea.KeyMsg{Type: tea.KeySpace} + } + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +// send pushes msgs through Update and returns the final Model. Returned +// commands are dropped — background actions stay "in flight". +func send(m Model, msgs ...tea.Msg) Model { + for _, msg := range msgs { + updated, _ := m.Update(msg) + m = updated.(Model) + } + return m +} + +// sendExec pushes msgs through Update and, when a command is returned, runs +// it synchronously and feeds its message back — the test-side equivalent of +// bubbletea's runtime for the one-shot action/enrich commands. Batches +// (ticker) are skipped: executing tea.Tick would sleep for real. +func sendExec(m Model, msgs ...tea.Msg) Model { + for _, msg := range msgs { + updated, cmd := m.Update(msg) + m = updated.(Model) + if cmd == nil { + continue + } + res := cmd() + if res == nil { + continue + } + if _, isBatch := res.(tea.BatchMsg); isBatch { + continue + } + updated, _ = m.Update(res) + m = updated.(Model) + } + return m +} + +func twoRows() *fakeManager { + return &fakeManager{items: []Item{ + {Path: "/proj/alpha", Running: true, PID: 111, AutoWatch: true}, + {Path: "/proj/beta", Running: false, AutoWatch: false}, + }} +} + +func newTestModel(f *fakeManager) Model { + m := NewModel(f) + m.width, m.height = 100, 30 + return m +} + +func TestNewModel_PopulatesRowsFromManager(t *testing.T) { + m := newTestModel(twoRows()) + if len(m.items) != 2 { + t.Fatalf("items = %d, want 2", len(m.items)) + } + if m.cursor != 0 { + t.Errorf("cursor = %d, want 0", m.cursor) + } +} + +func TestNavigation_DownMovesCursor(t *testing.T) { + m := newTestModel(twoRows()) + m = send(m, makeKey("down")) + if m.cursor != 1 { + t.Errorf("after down: cursor = %d, want 1", m.cursor) + } + // Clamped at the bottom. + m = send(m, makeKey("down")) + if m.cursor != 1 { + t.Errorf("cursor should clamp at 1, got %d", m.cursor) + } +} + +func TestQuitSetsQuittingFlag(t *testing.T) { + m := newTestModel(twoRows()) + m = send(m, makeKey("q")) + if !m.quitting { + t.Error("q should set quitting=true") + } +} + +func TestHelpToggle(t *testing.T) { + m := newTestModel(twoRows()) + m = send(m, makeKey("?")) + if !m.showHelp { + t.Error("? should show help") + } + m = send(m, makeKey("x")) + if m.showHelp { + t.Error("any key should dismiss help") + } + // The dismissing key must not also be treated as an action. + if len(m.mgr.(*fakeManager).stopCalls) != 0 || m.mgr.(*fakeManager).stopAllCalls != 0 { + t.Error("dismissing help should not trigger an action") + } +} + +func TestStopKey_CallsManagerForSelectedRunningRow(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("s")) + if len(f.stopCalls) != 1 || f.stopCalls[0] != "/proj/alpha" { + t.Fatalf("stopCalls = %v, want [/proj/alpha]", f.stopCalls) + } + if m.statusErr { + t.Errorf("status should be OK after a successful stop; msg=%q", m.statusMsg) + } +} + +func TestStopKey_NotRunning_NoCall(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("down"), makeKey("s")) // beta is stopped + if len(f.stopCalls) != 0 { + t.Errorf("stop should not be called on a stopped row; got %v", f.stopCalls) + } + if !m.statusErr { + t.Error("stopping a non-running row should set an error status") + } +} + +func TestStartKey_OnStoppedRow(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = sendExec(m, makeKey("down"), makeKey("S")) // beta is stopped + if len(f.startCalls) != 1 || f.startCalls[0] != "/proj/beta" { + t.Fatalf("startCalls = %v, want [/proj/beta]", f.startCalls) + } + if m.busy != "" { + t.Errorf("busy should clear once the action completes; busy=%q", m.busy) + } + if m.statusErr { + t.Errorf("status should be OK after a successful start; msg=%q", m.statusMsg) + } +} + +// TestActionsRunOffEventLoop pins the responsiveness contract: pressing a +// network-bound action key must NOT invoke the manager inside Update — the +// call happens in the returned command — and must mark the model busy. +func TestActionsRunOffEventLoop(t *testing.T) { + cases := []struct { + name string + keys []tea.Msg + called func(f *fakeManager) int + }{ + {"start", []tea.Msg{makeKey("down"), makeKey("S")}, func(f *fakeManager) int { return len(f.startCalls) }}, + {"restart", []tea.Msg{makeKey("r")}, func(f *fakeManager) int { return len(f.restartCalls) }}, + {"start-auto", []tea.Msg{makeKey("A")}, func(f *fakeManager) int { return f.autoCalls }}, + {"delete", []tea.Msg{makeKey("d"), makeKey("y")}, func(f *fakeManager) int { return len(f.deleteCalls) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f := twoRows() + m := send(newTestModel(f), c.keys...) // send drops the command + if got := c.called(f); got != 0 { + t.Errorf("%s ran inside Update (%d calls); must run in the background command", c.name, got) + } + if m.busy == "" { + t.Errorf("%s should mark the model busy while in flight", c.name) + } + }) + } +} + +// TestBusyGate_OneActionAtATime: while an action is in flight, other +// mutating keys are rejected; the gate reopens on actionDoneMsg. +func TestBusyGate_OneActionAtATime(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("down"), makeKey("S")) // async start of beta, still in flight + if m.busy == "" { + t.Fatal("S should mark the model busy") + } + + m = send(m, makeKey("up"), makeKey("s")) // try to stop alpha (running) while busy + if len(f.stopCalls) != 0 { + t.Errorf("action while busy must be rejected; stopCalls=%v", f.stopCalls) + } + if !m.statusErr { + t.Error("busy rejection should surface as an error status") + } + + m = send(m, actionDoneMsg{status: "started beta"}) + if m.busy != "" { + t.Fatal("actionDoneMsg should clear busy") + } + m = send(m, makeKey("s")) // stop is synchronous — runs inside Update + if len(f.stopCalls) != 1 || f.stopCalls[0] != "/proj/alpha" { + t.Errorf("stop should work again once the action completes; stopCalls=%v", f.stopCalls) + } +} + +func TestStartKey_AlreadyRunning_NoCall(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("S")) // alpha is running + if len(f.startCalls) != 0 { + t.Errorf("start should not be called on a running row; got %v", f.startCalls) + } + if !m.statusErr { + t.Error("starting an already-running row should set an error status") + } +} + +func TestRestartKey_CallsManager(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = sendExec(m, makeKey("r")) + if len(f.restartCalls) != 1 || f.restartCalls[0] != "/proj/alpha" { + t.Fatalf("restartCalls = %v, want [/proj/alpha]", f.restartCalls) + } + if m.busy != "" { + t.Errorf("busy should clear once the restart completes; busy=%q", m.busy) + } +} + +func TestStopAllKey_CallsManager(t *testing.T) { + f := twoRows() + f.stopAllN = 3 + m := newTestModel(f) + m = send(m, makeKey("x")) + if f.stopAllCalls != 1 { + t.Fatalf("stopAllCalls = %d, want 1", f.stopAllCalls) + } + if !strings.Contains(m.statusMsg, "3") { + t.Errorf("status should report the stopped count; msg=%q", m.statusMsg) + } +} + +func TestStartAutoKey_CallsManager(t *testing.T) { + f := twoRows() + f.autoN = 2 + m := newTestModel(f) + m = sendExec(m, makeKey("A")) + if f.autoCalls != 1 { + t.Fatalf("autoCalls = %d, want 1", f.autoCalls) + } + if m.statusErr { + t.Errorf("start-auto should be OK; msg=%q", m.statusMsg) + } + if !strings.Contains(m.statusMsg, "2") { + t.Errorf("status should report the started count; msg=%q", m.statusMsg) + } +} + +func TestManagerError_ShowsStatusErr(t *testing.T) { + f := twoRows() + f.stopErr = errors.New("boom") + m := newTestModel(f) + m = send(m, makeKey("s")) + if !m.statusErr { + t.Error("a manager error should set statusErr=true") + } + if !strings.Contains(m.statusMsg, "boom") { + t.Errorf("status should surface the error; msg=%q", m.statusMsg) + } +} + +func TestToggleAuto_FlipsFlagForSelectedRow(t *testing.T) { + f := twoRows() // alpha: auto=true, beta: auto=false + m := newTestModel(f) + + m = send(m, makeKey("a")) // alpha true → false + if len(f.autoWatchCalls) != 1 { + t.Fatalf("autoWatchCalls = %v, want 1", f.autoWatchCalls) + } + if got := f.autoWatchCalls[0]; got.path != "/proj/alpha" || got.on { + t.Errorf("call = %+v, want {/proj/alpha false}", got) + } + + m = send(m, makeKey("down"), makeKey("a")) // beta false → true + if got := f.autoWatchCalls[1]; got.path != "/proj/beta" || !got.on { + t.Errorf("call = %+v, want {/proj/beta true}", got) + } + if m.statusErr { + t.Errorf("toggle should be OK; msg=%q", m.statusMsg) + } +} + +func TestToggleAuto_SpaceIsTheSameBinding(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey(" ")) + if len(f.autoWatchCalls) != 1 || f.autoWatchCalls[0].path != "/proj/alpha" { + t.Fatalf("space should toggle the selected row; got %v", f.autoWatchCalls) + } + _ = m +} + +func TestToggleAuto_DoesNotStartOrStopAnything(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("a")) + if len(f.startCalls) != 0 || len(f.stopCalls) != 0 || f.autoCalls != 0 { + t.Error("toggling auto_watch must not start/stop watchers") + } + _ = m +} + +func TestToggleAuto_ErrorShowsStatusErr(t *testing.T) { + f := twoRows() + f.autoWatchErr = errors.New("disk full") + m := newTestModel(f) + m = send(m, makeKey("a")) + if !m.statusErr || !strings.Contains(m.statusMsg, "disk full") { + t.Errorf("toggle error should surface; err=%v msg=%q", m.statusErr, m.statusMsg) + } +} + +func TestDeleteKey_RequiresConfirmation(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("d")) + if !m.confirmDelete { + t.Fatal("d should arm the delete confirmation") + } + if m.confirmPath != "/proj/alpha" { + t.Errorf("confirmPath = %q, want /proj/alpha", m.confirmPath) + } + if len(f.deleteCalls) != 0 { + t.Errorf("delete must not fire before confirmation; got %v", f.deleteCalls) + } + // The prompt should be visible in the view. + if !strings.Contains(m.View(), "permanent") { + t.Error("View() should show the delete confirmation prompt") + } +} + +func TestDeleteConfirm_YesDeletes(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = sendExec(m, makeKey("d"), makeKey("y")) + if len(f.deleteCalls) != 1 || f.deleteCalls[0] != "/proj/alpha" { + t.Fatalf("deleteCalls = %v, want [/proj/alpha]", f.deleteCalls) + } + if m.confirmDelete { + t.Error("confirmation should be cleared after y") + } + if m.statusErr { + t.Errorf("status should be OK after a successful delete; msg=%q", m.statusMsg) + } +} + +func TestDeleteConfirm_OtherKeyCancels(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("d"), makeKey("n")) + if len(f.deleteCalls) != 0 { + t.Errorf("n should cancel; got delete calls %v", f.deleteCalls) + } + if m.confirmDelete { + t.Error("confirmation should be cleared after cancel") + } + // A stray cancel key must not fall through to an action or quit. + if m.quitting { + t.Error("cancel key should not quit") + } +} + +func TestDeleteConfirm_EscCancels(t *testing.T) { + f := twoRows() + m := newTestModel(f) + m = send(m, makeKey("d"), makeKey("esc")) + if len(f.deleteCalls) != 0 { + t.Errorf("esc should cancel; got %v", f.deleteCalls) + } + if m.quitting { + t.Error("esc during confirm should cancel, not quit") + } +} + +func TestDeleteError_ShowsStatusErr(t *testing.T) { + f := twoRows() + f.deleteErr = errors.New("kaboom") + m := newTestModel(f) + m = sendExec(m, makeKey("d"), makeKey("y")) + if !m.statusErr { + t.Error("a delete error should set statusErr=true") + } + if !strings.Contains(m.statusMsg, "kaboom") { + t.Errorf("status should surface the error; msg=%q", m.statusMsg) + } +} + +func TestDetailKey_OpensAndCloses(t *testing.T) { + m := newTestModel(twoRows()) + m = send(m, makeKey("enter")) + if !m.detail { + t.Fatal("enter should open the log detail overlay") + } + m = send(m, makeKey("esc")) + if m.detail { + t.Error("esc should close the detail overlay") + } +} + +func TestEnrichMsg_AppliesLastIndexed(t *testing.T) { + m := newTestModel(twoRows()) + ts := time.Now().Add(-3 * time.Minute) + m = send(m, enrichMsg{byPath: map[string]*time.Time{"/proj/alpha": &ts}}) + it, ok := find(m.items, "/proj/alpha") + if !ok || it.LastIndexedAt == nil { + t.Fatalf("LastIndexedAt should be applied to /proj/alpha") + } +} + +// TestEnrichSingleFlight pins the anti-pile-up rule: only one LastIndexed +// load may be in flight; the next fires only after the previous one lands. +func TestEnrichSingleFlight(t *testing.T) { + m := newTestModel(twoRows()) + // NewModel marks the initial load (fired by Init) as in flight. + if !m.enrichInFlight { + t.Fatal("NewModel should mark the initial enrichment in flight") + } + if cmd := m.maybeEnrichCmd(); cmd != nil { + t.Error("no second enrichment may fire while one is in flight") + } + + m = send(m, enrichMsg{byPath: nil}) + if m.enrichInFlight { + t.Fatal("enrichMsg should clear the in-flight flag") + } + if cmd := m.maybeEnrichCmd(); cmd == nil { + t.Error("the gate should reopen after the previous load lands") + } + if !m.enrichInFlight { + t.Error("firing a load should re-arm the in-flight flag") + } + + // An errored load must also reopen the gate (retry on the next tick). + m = send(m, enrichMsg{err: errors.New("server down")}) + if m.enrichInFlight { + t.Error("an errored enrichment should still clear the in-flight flag") + } +} + +func TestListWindow_KeepsCursorVisible(t *testing.T) { + cases := []struct { + n, cursor, size, wantStart, wantEnd int + }{ + {n: 3, cursor: 0, size: 5, wantStart: 0, wantEnd: 3}, // fits: whole list + {n: 10, cursor: 0, size: 4, wantStart: 0, wantEnd: 4}, // top pin + {n: 10, cursor: 9, size: 4, wantStart: 6, wantEnd: 10}, // bottom pin + {n: 10, cursor: 5, size: 4, wantStart: 3, wantEnd: 7}, // centered + } + for _, c := range cases { + start, end := listWindow(c.n, c.cursor, c.size) + if start != c.wantStart || end != c.wantEnd { + t.Errorf("listWindow(%d,%d,%d) = (%d,%d), want (%d,%d)", + c.n, c.cursor, c.size, start, end, c.wantStart, c.wantEnd) + } + if c.cursor < start || c.cursor >= end { + t.Errorf("cursor %d not visible in window [%d,%d)", c.cursor, start, end) + } + } +} + +func TestView_RendersRowsAndStatus(t *testing.T) { + m := newTestModel(twoRows()) + m = send(m, makeKey("s")) // sets a status message + out := m.View() + for _, want := range []string{"/proj/alpha", "/proj/beta", "Watchers"} { + if !strings.Contains(out, want) { + t.Errorf("View() missing %q\n---\n%s", want, out) + } + } +} diff --git a/cli/internal/watchtui/styles.go b/cli/internal/watchtui/styles.go new file mode 100644 index 00000000..6c25c1d4 --- /dev/null +++ b/cli/internal/watchtui/styles.go @@ -0,0 +1,55 @@ +package watchtui + +import "github.com/charmbracelet/lipgloss" + +// Palette mirrors internal/config/tui — mid-tones that adapt to light and +// dark terminals via lipgloss's color detection. +var ( + colAccent = lipgloss.AdaptiveColor{Light: "#005577", Dark: "#7dd3fc"} + colMuted = lipgloss.AdaptiveColor{Light: "#666666", Dark: "#888888"} + colActiveBdr = lipgloss.AdaptiveColor{Light: "#005577", Dark: "#7dd3fc"} + colSel = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"} + colSelBg = lipgloss.AdaptiveColor{Light: "#cce5f0", Dark: "#1e3a8a"} + colOK = lipgloss.AdaptiveColor{Light: "#15803d", Dark: "#86efac"} + colErr = lipgloss.AdaptiveColor{Light: "#b91c1c", Dark: "#fca5a5"} + colWarn = lipgloss.AdaptiveColor{Light: "#a16207", Dark: "#fcd34d"} + colAuto = lipgloss.AdaptiveColor{Light: "#7c3aed", Dark: "#c4b5fd"} +) + +// styles is a flat bundle of every reusable lipgloss.Style, built once in +// newStyles() and stored on the Model so View() does no allocation. +type styles struct { + panel lipgloss.Style + header lipgloss.Style + row lipgloss.Style + rowSel lipgloss.Style + muted lipgloss.Style + dot lipgloss.Style + dotDimmed lipgloss.Style + autoTag lipgloss.Style + statusBar lipgloss.Style + statusOK lipgloss.Style + statusErr lipgloss.Style + statusKey lipgloss.Style + confirm lipgloss.Style +} + +func newStyles() styles { + return styles{ + panel: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()).BorderForeground(colActiveBdr). + Padding(0, 1), + header: lipgloss.NewStyle().Foreground(colAccent).Bold(true), + row: lipgloss.NewStyle(), + rowSel: lipgloss.NewStyle().Foreground(colSel).Background(colSelBg).Bold(true), + muted: lipgloss.NewStyle().Foreground(colMuted), + dot: lipgloss.NewStyle().Foreground(colOK), + dotDimmed: lipgloss.NewStyle().Foreground(colMuted), + autoTag: lipgloss.NewStyle().Foreground(colAuto).Italic(true), + statusBar: lipgloss.NewStyle().Foreground(colMuted), + statusOK: lipgloss.NewStyle().Foreground(colOK).Bold(true), + statusErr: lipgloss.NewStyle().Foreground(colErr).Bold(true), + statusKey: lipgloss.NewStyle().Foreground(colAccent).Bold(true), + confirm: lipgloss.NewStyle().Foreground(colWarn).Bold(true), + } +} diff --git a/cli/internal/watchtui/tui.go b/cli/internal/watchtui/tui.go new file mode 100644 index 00000000..74fa2239 --- /dev/null +++ b/cli/internal/watchtui/tui.go @@ -0,0 +1,28 @@ +// Package watchtui implements the interactive watcher manager used by +// `cix watch manage`. +// +// It is a full-screen bubbletea program — Elm-style state machine, lipgloss +// for styling — mirroring internal/config/tui. The list unites the local +// known-project set (~/.cix/config.yaml `projects:`) with the running +// watcher daemons, so the user can see every watcher at a glance and stop, +// restart, or start any of them from one screen. +// +// Local actions (stop, stop-all, auto_watch toggle) run synchronously inside +// Update — they are filesystem-only and microsecond-fast. Anything that talks +// to the server (start/restart preflight, start-all-auto, delete, and the +// last-indexed enrichment) runs off the event loop as a tea.Cmd, so a slow or +// unreachable server can never freeze rendering or input. A busy flag keeps +// two background actions from interleaving, and the enrichment is +// single-flight so a stalled server can't pile up one hung request per tick. +package watchtui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Run boots the manager TUI against mgr and blocks until the user quits. +func Run(mgr Manager) error { + p := tea.NewProgram(NewModel(mgr), tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/cli/internal/watchtui/update.go b/cli/internal/watchtui/update.go new file mode 100644 index 00000000..b055a94f --- /dev/null +++ b/cli/internal/watchtui/update.go @@ -0,0 +1,314 @@ +package watchtui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +// refreshInterval is how often the manager re-lists watchers and re-fires +// the (best-effort) server enrichment. +const refreshInterval = 2 * time.Second + +// tickMsg fires on the periodic refresh timer. +type tickMsg struct{} + +// enrichMsg carries the result of the async server ListProjects call. +type enrichMsg struct { + byPath map[string]*time.Time + err error +} + +// actionDoneMsg reports the completion of a background action started by +// runAsync. status is ready-to-render text; isErr picks the style. +type actionDoneMsg struct { + status string + isErr bool +} + +func tickCmd() tea.Cmd { + return tea.Tick(refreshInterval, func(time.Time) tea.Msg { return tickMsg{} }) +} + +// loadEnrichCmd fetches last-indexed times off the event loop. It only +// *returns* a message and never touches the Model, per the bubbletea +// contract — so it can never race a synchronous action. +func loadEnrichCmd(mgr Manager) tea.Cmd { + return func() tea.Msg { + byPath, err := mgr.LastIndexed() + return enrichMsg{byPath: byPath, err: err} + } +} + +// maybeEnrichCmd fires a LastIndexed load unless one is already in flight — +// single-flight, so a stalled server (the client timeout is minutes) can't +// pile up a hung goroutine per tick. +func (m *Model) maybeEnrichCmd() tea.Cmd { + if m.enrichInFlight { + return nil + } + m.enrichInFlight = true + return loadEnrichCmd(m.mgr) +} + +// runAsync marks the model busy and returns a command running fn off the +// event loop. Network-bound actions (start/restart/start-auto/delete) go +// through here: their server calls can block for the full client timeout, +// and running them inside Update would freeze rendering and input. +func (m *Model) runAsync(busy string, fn func() actionDoneMsg) tea.Cmd { + m.busy = busy + m.setStatus(busy+"…", true) + return func() tea.Msg { return fn() } +} + +// Init kicks off the refresh ticker and the first enrichment load (marked +// in flight by NewModel). +func (m Model) Init() tea.Cmd { + return tea.Batch(tickCmd(), loadEnrichCmd(m.mgr)) +} + +// Update is the central message handler. bubbletea serializes calls, so +// state transitions never overlap; background work (enrichment, network +// actions) only ever comes back as a message. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case tickMsg: + m.refresh() + return m, tea.Batch(tickCmd(), m.maybeEnrichCmd()) + case enrichMsg: + m.enrichInFlight = false + if msg.err == nil { + m.enrich = msg.byPath + m.applyEnrich() + } + return m, nil + case actionDoneMsg: + m.busy = "" + m.setStatus(msg.status, !msg.isErr) + m.refresh() + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Overlays trap input: any key dismisses them. + if m.showHelp { + m.showHelp = false + return m, nil + } + if m.detail { + if key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Detail) { + m.detail = false + } + return m, nil + } + // Delete confirmation traps all input: 'y' confirms, anything else cancels. + if m.confirmDelete { + m.confirmDelete = false + if s := msg.String(); s == "y" || s == "Y" { + return m, m.doDelete() + } + m.setStatus("delete canceled", true) + return m, nil + } + + m.clearStatus() + + // One background action at a time: while one is in flight, reject the + // other mutating keys (navigation, refresh, and overlays stay available) + // so daemon/config mutations can't interleave. + if m.busy != "" && m.keys.isAction(msg) { + m.setStatus("still "+m.busy+"…", false) + return m, nil + } + + switch { + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Help): + m.showHelp = true + return m, nil + case key.Matches(msg, m.keys.Up): + m.cursor-- + m.clampCursor() + return m, nil + case key.Matches(msg, m.keys.Down): + m.cursor++ + m.clampCursor() + return m, nil + case key.Matches(msg, m.keys.Refresh): + m.refresh() + m.setStatus("refreshed", true) + return m, m.maybeEnrichCmd() + case key.Matches(msg, m.keys.Detail): + m.openDetail() + return m, nil + case key.Matches(msg, m.keys.Stop): + m.doStop() + return m, nil + case key.Matches(msg, m.keys.Start): + return m, m.doStart() + case key.Matches(msg, m.keys.Restart): + return m, m.doRestart() + case key.Matches(msg, m.keys.StartAuto): + return m, m.doStartAuto() + case key.Matches(msg, m.keys.StopAll): + m.doStopAll() + return m, nil + case key.Matches(msg, m.keys.ToggleAut): + m.doToggleAuto() + return m, nil + case key.Matches(msg, m.keys.Delete): + if it, ok := m.selected(); ok { + m.confirmDelete = true + m.confirmPath = it.Path + } + return m, nil + } + return m, nil +} + +// doToggleAuto flips the selected project's auto_watch flag in the local +// config. It does not start or stop anything — the flag only tells 'A' +// (start all auto) which projects to bring up. +func (m *Model) doToggleAuto() { + it, ok := m.selected() + if !ok { + return + } + want := !it.AutoWatch + if err := m.mgr.SetAutoWatch(it.Path, want); err != nil { + m.setStatus("auto_watch: "+err.Error(), false) + return + } + state := "off" + if want { + state = "on" + } + m.setStatus(fmt.Sprintf("auto_watch %s for %s", state, label(it.Path)), true) + m.refresh() +} + +// doDelete removes the confirmed project everywhere (watcher + server index + +// local config). Called only after the user answers 'y' to the prompt. The +// server delete runs in the background — it's a network call. +func (m *Model) doDelete() tea.Cmd { + mgr, path := m.mgr, m.confirmPath + return m.runAsync("deleting "+label(path), func() actionDoneMsg { + if err := mgr.Delete(path); err != nil { + return actionDoneMsg{status: "delete: " + err.Error(), isErr: true} + } + return actionDoneMsg{status: "deleted " + label(path)} + }) +} + +// doStop stops the selected watcher (if running). +func (m *Model) doStop() { + it, ok := m.selected() + if !ok { + return + } + if !it.Running { + m.setStatus(label(it.Path)+" is not running", false) + return + } + if err := m.mgr.Stop(it.Path); err != nil { + m.setStatus("stop: "+err.Error(), false) + return + } + m.setStatus("stopped "+label(it.Path), true) + m.refresh() +} + +// doStart starts a watcher for the selected (stopped) row in the background +// — the preflight talks to the server. +func (m *Model) doStart() tea.Cmd { + it, ok := m.selected() + if !ok { + return nil + } + if it.Running { + m.setStatus(label(it.Path)+" already running", false) + return nil + } + mgr, path := m.mgr, it.Path + return m.runAsync("starting "+label(path), func() actionDoneMsg { + if err := mgr.Start(path); err != nil { + return actionDoneMsg{status: "start: " + err.Error(), isErr: true} + } + return actionDoneMsg{status: "started " + label(path)} + }) +} + +// doRestart restarts the selected watcher in the background — the preflight +// talks to the server. +func (m *Model) doRestart() tea.Cmd { + it, ok := m.selected() + if !ok { + return nil + } + mgr, path := m.mgr, it.Path + return m.runAsync("restarting "+label(path), func() actionDoneMsg { + if err := mgr.Restart(path); err != nil { + return actionDoneMsg{status: "restart: " + err.Error(), isErr: true} + } + return actionDoneMsg{status: "restarted " + label(path)} + }) +} + +// doStartAuto starts every auto_watch project that isn't already running, +// in the background — each start is preflighted against the server. +func (m *Model) doStartAuto() tea.Cmd { + mgr := m.mgr + return m.runAsync("starting auto_watch projects", func() actionDoneMsg { + n, err := mgr.StartAllAuto() + switch { + case err != nil && n > 0: + return actionDoneMsg{status: fmt.Sprintf("started %d, then: %s", n, err.Error()), isErr: true} + case err != nil: + return actionDoneMsg{status: "start-auto: " + err.Error(), isErr: true} + case n == 0: + return actionDoneMsg{status: "no auto_watch projects to start"} + default: + return actionDoneMsg{status: fmt.Sprintf("started %d auto_watch watcher(s)", n)} + } + }) +} + +// doStopAll stops every running watcher. +func (m *Model) doStopAll() { + n, err := m.mgr.StopAll() + if err != nil { + m.setStatus("stop-all: "+err.Error(), false) + m.refresh() + return + } + m.setStatus(fmt.Sprintf("stopped %d watcher(s)", n), true) + m.refresh() +} + +// openDetail loads the tail of the selected watcher's log into the detail +// overlay. +func (m *Model) openDetail() { + it, ok := m.selected() + if !ok { + return + } + logPath, err := m.mgr.LogPath(it.Path) + if err != nil { + m.setStatus("log: "+err.Error(), false) + return + } + m.detailPath = it.Path + m.logLines = readTail(logPath, 200) + m.detail = true +} diff --git a/cli/internal/watchtui/view.go b/cli/internal/watchtui/view.go new file mode 100644 index 00000000..8ffe5034 --- /dev/null +++ b/cli/internal/watchtui/view.go @@ -0,0 +1,226 @@ +package watchtui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// View renders the full screen. Layout: +// +// ┌─ Watchers (3) ─────────────────────────────────┐ +// │ ● PID 24164 /path/to/proj auto · 3m ago │ +// │ ○ stopped /path/to/other │ +// └─────────────────────────────────────────────────┘ +// status line +// short-help bar (always visible) +func (m Model) View() string { + if m.quitting { + return "" + } + if m.width == 0 || m.height == 0 { + return "initializing…" + } + + bodyH := m.height - 3 // 1 status, 1 short-help, 1 spacing + if bodyH < 4 { + bodyH = 4 + } + + if m.showHelp { + return m.renderHelp(bodyH) + } + if m.detail { + return m.renderDetail(bodyH) + } + + body := m.renderList(m.width, bodyH) + statusLine := m.renderStatus() + helpLine := m.renderShortHelp() + + return lipgloss.JoinVertical(lipgloss.Left, body, statusLine, helpLine) +} + +// renderList draws the bordered watcher list. The row set is windowed +// around the cursor so long lists (many known projects) stay navigable on +// short terminals, with a "N more" indicator when rows are hidden. +func (m Model) renderList(w, h int) string { + running := 0 + for _, it := range m.items { + if it.Running { + running++ + } + } + title := m.styles.header.Render(fmt.Sprintf("Watchers — %d running / %d known", running, len(m.items))) + + contentH := h - 2 // inside the border + lines := []string{title, ""} + + if len(m.items) == 0 { + lines = append(lines, m.styles.muted.Render("no projects — register one with `cix init`")) + } else { + maxRows := contentH - 2 // title + blank + if maxRows < 1 { + maxRows = 1 + } + start, end := listWindow(len(m.items), m.cursor, maxRows) + if start > 0 || end < len(m.items) { + // Spend one row on a scroll indicator. + start, end = listWindow(len(m.items), m.cursor, maxRows-1) + lines = append(lines, m.styles.muted.Render(scrollHint(start, end, len(m.items)))) + } + pathW := m.pathColumnWidth() + for i := start; i < end; i++ { + lines = append(lines, m.renderRow(i, m.items[i], pathW)) + } + } + + inner := w - 4 // border + padding + if inner < 20 { + inner = 20 + } + return m.styles.panel.Width(inner).Height(h).Render(strings.Join(lines, "\n")) +} + +// listWindow returns a [start,end) slice of size at most `size` that keeps +// `cursor` visible, centered when possible and pinned at the ends. Stateless +// so View stays a pure read of the Model. +func listWindow(n, cursor, size int) (int, int) { + if size >= n { + return 0, n + } + start := cursor - size/2 + if start < 0 { + start = 0 + } + if start > n-size { + start = n - size + } + return start, start + size +} + +func scrollHint(start, end, n int) string { + above, below := start, n-end + switch { + case above > 0 && below > 0: + return fmt.Sprintf("↑ %d more · ↓ %d more", above, below) + case above > 0: + return fmt.Sprintf("↑ %d more", above) + default: + return fmt.Sprintf("↓ %d more", below) + } +} + +// renderRow renders one watcher line. +func (m Model) renderRow(i int, it Item, pathW int) string { + var dot, state string + if it.Running { + dot = m.styles.dot.Render("●") + state = fmt.Sprintf("PID %-6d", it.PID) + } else { + dot = m.styles.dotDimmed.Render("○") + state = "stopped " + } + + trailer := "" + if it.AutoWatch { + trailer += " " + m.styles.autoTag.Render("auto") + } + if it.LastIndexedAt != nil { + trailer += " " + m.styles.muted.Render("· "+humanAge(*it.LastIndexedAt)) + } + + line := fmt.Sprintf("%s %s %s%s", dot, state, padRight(it.Path, pathW), trailer) + if i == m.cursor { + return m.styles.rowSel.Render(line) + } + return m.styles.row.Render(line) +} + +func (m Model) pathColumnWidth() int { + w := 20 + for _, it := range m.items { + if l := lipgloss.Width(it.Path); l > w { + w = l + } + } + if w > 60 { + w = 60 + } + return w +} + +func padRight(s string, w int) string { + if lipgloss.Width(s) >= w { + return s + } + return s + strings.Repeat(" ", w-lipgloss.Width(s)) +} + +// renderStatus shows the delete-confirmation prompt, or the transient status +// line (OK green / error red). +func (m Model) renderStatus() string { + if m.confirmDelete { + return m.styles.confirm.Render(fmt.Sprintf( + "delete %s and its server index? this is permanent (y/N)", label(m.confirmPath))) + } + if m.statusMsg == "" { + // Keep the in-flight action visible even after a keypress cleared + // the transient status. + if m.busy != "" { + return m.styles.statusBar.Render(m.busy + "…") + } + return "" + } + style := m.styles.statusOK + if m.statusErr { + style = m.styles.statusErr + } + return style.Render(m.statusMsg) +} + +// renderShortHelp is the always-on bottom hint bar. +func (m Model) renderShortHelp() string { + var parts []string + for _, b := range m.keys.shortHelp() { + parts = append(parts, fmt.Sprintf("%s %s", b.Help().Key, b.Help().Desc)) + } + return m.styles.statusBar.Render(strings.Join(parts, " · ")) +} + +// renderHelp shows the full key table when ? is pressed. +func (m Model) renderHelp(bodyH int) string { + var b strings.Builder + b.WriteString(m.styles.header.Render("Keybindings") + "\n\n") + for _, group := range m.keys.fullHelp() { + for _, k := range group { + b.WriteString(fmt.Sprintf(" %-14s %s\n", + m.styles.statusKey.Render(k.Help().Key), k.Help().Desc)) + } + b.WriteString("\n") + } + b.WriteString(m.styles.statusBar.Render("press any key to dismiss")) + return lipgloss.Place(m.width, bodyH, lipgloss.Center, lipgloss.Center, b.String()) +} + +// renderDetail shows the tail of the selected watcher's log. +func (m Model) renderDetail(bodyH int) string { + title := m.styles.header.Render("Log — " + m.detailPath) + + visible := m.logLines + if max := bodyH - 4; max > 0 && len(visible) > max { + visible = visible[len(visible)-max:] + } + + lines := []string{title, ""} + lines = append(lines, visible...) + + inner := m.width - 4 + if inner < 20 { + inner = 20 + } + body := m.styles.panel.Width(inner).Height(bodyH).Render(strings.Join(lines, "\n")) + help := m.styles.statusBar.Render("l/enter/esc close · q quit") + return lipgloss.JoinVertical(lipgloss.Left, body, "", help) +} diff --git a/doc/CLI_REFERENCE.md b/doc/CLI_REFERENCE.md index b5dc3ed0..4b8e131e 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 c264c973..5bc35312 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 3e85b9ba..0c400bcd 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 d99c3f61..e6376cac 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 5288299a..790d1ada 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 7b7549c6..c354d105 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/server/Dockerfile.cuda b/server/Dockerfile.cuda index be134561..7eb4fdb7 100644 --- a/server/Dockerfile.cuda +++ b/server/Dockerfile.cuda @@ -91,7 +91,7 @@ RUN curl -fsSL \ # missing libllama-server-impl.so). Bump this digest deliberately and re-run # `make scout-cuda` to validate. Resolve a new digest with: # docker buildx imagetools inspect ghcr.io/ggml-org/llama.cpp:server-cuda -FROM ghcr.io/ggml-org/llama.cpp:server-cuda@sha256:d0cd0c4563e046f6ad0c8c27fe78280376d0d645cc3fef7a1ab87c75de08377b AS llama-source +FROM ghcr.io/ggml-org/llama.cpp:server-cuda@sha256:7b3d7834fc7307cb54f24f8869b67bfff276404c416452a48d11321bc36a81be AS llama-source # ── Stage 3: extract CUDA shared libraries ───────────────────────────────── # Install the CUDA libs here, then COPY individual .so files into the diff --git a/skills/cix-workspace/SKILL.md b/skills/cix-workspace/SKILL.md index 5288299a..790d1ada 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 89131076..b35f50bf 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 72e1dddb..bc82c397 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), @@ -790,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