Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
/coverage.out
/goreleaser
/.tmp/
/.tools/
/.gocache/
/.gomodcache/

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.0] - 2026-09-23

### Added

- `scan` hides stale noise by default (`.__rebind_orphan_*` workspaces, `empty-window`, `tmp-*` / numeric session agent dirs, and 0-transcript agent dirs). Pass `scan --all` to show everything.
- `scan --path <dir>` filters the workspace/agent tables to one project (includes `.__rebind_orphan_*` siblings).
- `prune` previews (default) or deletes (`--yes`) safe leftovers: orphaned `workspaceStorage` dirs and empty ephemeral agent project dirs. Creates a backup first. Optional `--empty-projects` also includes 0-transcript path-like agent dirs.
- Reject workspace-storage hashes pasted into `--from` / `--to` / `scan --path` (and the guided menu path prompts). Use the project folder path plus `--target-id` instead.
- Guided migrate/repair picker labels SPLIT-BRAIN rows with header counts and a recommended keep id.

## [1.0.1] - 2026-09-11

### Fixed
Expand Down Expand Up @@ -33,4 +43,6 @@ First public release.
- Machine-move documentation (`docs/machine-move.md`) with backup/restore and prefix vs exact guidance
- Contributing guide, code of conduct, security policy, changelog, and GitHub issue/PR templates

[Unreleased]: https://github.com/Interittus13/cursor-rebind/compare/v1.0.1...HEAD
[1.0.1]: https://github.com/Interittus13/cursor-rebind/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/Interittus13/cursor-rebind/releases/tag/v1.0.0
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ Download the Windows archive from [Releases](https://github.com/Interittus13/cur
cursor-rebind

cursor-rebind scan
cursor-rebind scan --all
cursor-rebind scan --path /path/to/project
cursor-rebind prune # dry-run safe leftovers
cursor-rebind prune --yes # quit Cursor first; backs up then deletes
cursor-rebind doctor /path/to/project

# Preview a rebind
Expand Down Expand Up @@ -161,13 +165,14 @@ cursor-rebind migrate --from /home/olduser --to /home/newuser --prefix --yes

### Notes

- Quit Cursor completely before `migrate` / `repair` (reload is not enough).
- Quit Cursor completely before `migrate` / `repair` / `prune --yes` (reload is not enough).
- Prefer `--target-id` when multiple `workspaceStorage` entries exist for the same folder.
- `--from` / `--to` must be **folder paths**, not the 32-character workspace ids from `scan` (those go in `--target-id`). Pasting an id as a path is rejected with a hint.
- **Never delete the empty shell** Cursor minted for `--to` and consolidate onto the older data leftover — Cursor remints that shell and IDE/Agents stay empty. Migrate/repair attach chats **onto** the emptiest/newest shell, then orphan siblings.
- Exact `migrate` / `repair` run a post-apply **health check** (single live workspace id + named chats on that id). Failure exits non-zero with a `repair --to` hint; use `verify` / `doctor` to detect `SPLIT-BRAIN`.
- `migrate` strategy (`create` / `replace-empty` / `merge`) chooses plan messaging and which chat becomes the primary tab. Apply steps are the same; **merge does not combine two threads into one**.
- Tool backups from migrate/repair live under `~/.cursor-rebind/backups/` and can be listed with `cursor-rebind restore --list`.
- Use `--cleanup` only after you are happy with the migrate; default is to keep path-orphaned storage as a safety net.
- Tool backups from migrate/repair/prune live under `~/.cursor-rebind/backups/` and can be listed with `cursor-rebind restore --list`.
- Use `--cleanup` only after you are happy with the migrate; default is to keep path-orphaned storage as a safety net. Later, `prune` (or migrate `--cleanup`) can remove those orphans.

## How it works

Expand Down
5 changes: 5 additions & 0 deletions docs/machine-move.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ After a rename or machine move, Cursor may leave **two** `workspaceStorage/<id>/

```bash
cursor-rebind doctor /path/to/project # look for SPLIT-BRAIN
cursor-rebind scan --path /path/to/project
# quit Cursor fully
cursor-rebind repair --to /path/to/project --yes
# optional: pick keep id explicitly from scan
# cursor-rebind repair --to /path/to/project --target-id <id> --yes
cursor-rebind verify /path/to/project
```

`--from` / `--to` are **folder paths**. The 32-character `ID` column from `scan` belongs in `--target-id` only. After migrate/repair, `cursor-rebind prune` can remove `.__rebind_orphan_*` workspaceStorage leftovers (dry-run by default).
205 changes: 185 additions & 20 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ func Execute() error {
return nil
case "scan":
return runScan(args[1:])
case "prune":
return runPrune(args[1:])
case "doctor":
return runDoctor(args[1:])
case "map", "preview":
Expand All @@ -63,7 +65,8 @@ Run with no arguments in a terminal for a guided menu.

Usage:
cursor-rebind
cursor-rebind scan [--json]
cursor-rebind scan [--all] [--path <dir>] [--json]
cursor-rebind prune [--empty-projects] [--json] [--yes]
cursor-rebind doctor [path] [--json]
cursor-rebind map --from <old> --to <new> [--prefix] [--json]
cursor-rebind migrate --from <old> --to <new> [--prefix] [--target-id <id>] [--cleanup] [--dry-run|--yes]
Expand All @@ -75,7 +78,8 @@ Usage:

Commands:
(no args) Guided menu (interactive terminal only)
scan Inventory workspaces and chat identity
scan Inventory workspaces and chat identity (hides stale noise by default)
prune Preview/delete orphaned workspaceStorage + empty tmp/empty-window agent dirs
doctor Diagnose missing chats for a project path
map Build a rebind plan (alias: preview)
migrate Apply a rebind plan (quit Cursor first)
Expand All @@ -86,17 +90,38 @@ Commands:
Notes:
--cleanup After exact migrate/repair, delete orphaned old workspaceStorage
dirs (not your project folder). Refused with --prefix.
scan Default view hides .__rebind_orphan_*, empty-window, tmp-*, and
0-transcript agent dirs. Use scan --all to show everything.
scan --path <dir> limits the table to one project.
prune Dry-run by default. Quit Cursor, then prune --yes to delete
(backs up under ~/.cursor-rebind/backups/).
--from/--to must be folder paths, not workspace ids (use --target-id for ids).
`, Version)
}

