From 648aa6708555b62252ca856b20147695c01781b0 Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:36:49 +0800 Subject: [PATCH] feat: ccswitch doctor checks the whole setup in one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One read-only command that answers "why isn't this working?": the claude binary on PATH, environment variables that would override the login or the profiles, a second ccswitch install shadowing the running one (Scoop shims excepted — a shim never resolves to the binary it launches), ~/.ccswitch inside a cloud-synced folder, state.json and the shared defaults parsing, every profile's settings.json / .claude.json / credentials, two profiles signed in to the same account, and links whose profile or directory is gone. Strictly read-only — even state.json is read directly rather than through loadState, whose corrupt-file quarantine is a side effect a diagnostic shouldn't have. Exit code 1 when a real problem is found, so scripts can gate on it. --- README.md | 33 ++++ cmd/ccswitch/complete.go | 4 +- cmd/ccswitch/doctor.go | 338 ++++++++++++++++++++++++++++++++++++ cmd/ccswitch/doctor_test.go | 47 +++++ cmd/ccswitch/main.go | 3 + 5 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 cmd/ccswitch/doctor.go create mode 100644 cmd/ccswitch/doctor_test.go diff --git a/README.md b/README.md index 7daba1c..54e340f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ cd claude-code-profiles | `ccswitch list` | print profiles | | `ccswitch current` | print the profile this shell is set to | | `ccswitch where work` | print a profile's config directory | +| `ccswitch doctor` | check the whole setup and say what to fix (see below) | | `ccswitch upgrade` | update ccswitch to the latest release | **First run:** press `i` to import the account you're already logged into (this @@ -269,6 +270,38 @@ only the keys you name are touched: everything else in each profile's alone. If a profile's `settings.json` can't be parsed, ccswitch says so and writes nothing rather than replacing it. +### When something's off: `ccswitch doctor` + +`ccswitch doctor` answers "why isn't this working?" in one pass — one line per +check, each either fine or telling you what to do about it: + +``` +ccswitch 0.1.7 on windows/amd64 + + ok binary — ~\bin\ccswitch.exe + ok claude — ~\AppData\Roaming\npm\claude.cmd + warn environment — ANTHROPIC_API_KEY is set — ccswitch strips it at launch, but a plain `claude` will bill the key instead of using your login + ok storage — ~\.ccswitch + ok state — ~\.ccswitch\state.json + ok work — you@work.com, signed in + FAIL personal — settings.json does not parse — fix it by hand; ccswitch won't touch a file it can't read + +1 problem, 1 warning. +``` + +It covers the claude binary being on PATH, environment variables that would +override your login or profiles, a second ccswitch install shadowing the one +you're running, `~/.ccswitch` sitting inside a cloud-synced folder (profiles +hold live OAuth tokens), our own state and shared-settings files, every +profile's `settings.json` / `.claude.json` / credentials parsing, two profiles +signed in to the same account (one set of rate limits — `--best` would count it +twice), and directory links pointing at profiles or directories that no longer +exist. + +It's strictly read-only: nothing is repaired, moved or rewritten, so you can +run it while deciding what to do. Exit code 1 when it finds a real problem, so +scripts can gate on it. + ## How it works Claude Code keeps everything — credentials, `settings.json`, MCP servers, diff --git a/cmd/ccswitch/complete.go b/cmd/ccswitch/complete.go index 886d088..316fae4 100644 --- a/cmd/ccswitch/complete.go +++ b/cmd/ccswitch/complete.go @@ -98,8 +98,8 @@ func splitLine(line string) []string { // only, so the list reads like the help text rather than every alias. var completeCommands = []string{ "run", "use", "usage", "sync", "defaults", "link", "unlink", "links", "new", - "import", "rename", "rm", "list", "current", "where", "init", "upgrade", - "version", "help", + "import", "rename", "rm", "list", "current", "where", "init", "doctor", + "upgrade", "version", "help", } var completeFlags = map[string][]string{ diff --git a/cmd/ccswitch/doctor.go b/cmd/ccswitch/doctor.go new file mode 100644 index 0000000..6c90aef --- /dev/null +++ b/cmd/ccswitch/doctor.go @@ -0,0 +1,338 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// ccswitch doctor answers "why isn't this working?" in one pass: the claude +// binary, the environment, our own files, every profile's config, and the +// directory links — each as one line saying it's fine or what to do about it. +// It is strictly read-only. Nothing is repaired, moved aside or rewritten, +// because half the point of a diagnostic is that you can run it while deciding +// what to do; even state.json is read directly here rather than through +// loadState, which quarantines a corrupt file as a side effect. + +// checkup collects the verdicts. Problems are things that break a command +// (exit code 1); warnings are things that will bite later; notes are context. +type checkup struct { + problems, warnings int +} + +func (c *checkup) ok(what, detail string) { printCheck("ok", what, detail) } +func (c *checkup) note(what, detail string) { printCheck("--", what, detail) } +func (c *checkup) warn(what, detail string) { + c.warnings++ + printCheck("warn", what, detail) +} +func (c *checkup) fail(what, detail string) { + c.problems++ + printCheck("FAIL", what, detail) +} + +func printCheck(mark, what, detail string) { + fmt.Printf(" %-5s %s — %s\n", mark, what, detail) +} + +func cmdDoctor(args []string) error { + if len(args) > 0 { + return fmt.Errorf("usage: ccswitch doctor") + } + kickUpdateCheck() + fmt.Printf("ccswitch %s on %s/%s\n\n", version, runtime.GOOS, runtime.GOARCH) + + c := &checkup{} + + // List first: the environment and link checks both need to know which + // profiles exist. If the profiles directory itself can't be read there is + // nothing meaningful to check against, so say so and stop. + ps, listErr := List() + if listErr != nil { + c.fail("profiles", listErr.Error()) + } + + checkBinary(c) + checkClaude(c) + checkEnv(c, ps) + checkRoot(c) + checkState(c) + checkDefaultsFile(c, ps) + if listErr == nil { + checkProfiles(c, ps) + checkLinks(c, ps) + } + + fmt.Println() + switch { + case c.problems == 0 && c.warnings == 0: + fmt.Println("Everything looks fine.") + case c.problems == 0: + fmt.Printf("%s — nothing broken, but worth a look.\n", plural(c.warnings, "warning")) + default: + fmt.Printf("%s, %s.\n", plural(c.problems, "problem"), plural(c.warnings, "warning")) + } + printUpdateNudges() + + if c.problems > 0 { + return fmt.Errorf("found %s", plural(c.problems, "problem")) + } + return nil +} + +// checkBinary reports which ccswitch is running, who owns it, and whether the +// name `ccswitch` on PATH would start a different copy — the classic two-install +// drift where you upgrade one and keep running the other. +func checkBinary(c *checkup) { + self, err := selfPath() + if err != nil { + c.warn("binary", err.Error()) + return + } + if name, hint := managedInstaller(self, scoopRootsFromEnv()); name != "" { + c.ok("binary", fmt.Sprintf("%s — installed by %s (update with: %s)", shortPath(self), name, hint)) + } else { + c.ok("binary", shortPath(self)) + } + + onPath, err := exec.LookPath("ccswitch") + if err != nil { + c.note("PATH", "ccswitch itself isn't on PATH — you're running it by full path") + return + } + resolved, err := filepath.EvalSymlinks(onPath) + if err != nil { + return + } + if !sameDir(resolved, self) && !isLauncherShim(resolved, scoopRootsFromEnv()) { + c.warn("PATH", fmt.Sprintf("`ccswitch` resolves to %s, not the copy you're running — two installs drift apart", shortPath(resolved))) + } +} + +// isLauncherShim reports whether path is a package manager's launcher rather +// than the binary itself. A Scoop shim is a real .exe that execs the app from +// apps//current, so it never EvalSymlinks-resolves to the running binary +// — comparing them raw would warn on every healthy Scoop install. +func isLauncherShim(path string, scoopRoots []string) bool { + p := normInstallPath(path) + if strings.Contains(p, "/scoop/shims/") { + return true + } + for _, r := range scoopRoots { + if r = normInstallPath(r); r != "" && strings.HasPrefix(p, r+"/shims/") { + return true + } + } + return false +} + +func checkClaude(c *checkup) { + bin, err := ClaudeBinary() + if err != nil { + c.fail("claude", err.Error()) + return + } + c.ok("claude", shortPath(bin)) +} + +func checkEnv(c *checkup, ps []Profile) { + clean := true + if k := ApiKeyInEnv(); k != "" { + clean = false + c.warn("environment", k+" is set — ccswitch strips it at launch, but a plain `claude` will bill the key instead of using your login") + } + if dir := ActiveConfigDir(); dir != "" { + clean = false + if name := ActiveProfileName(ps); name != "" { + c.ok("environment", "this shell is pinned to "+name+" (CLAUDE_CONFIG_DIR)") + } else { + c.warn("environment", fmt.Sprintf("CLAUDE_CONFIG_DIR points at %s — not a ccswitch profile, so `claude` here uses none of them", shortPath(dir))) + } + } + if clean { + c.ok("environment", "nothing overriding") + } +} + +func checkRoot(c *checkup) { + root, err := Root() + if err != nil { + c.fail("storage", err.Error()) + return + } + if m := cloudSyncMarker(root); m != "" { + c.warn("storage", fmt.Sprintf("%s sits inside %s — profiles hold live OAuth tokens and shouldn't sync to cloud storage", shortPath(root), m)) + return + } + c.ok("storage", shortPath(root)) +} + +// cloudSyncMarker names the sync service a path lives under, or "" for a local +// one. "Mobile Documents" is where macOS keeps iCloud Drive. +func cloudSyncMarker(path string) string { + p := strings.ToLower(filepath.ToSlash(path)) + for _, m := range []struct{ needle, name string }{ + {"onedrive", "OneDrive"}, + {"dropbox", "Dropbox"}, + {"google drive", "Google Drive"}, + {"googledrive", "Google Drive"}, + {"mobile documents", "iCloud Drive"}, + } { + if strings.Contains(p, m.needle) { + return m.name + } + } + return "" +} + +func checkState(c *checkup) { + path, err := statePath() + if err != nil { + c.fail("state", err.Error()) + return + } + b, err := os.ReadFile(path) + switch { + case os.IsNotExist(err): + c.ok("state", "none yet") + case err != nil: + c.fail("state", fmt.Sprintf("%s is unreadable — %v", shortPath(path), err)) + case json.Unmarshal(b, &state{}) != nil: + c.fail("state", fmt.Sprintf("%s does not parse — the next ccswitch command will move it aside as state.json.corrupt", shortPath(path))) + default: + c.ok("state", shortPath(path)) + } + + if _, err := os.Stat(path + ".corrupt"); err == nil { + c.warn("state", fmt.Sprintf("%s.corrupt exists — an earlier state file was unreadable; recover your links from it or delete it", shortPath(path))) + } +} + +func checkDefaultsFile(c *checkup, ps []Profile) { + shared, err := loadDefaults() + if err != nil { + c.fail("defaults", err.Error()) + return + } + if len(shared) == 0 { + return // opt-in and unset — nothing to check, nothing to say + } + stale := 0 + for _, p := range ps { + // An unreadable settings.json is reported per profile below; here it + // just can't tell us about drift. + if drift, err := defaultsDrift(shared, p); err == nil && len(drift) > 0 { + stale++ + } + } + if stale > 0 { + c.note("defaults", fmt.Sprintf("%s shared, %s out of step — ccswitch defaults apply (or just launch them)", plural(len(shared), "key"), plural(stale, "profile"))) + return + } + c.ok("defaults", fmt.Sprintf("%s shared, every profile up to date", plural(len(shared), "key"))) +} + +func checkProfiles(c *checkup, ps []Profile) { + if len(ps) == 0 { + c.note("profiles", "none yet — run ccswitch and press i to import your current login") + return + } + for _, p := range ps { + checkProfile(c, p) + } + checkDuplicateLogins(c, ps) +} + +func checkProfile(c *checkup, p Profile) { + broken := false + + // The two JSON files Claude Code itself reads on startup. A parse error in + // either breaks that profile's sessions, not just our display of them. + for _, f := range []string{settingsFile, ".claude.json"} { + b, err := readFileIfPresent(filepath.Join(p.Dir, f)) + switch { + case err != nil: + broken = true + c.fail(p.Name, fmt.Sprintf("%s is unreadable — %v", f, err)) + case len(b) > 0 && !json.Valid(b): + broken = true + c.fail(p.Name, fmt.Sprintf("%s does not parse — fix it by hand; ccswitch won't touch a file it can't read", f)) + } + } + if _, err := readCredentials(p.Dir); err != nil && !os.IsNotExist(err) { + broken = true + c.fail(p.Name, fmt.Sprintf(".credentials.json is unreadable — sign in again with: ccswitch run %s", p.Name)) + } + if broken { + return + } + + if !p.SignedIn { + c.ok(p.Name, fmt.Sprintf("not signed in yet — sign in with: ccswitch run %s", p.Name)) + return + } + status, _ := p.Status() + c.ok(p.Name, p.Label()+", "+status) +} + +// checkDuplicateLogins flags two profiles signed in to the same account. It +// works, but the isolation is an illusion: they share one set of rate limits, +// and `run --best` scores the same headroom twice. +func checkDuplicateLogins(c *checkup, ps []Profile) { + byEmail := map[string][]string{} + for _, p := range ps { + if p.SignedIn && p.Email != "" { + k := strings.ToLower(p.Email) + byEmail[k] = append(byEmail[k], p.Name) + } + } + emails := make([]string, 0, len(byEmail)) + for e, names := range byEmail { + if len(names) > 1 { + emails = append(emails, e) + } + } + sort.Strings(emails) + for _, e := range emails { + c.warn("accounts", fmt.Sprintf("%s share one login (%s) — one set of rate limits, and --best counts it twice", strings.Join(byEmail[e], " and "), e)) + } +} + +func checkLinks(c *checkup, ps []Profile) { + links := LinkList() + if len(links) == 0 { + return + } + exists := map[string]bool{} + for _, p := range ps { + exists[strings.ToLower(p.Name)] = true + } + good := 0 + for _, l := range links { + switch { + case !exists[strings.ToLower(l.Profile)]: + c.warn("links", fmt.Sprintf("%s points at %q, which doesn't exist — relink it, or drop it with: ccswitch unlink %q", shortPath(l.Dir), l.Profile, l.Dir)) + case !dirExists(l.Dir): + c.warn("links", fmt.Sprintf("%s no longer exists (was linked to %s) — drop it with: ccswitch unlink %q", shortPath(l.Dir), l.Profile, l.Dir)) + default: + good++ + } + } + if good == len(links) { + detail := fmt.Sprintf("%d linked directories, all resolve", good) + if good == 1 { + detail = "1 linked directory, resolves fine" + } + c.ok("links", detail) + } +} + +func dirExists(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} diff --git a/cmd/ccswitch/doctor_test.go b/cmd/ccswitch/doctor_test.go new file mode 100644 index 0000000..cb959e0 --- /dev/null +++ b/cmd/ccswitch/doctor_test.go @@ -0,0 +1,47 @@ +package main + +import "testing" + +func TestCloudSyncMarker(t *testing.T) { + cases := []struct { + path, want string + }{ + {`C:\Users\ryan\.ccswitch`, ""}, + {`/home/ryan/.ccswitch`, ""}, + {`C:\Users\ryan\OneDrive\.ccswitch`, "OneDrive"}, + {`C:\Users\ryan\onedrive - Contoso\.ccswitch`, "OneDrive"}, + {`/Users/ryan/Dropbox/.ccswitch`, "Dropbox"}, + {`C:\Users\ryan\Google Drive\.ccswitch`, "Google Drive"}, + {`/Users/ryan/Library/Mobile Documents/.ccswitch`, "iCloud Drive"}, + } + for _, c := range cases { + if got := cloudSyncMarker(c.path); got != c.want { + t.Errorf("cloudSyncMarker(%q) = %q, want %q", c.path, got, c.want) + } + } +} + +func TestIsLauncherShim(t *testing.T) { + cases := []struct { + path string + roots []string + want bool + }{ + // Default Scoop layout, either slash direction. + {`C:\Users\ryan\scoop\shims\ccswitch.exe`, nil, true}, + {`C:/Users/ryan/scoop/shims/ccswitch.exe`, nil, true}, + // A relocated root only counts via the env-provided roots. + {`D:\tools\shims\ccswitch.exe`, nil, false}, + {`D:\tools\shims\ccswitch.exe`, []string{`D:\tools`}, true}, + // The app's real binary is not a shim. + {`C:\Users\ryan\scoop\apps\ccswitch\current\ccswitch.exe`, nil, false}, + // A directory merely named shims elsewhere doesn't match. + {`C:\shims\ccswitch.exe`, nil, false}, + {`C:\shims\ccswitch.exe`, []string{""}, false}, + } + for _, c := range cases { + if got := isLauncherShim(c.path, c.roots); got != c.want { + t.Errorf("isLauncherShim(%q, %v) = %v, want %v", c.path, c.roots, got, c.want) + } + } +} diff --git a/cmd/ccswitch/main.go b/cmd/ccswitch/main.go index bb42e51..287e40e 100644 --- a/cmd/ccswitch/main.go +++ b/cmd/ccswitch/main.go @@ -54,6 +54,7 @@ const usage = `ccswitch — run Claude Code as any of your accounts, without log ccswitch list print profiles ccswitch current print the profile this shell is set to ccswitch where print a profile's config directory + ccswitch doctor check the setup end to end and say what to fix ccswitch upgrade update ccswitch to the latest release ccswitch version ccswitch help @@ -116,6 +117,8 @@ func run(args []string) error { return cmdCurrent() case "where": return cmdWhere(args[1:]) + case "doctor", "check": + return cmdDoctor(args[1:]) case "upgrade", "self-update": return cmdUpgrade() case "version", "--version", "-v":