func runScan(args []string) error {
asJSON := false
for _, a := range args {
showAll := false
pathFilter := ""
for i := 0; i < len(args); i++ {
a := args[i]
switch a {
case "--json":
asJSON = true
case "--all":
showAll = true
case "--path":
if i+1 >= len(args) {
return fmt.Errorf("scan: --path requires a directory")
}
i++
pathFilter = args[i]
case "-h", "--help":
fmt.Println("Usage: cursor-rebind scan [--json]")
fmt.Println("Usage: cursor-rebind scan [--all] [--path <dir>] [--json]")
fmt.Println()
fmt.Println("By default hides stale noise (rebind orphans, empty-window, tmp-*,")
fmt.Println("0-transcript agent dirs). Pass --all to list everything.")
fmt.Println("--path limits the table to one project folder (and its orphan siblings).")
return nil
default:
return fmt.Errorf("scan: unknown flag %q", a)
Expand All @@ -112,23 +137,41 @@ func runScan(args []string) error {
return err
}

workspaces, projects, hiddenWS, hiddenProj := discover.FilterInventory(inv, !showAll)
if pathFilter != "" {
if paths.LooksLikeWorkspaceID(pathFilter) {
return paths.ErrWorkspaceIDAsPath("--path", pathFilter)
}
pathFilter = absPath(expandHome(pathFilter))
workspaces, projects = discover.FilterByPath(workspaces, projects, pathFilter)
hiddenWS, hiddenProj = 0, 0 // counts are for pre-path filter; avoid confusing footer
}

if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
// Avoid dumping every header entry in default JSON (can be large).
type scanOut struct {
Roots paths.Roots `json:"roots"`
Workspaces []discover.Workspace `json:"workspaces"`
Projects []discover.AgentProject `json:"projects"`
Headers discover.HeaderIndex `json:"headers"`
ScannedAt interface{} `json:"scannedAt"`
Roots paths.Roots `json:"roots"`
Workspaces []discover.Workspace `json:"workspaces"`
Projects []discover.AgentProject `json:"projects"`
Headers discover.HeaderIndex `json:"headers"`
ScannedAt interface{} `json:"scannedAt"`
HiddenWorkspaces int `json:"hiddenWorkspaces,omitempty"`
HiddenProjects int `json:"hiddenProjects,omitempty"`
ShowingAll bool `json:"showingAll"`
PathFilter string `json:"pathFilter,omitempty"`
}
return enc.Encode(scanOut{
Roots: inv.Roots,
Workspaces: inv.Workspaces,
Projects: inv.Projects,
Headers: inv.Headers,
ScannedAt: inv.ScannedAt,
Roots: inv.Roots,
Workspaces: workspaces,
Projects: projects,
Headers: inv.Headers,
ScannedAt: inv.ScannedAt,
HiddenWorkspaces: hiddenWS,
HiddenProjects: hiddenProj,
ShowingAll: showAll,
PathFilter: pathFilter,
})
}

Expand All @@ -137,9 +180,20 @@ func runScan(args []string) error {
fmt.Printf("User data: %s\n", roots.UserDataDir)
fmt.Printf("Global DB: %s\n", roots.GlobalDB)
fmt.Printf("Projects: %s\n", roots.ProjectsDir)
fmt.Printf("Workspaces: %d\n", len(inv.Workspaces))
fmt.Printf("Agent dirs: %d\n", len(inv.Projects))
if inv.Headers.Loaded {
if pathFilter != "" {
fmt.Printf("Filter: %s\n", pathFilter)
}
fmt.Printf("Workspaces: %d", len(workspaces))
if pathFilter == "" && !showAll && len(inv.Workspaces) != len(workspaces) {
fmt.Printf(" (of %d)", len(inv.Workspaces))
}
fmt.Println()
fmt.Printf("Agent dirs: %d", len(projects))
if pathFilter == "" && !showAll && len(inv.Projects) != len(projects) {
fmt.Printf(" (of %d)", len(inv.Projects))
}
fmt.Println()
if pathFilter == "" && inv.Headers.Loaded {
fmt.Printf("Headers: %d chats", inv.Headers.Total)
if inv.Headers.MissingPath > 0 {
fmt.Printf(" (%d without path)", inv.Headers.MissingPath)
Expand All @@ -151,14 +205,14 @@ func runScan(args []string) error {
fmt.Printf(" %-40s %d\n", k, n)
}
}
} else if inv.Headers.Error != "" {
} else if pathFilter == "" && inv.Headers.Error != "" {
fmt.Printf("Headers: error: %s\n", inv.Headers.Error)
}
fmt.Println()

w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tEXISTS\tHEADERS\tSCHEMA\tPATH")
for _, ws := range inv.Workspaces {
for _, ws := range workspaces {
ex := "no"
if ws.PathExists {
ex = "yes"
Expand All @@ -179,18 +233,101 @@ func runScan(args []string) error {
_ = w.Flush()
fmt.Println()
fmt.Println("Tip: pass ID to migrate/repair with --target-id when several rows share the same PATH.")
if pathFilter == "" && !showAll && (hiddenWS > 0 || hiddenProj > 0) {
fmt.Printf("Hidden: %d workspace(s), %d agent dir(s) — scan --all to show; prune to delete safe leftovers.\n", hiddenWS, hiddenProj)
}
if pathFilter != "" && len(workspaces) == 0 {
fmt.Println("No workspaces matched this path. Try scan --all --path <dir> or check the folder exists.")
}

fmt.Println()
fmt.Println("Agent projects:")
w = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "TRANSCRIPTS\tNAME")
for _, p := range inv.Projects {
for _, p := range projects {
fmt.Fprintf(w, "%d\t%s\n", p.TranscriptCount, p.Name)
}
_ = w.Flush()
return nil
}

func runPrune(args []string) error {
asJSON := false
yes := false
includeEmpty := false
for _, a := range args {
switch a {
case "--json":
asJSON = true
case "--yes":
yes = true
case "--empty-projects":
includeEmpty = true
case "-h", "--help":
fmt.Println("Usage: cursor-rebind prune [--empty-projects] [--json] [--yes]")
fmt.Println()
fmt.Println("Dry-run by default. Lists safe leftovers:")
fmt.Println(" • workspaceStorage dirs marked .__rebind_orphan_*")
fmt.Println(" • empty-window workspaceStorage (if present)")
fmt.Println(" • tmp-*/empty-window/numeric agent dirs with 0 transcripts")
fmt.Println()
fmt.Println("--empty-projects also lists 0-transcript path-like agent dirs (still opt-in).")
fmt.Println("Quit Cursor, then pass --yes to delete (creates a backup first).")
return nil
default:
return fmt.Errorf("prune: unknown flag %q", a)
}
}

roots, err := paths.Discover()
if err != nil {
return err
}
inv, err := discover.Scan(roots)
if err != nil {
return err
}

dryRun := !yes
res, err := rebind.PruneStale(inv, includeEmpty, dryRun)
if err != nil {
return err
}

if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(res)
}

fmt.Println("cursor-rebind prune")
fmt.Println("===================")
if len(res.Candidates) == 0 {
fmt.Println("Nothing to prune.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "KIND\tSAFE\tLABEL\tPATH")
for _, c := range res.Candidates {
safe := "no"
if c.Safe {
safe = "yes"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Kind, safe, c.Label, c.Path)
}
_ = w.Flush()
fmt.Println()
if dryRun {
fmt.Printf("%d candidate(s). Dry-run only — quit Cursor, then: cursor-rebind prune --yes\n", len(res.Candidates))
return nil
}
fmt.Printf("Removed %d path(s).\n", len(res.Removed))
if res.BackupID != "" {
fmt.Printf("Backup: %s (cursor-rebind restore %s)\n", res.BackupID, res.BackupID)
}
return nil
}

func runDoctor(args []string) error {
asJSON := false
var pathArg string
Expand Down Expand Up @@ -325,6 +462,14 @@ func absPath(p string) string {
return abs
}

// requireFolderPath rejects workspace-storage hashes pasted into --from/--to/--path.
func requireFolderPath(flag, value string) error {
if paths.LooksLikeWorkspaceID(value) {
return paths.ErrWorkspaceIDAsPath(flag, value)
}
return nil
}

func runPlan(cmd string, args []string) error {
f, _, err := parsePathFlags(args)
if err != nil {
Expand All @@ -337,6 +482,12 @@ func runPlan(cmd string, args []string) error {
if f.from == "" || f.to == "" {
return fmt.Errorf("%s requires --from and --to", cmd)
}
if err := requireFolderPath("--from", f.from); err != nil {
return err
}
if err := requireFolderPath("--to", f.to); err != nil {
return err
}
f.from, f.to = absPath(f.from), absPath(f.to)

roots, err := paths.Discover()
Expand Down Expand Up @@ -377,6 +528,12 @@ func runMigrate(args []string) error {
if f.from == "" || f.to == "" {
return fmt.Errorf("migrate requires --from and --to")
}
if err := requireFolderPath("--from", f.from); err != nil {
return err
}
if err := requireFolderPath("--to", f.to); err != nil {
return err
}
if f.dryRun && f.yes {
return fmt.Errorf("use either --dry-run or --yes, not both")
}
Expand Down Expand Up @@ -436,6 +593,14 @@ func runRepair(args []string) error {
if f.to == "" {
return fmt.Errorf("repair requires --to")
}
if err := requireFolderPath("--to", f.to); err != nil {
return err
}
if f.from != "" {
if err := requireFolderPath("--from", f.from); err != nil {
return err
}
}
if !f.yes {
return fmt.Errorf("repair requires --yes (quit Cursor first)")
}
Expand Down
Loading
Loading