From 53609b9348aed89843ae98775a57819d1729a323 Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:28:15 +0800 Subject: [PATCH 1/6] fix: stop sync and defaults from truncating config they could not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readFileOrEmpty returned nil on any read error, not just a missing file, so a target .claude.json or settings.json that exists but momentarily can't be read (AV scan, ACL mishap) was treated as empty — and the merge then rewrote the file with only the merged keys, destroying the account's identity block or hand-edited settings. The renamed readFileIfPresent treats only ErrNotExist as empty and surfaces every other error, which now aborts the item instead of writing. --- cmd/ccswitch/defaults.go | 12 ++++++++++-- cmd/ccswitch/sync.go | 35 +++++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/cmd/ccswitch/defaults.go b/cmd/ccswitch/defaults.go index b7661a6..dae1a74 100644 --- a/cmd/ccswitch/defaults.go +++ b/cmd/ccswitch/defaults.go @@ -129,7 +129,11 @@ func ApplyDefaults(p Profile) ([]string, error) { } path := filepath.Join(p.Dir, settingsFile) - out, changed, err := mergeDefaults(shared, readFileOrEmpty(path)) + cur, err := readFileIfPresent(path) + if err != nil { + return nil, fmt.Errorf("%s: %s: %w", p.Name, settingsFile, err) + } + out, changed, err := mergeDefaults(shared, cur) if err != nil { // A settings.json we can't parse is someone's hand-edit in progress: // say so and change nothing, rather than replace it with our own idea @@ -148,7 +152,11 @@ func ApplyDefaults(p Profile) ([]string, error) { // defaultsDrift reports which shared keys a profile doesn't match yet, without // writing anything. func defaultsDrift(shared map[string]json.RawMessage, p Profile) ([]string, error) { - _, changed, err := mergeDefaults(shared, readFileOrEmpty(filepath.Join(p.Dir, settingsFile))) + cur, err := readFileIfPresent(filepath.Join(p.Dir, settingsFile)) + if err != nil { + return nil, err + } + _, changed, err := mergeDefaults(shared, cur) return changed, err } diff --git a/cmd/ccswitch/sync.go b/cmd/ccswitch/sync.go index 0c353a9..d72fcc8 100644 --- a/cmd/ccswitch/sync.go +++ b/cmd/ccswitch/sync.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -297,8 +298,15 @@ func printSyncPlan(src Profile, targets []Profile, changes []change, warnings [] // no such item, or the target already matches. func planItem(it syncItem, src, dst Profile) (string, error) { if it.Name == "mcp" { - _, n, err := mergeMCP(readFileOrEmpty(filepath.Join(src.Dir, claudeJSONFile)), - readFileOrEmpty(filepath.Join(dst.Dir, claudeJSONFile))) + srcJSON, err := readFileIfPresent(filepath.Join(src.Dir, claudeJSONFile)) + if err != nil { + return "", err + } + dstJSON, err := readFileIfPresent(filepath.Join(dst.Dir, claudeJSONFile)) + if err != nil { + return "", err + } + _, n, err := mergeMCP(srcJSON, dstJSON) if err != nil || n == 0 { return "", err } @@ -345,8 +353,15 @@ func planItem(it syncItem, src, dst Profile) (string, error) { func applyItem(it syncItem, src, dst Profile) error { if it.Name == "mcp" { dstPath := filepath.Join(dst.Dir, claudeJSONFile) - out, n, err := mergeMCP(readFileOrEmpty(filepath.Join(src.Dir, claudeJSONFile)), - readFileOrEmpty(dstPath)) + srcJSON, err := readFileIfPresent(filepath.Join(src.Dir, claudeJSONFile)) + if err != nil { + return err + } + dstJSON, err := readFileIfPresent(dstPath) + if err != nil { + return err + } + out, n, err := mergeMCP(srcJSON, dstJSON) if err != nil || n == 0 { return err } @@ -410,12 +425,16 @@ func sameContents(src, dst string) (bool, error) { return bytes.Equal(a, b), nil } -func readFileOrEmpty(path string) []byte { +// readFileIfPresent returns the file's bytes, or nil for a file that simply +// doesn't exist. Any other read failure is an error the caller must stop on: +// merges treat nil as "start from empty", and rewriting a file that exists but +// couldn't be read would replace an account's config with only the merged keys. +func readFileIfPresent(path string) ([]byte, error) { b, err := os.ReadFile(path) - if err != nil { - return nil + if errors.Is(err, os.ErrNotExist) { + return nil, nil } - return b + return b, err } // --------------------------------------------------------------------------- From c890723c9b2f739e8c1209898b93b2a3cdb4995a Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:28:15 +0800 Subject: [PATCH 2/6] fix: preserve corrupt state, save atomically, refuse case-collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three profile-store fixes: - A state.json that fails to parse was silently discarded, and the next TouchProfile overwrote it — permanently losing every directory link. It is now moved aside to state.json.corrupt with a warning, and saves go through write-then-rename so a crash mid-save can't truncate the file in the first place. - Create and Rename checked for collisions with exact-case os.Stat while Find resolves case-insensitively, so Linux could hold both 'work' and 'Work' — ambiguous everywhere a name is looked up — and Windows wrongly rejected a case-only rename. Both now collide the way Find resolves, and case-only renames of the same profile are allowed. - Rename and Delete keyed last-used state by the caller's spelling of the name rather than the resolved profile name, dropping recency on a case-insensitive rename and leaving stale entries behind. --- cmd/ccswitch/store.go | 46 ++++++++++++++--- cmd/ccswitch/store_test.go | 103 +++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 cmd/ccswitch/store_test.go diff --git a/cmd/ccswitch/store.go b/cmd/ccswitch/store.go index 8c7dd62..838822d 100644 --- a/cmd/ccswitch/store.go +++ b/cmd/ccswitch/store.go @@ -124,7 +124,14 @@ func loadState() state { if err != nil { return st } - _ = json.Unmarshal(b, &st) + if err := json.Unmarshal(b, &st); err != nil { + // A mangled state file must not be silently replaced by the next + // save — it holds every directory link. Put it aside for recovery + // and say so; starting fresh is then an honest choice, not a leak. + _ = os.Rename(p, p+".corrupt") + fmt.Fprintf(os.Stderr, "ccswitch: could not read %s (%v) — moved aside to state.json.corrupt\n", p, err) + return state{LastUsed: map[string]time.Time{}} + } if st.LastUsed == nil { st.LastUsed = map[string]time.Time{} } @@ -143,7 +150,13 @@ func (s state) save() error { if err != nil { return err } - return os.WriteFile(p, b, 0o600) + // Write-then-rename, so a crash mid-save can never leave a truncated + // state.json for loadState to declare corrupt. + tmp := p + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, p) } func TouchProfile(name string) { @@ -305,6 +318,12 @@ func Create(name string) (Profile, error) { return Profile{}, err } target := filepath.Join(dir, name) + // Find matches case-insensitively — exactly how run/use/link resolve a + // name — so two profiles differing only in case would be ambiguous + // everywhere. Refuse them even where the filesystem would allow it. + if p, err := Find(name); err == nil { + return Profile{}, fmt.Errorf("profile %q already exists", p.Name) + } if _, err := os.Stat(target); err == nil { return Profile{}, fmt.Errorf("profile %q already exists", name) } @@ -324,7 +343,8 @@ func Delete(name string) error { } dropLinksTo(p.Name) st := loadState() - delete(st.LastUsed, name) + // p.Name, not the caller's spelling: Find resolved case-insensitively. + delete(st.LastUsed, p.Name) return st.save() } @@ -340,18 +360,30 @@ func Rename(old, newName string) error { if err != nil { return err } + if p.Name == newName { + return fmt.Errorf("profile is already named %q", newName) + } target := filepath.Join(dir, newName) - if _, err := os.Stat(target); err == nil { - return fmt.Errorf("profile %q already exists", newName) + // A case-only rename of the same profile is legitimate; anything else + // that resolves to an existing profile (case-insensitively, like Find) + // is a collision — including on filesystems that would permit it. + if !strings.EqualFold(p.Name, newName) { + if other, err := Find(newName); err == nil { + return fmt.Errorf("profile %q already exists", other.Name) + } + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("profile %q already exists", newName) + } } if err := os.Rename(p.Dir, target); err != nil { return err } renameLinksTo(p.Name, newName) st := loadState() - if t, ok := st.LastUsed[old]; ok { + // p.Name, not the caller's spelling: Find resolved case-insensitively. + if t, ok := st.LastUsed[p.Name]; ok { st.LastUsed[newName] = t - delete(st.LastUsed, old) + delete(st.LastUsed, p.Name) } return st.save() } diff --git a/cmd/ccswitch/store_test.go b/cmd/ccswitch/store_test.go new file mode 100644 index 0000000..51ad7fa --- /dev/null +++ b/cmd/ccswitch/store_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// A mangled state.json holds every directory link; it must be preserved for +// recovery, not silently replaced by the next save. +func TestLoadStatePreservesCorruptFile(t *testing.T) { + home := withHome(t) + path := filepath.Join(home, ".ccswitch", "state.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"lastUsed": {"work"`), 0o600); err != nil { + t.Fatal(err) + } + + st := loadState() + if len(st.LastUsed) != 0 || len(st.Links) != 0 { + t.Errorf("corrupt state must load as empty, got %+v", st) + } + if _, err := os.Stat(path + ".corrupt"); err != nil { + t.Error("the corrupt file must be moved aside, not left to be overwritten") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("the corrupt file must no longer sit where the next save lands") + } +} + +func TestStateSaveRoundTrips(t *testing.T) { + withHome(t) + st := loadState() + st.LastUsed["work"] = time.Now() + if err := st.save(); err != nil { + t.Fatalf("save: %v", err) + } + if _, ok := loadState().LastUsed["work"]; !ok { + t.Error("saved state must load back") + } +} + +// Find resolves names case-insensitively, so profiles differing only in case +// would be ambiguous everywhere — Create must refuse them even on filesystems +// that would happily hold both directories. +func TestCreateRejectsCaseCollision(t *testing.T) { + withHome(t, "work") + if _, err := Create("Work"); err == nil { + t.Error("creating a case-variant of an existing profile must fail") + } + if _, err := Create("side"); err != nil { + t.Errorf("an unrelated name must still create: %v", err) + } +} + +// Rename resolves the old name case-insensitively, and must migrate the +// last-used timestamp under the profile's real name, not the caller's spelling. +func TestRenameMigratesLastUsed(t *testing.T) { + withHome(t, "work") + TouchProfile("work") + + if err := Rename("WORK", "job"); err != nil { + t.Fatalf("Rename: %v", err) + } + st := loadState() + if _, ok := st.LastUsed["job"]; !ok { + t.Error("the new name must carry the old last-used timestamp") + } + if _, ok := st.LastUsed["work"]; ok { + t.Error("the old name must not linger in state") + } +} + +func TestRenameCaseOnly(t *testing.T) { + withHome(t, "work") + if err := Rename("work", "Work"); err != nil { + t.Fatalf("a case-only rename of the same profile must be allowed: %v", err) + } + p, err := Find("work") + if err != nil { + t.Fatal(err) + } + if p.Name != "Work" { + t.Errorf("profile is named %q, want %q", p.Name, "Work") + } +} + +func TestReadFileIfPresent(t *testing.T) { + dir := t.TempDir() + if b, err := readFileIfPresent(filepath.Join(dir, "absent.json")); b != nil || err != nil { + t.Errorf("a missing file is empty, not an error: %v %v", b, err) + } + path := filepath.Join(dir, "present.json") + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if b, err := readFileIfPresent(path); err != nil || string(b) != "{}" { + t.Errorf("an existing file must read back: %q %v", b, err) + } +} From a332fb063035031b9bf3049dd02891f9b539ff52 Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:28:15 +0800 Subject: [PATCH 3/6] fix: quote claude.cmd launches the way cmd.exe actually parses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing the npm claude.cmd through exec.Command("cmd.exe", "/c", bin, args...) let Go quote each argument CommandLineToArgvW-style, but cmd.exe parses by its own rules: with a quoted path plus one quoted argument it strips the first and last quote on the line and tries to run the path's first word. Reproduced with a batch file under a spaced path — 'ccswitch run work -- -p "two words"' failed to launch. The /s /c "..." idiom hands cmd.exe the whole payload in one outer pair of quotes it strips verbatim; verified against spaced paths, no args, and quoted args. The batch routing now lives in one claudeCommand helper shared by every claude invocation. --- cmd/ccswitch/launch.go | 23 +++++++++++++---------- cmd/ccswitch/launch_other.go | 14 ++++++++++++++ cmd/ccswitch/launch_windows.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 cmd/ccswitch/launch_other.go create mode 100644 cmd/ccswitch/launch_windows.go diff --git a/cmd/ccswitch/launch.go b/cmd/ccswitch/launch.go index a265cad..c07b597 100644 --- a/cmd/ccswitch/launch.go +++ b/cmd/ccswitch/launch.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "os/exec" @@ -45,16 +46,7 @@ func Command(p Profile, args []string) (*exec.Cmd, error) { return nil, err } - var cmd *exec.Cmd - lower := strings.ToLower(bin) - if runtime.GOOS == "windows" && (strings.HasSuffix(lower, ".cmd") || strings.HasSuffix(lower, ".bat")) { - // CreateProcess cannot execute batch files directly; the npm install - // of Claude Code ships claude.cmd, so route those through cmd.exe. - cmd = exec.Command("cmd.exe", append([]string{"/c", bin}, args...)...) - } else { - cmd = exec.Command(bin, args...) - } - + cmd := claudeCommand(context.Background(), bin, args...) cmd.Env = childEnv(p.Dir) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -62,6 +54,17 @@ func Command(p Profile, args []string) (*exec.Cmd, error) { return cmd, nil } +// claudeCommand builds a process running the claude binary. The npm install of +// Claude Code ships claude.cmd on Windows, which needs cmd.exe and its own +// quoting rules — see batchCommand. +func claudeCommand(ctx context.Context, bin string, args ...string) *exec.Cmd { + lower := strings.ToLower(bin) + if runtime.GOOS == "windows" && (strings.HasSuffix(lower, ".cmd") || strings.HasSuffix(lower, ".bat")) { + return batchCommand(ctx, bin, args...) + } + return exec.CommandContext(ctx, bin, args...) +} + func childEnv(dir string) []string { drop := map[string]bool{strings.ToUpper(envOverride): true} for _, k := range keyVars { diff --git a/cmd/ccswitch/launch_other.go b/cmd/ccswitch/launch_other.go new file mode 100644 index 0000000..98b5a36 --- /dev/null +++ b/cmd/ccswitch/launch_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main + +import ( + "context" + "os/exec" +) + +// batchCommand only means something on Windows; elsewhere claude is a real +// executable and this is never reached, but the symbol must still compile. +func batchCommand(ctx context.Context, bin string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, bin, args...) +} diff --git a/cmd/ccswitch/launch_windows.go b/cmd/ccswitch/launch_windows.go new file mode 100644 index 0000000..b6fe828 --- /dev/null +++ b/cmd/ccswitch/launch_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "context" + "os/exec" + "strings" + "syscall" +) + +// batchCommand runs a .cmd/.bat through cmd.exe — CreateProcess cannot execute +// batch files directly. Letting exec.Command quote the arguments is not enough: +// cmd.exe parses its command line by its own rules, and with `/c "path" args...` +// it strips the first and last quote on the line, so a spaced install path plus +// one quoted argument breaks the launch. The `/s /c "..."` idiom hands cmd.exe +// the whole payload in one outer pair of quotes, which /s makes it strip +// verbatim. (Literal `"` or `%` inside an argument still degrades — that is +// batch's own limit, not a quoting bug here.) +func batchCommand(ctx context.Context, bin string, args ...string) *exec.Cmd { + parts := []string{syscall.EscapeArg(bin)} + for _, a := range args { + parts = append(parts, syscall.EscapeArg(a)) + } + cmd := exec.CommandContext(ctx, "cmd.exe") + cmd.SysProcAttr = &syscall.SysProcAttr{ + CmdLine: `cmd.exe /s /c "` + strings.Join(parts, " ") + `"`, + } + return cmd +} From 29be6d4f97bf134f4a21063f48fbafabcdff5773 Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:28:30 +0800 Subject: [PATCH 4/6] fix: watch refresh accounting and the --every error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refresh while a previous batch's replies were still in flight let the stale replies decrement the new batch's pending counter, so the footer said 'updated' while rows still showed 'checking'. Fetches now carry a generation stamp; stale replies still fill rows in but no longer count the new batch down. The --every guard also said 'capped at 15s minimum' while actually rejecting — it now says what it does ('must be at least 15s'), and a bare '--every=' errors like a missing value instead of being silently ignored. --- cmd/ccswitch/usage.go | 5 ++++- cmd/ccswitch/watch.go | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/ccswitch/usage.go b/cmd/ccswitch/usage.go index a58c313..bce3a6e 100644 --- a/cmd/ccswitch/usage.go +++ b/cmd/ccswitch/usage.go @@ -205,6 +205,9 @@ func cmdUsage(args []string) error { raw, err = next("--every") case strings.HasPrefix(a, "--every="): raw = strings.TrimPrefix(a, "--every=") + if raw == "" { + return fmt.Errorf("--every needs a value") + } case strings.HasPrefix(a, "-"): return fmt.Errorf("unknown flag %q (usage: ccswitch usage [--watch] [--every 60s])", a) default: @@ -221,7 +224,7 @@ func cmdUsage(args []string) error { // The endpoint is undocumented and shared with Claude Code itself; // polling it harder than this is rude and tells you nothing new. if d < watchMinEvery { - return fmt.Errorf("--every is capped at %s minimum", watchMinEvery) + return fmt.Errorf("--every must be at least %s", watchMinEvery) } every = d watch = true diff --git a/cmd/ccswitch/watch.go b/cmd/ccswitch/watch.go index 8434748..2b2ca76 100644 --- a/cmd/ccswitch/watch.go +++ b/cmd/ccswitch/watch.go @@ -133,6 +133,11 @@ type watchModel struct { every time.Duration updated time.Time pending int + // gen stamps each fetch batch. A refresh while replies are still in + // flight starts a new generation; the old batch's replies still fill + // rows in, but only current-generation ones may count pending down — + // otherwise the footer says "updated" while rows are still checking. + gen int step int // mascot animation frame @@ -146,6 +151,7 @@ type watchUsageMsg struct { name string u *Usage err string + gen int } type watchTickMsg time.Time @@ -203,6 +209,7 @@ func (m watchModel) animate() tea.Cmd { // fetched — refreshing is Claude Code's job, not ours. func (m watchModel) fetchAll() tea.Cmd { var cmds []tea.Cmd + gen := m.gen for _, p := range m.profiles { if !p.SignedIn { continue @@ -211,9 +218,9 @@ func (m watchModel) fetchAll() tea.Cmd { cmds = append(cmds, func() tea.Msg { u, err := FetchUsage(p) if err != nil { - return watchUsageMsg{name: p.Name, err: err.Error()} + return watchUsageMsg{name: p.Name, err: err.Error(), gen: gen} } - return watchUsageMsg{name: p.Name, u: u} + return watchUsageMsg{name: p.Name, u: u, gen: gen} }) } return tea.Batch(cmds...) @@ -246,7 +253,7 @@ func (m watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { delete(m.errs, msg.name) alerts = m.checkAlerts(msg.name, msg.u) } - if m.pending > 0 { + if msg.gen == m.gen && m.pending > 0 { m.pending-- } m.updated = time.Now() @@ -260,6 +267,7 @@ func (m watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Pick up profiles added or signed in since the last pass, so the panel // doesn't need restarting after a `ccswitch new`. m.reload() + m.gen++ m.pending = m.countFetchable() return m, tea.Batch(m.fetchAll(), m.tick()) @@ -269,6 +277,7 @@ func (m watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "r": m.reload() + m.gen++ m.pending = m.countFetchable() return m, m.fetchAll() } From 4bd7a8d9cbcaea27fa7323a0dbb538ee31b6364a Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:28:30 +0800 Subject: [PATCH 5/6] feat: nudge when ccswitch or Claude Code has a newer release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither tool announced updates: ccswitch upgrade is manual, and the claude binary's own updater can lag or be disabled. A detached helper (the hidden update-check command) now refreshes a small cache at most once a day — latest ccswitch release, latest Claude Code on the npm registry, and the installed claude --version — and the moments you're back at a prompt print one line per stale tool with the command that updates it. No launch ever waits on the network: nudges read only the cache. CCSWITCH_NO_UPDATE_CHECK=1 silences the whole feature. cmdRun also now ignores Ctrl-C while claude runs: the terminal delivers the interrupt to the whole foreground group, and claude traps it to cancel a response — its wrapper dying instead dropped a shell prompt onto a live TUI and lost the exit-code passthrough. --- cmd/ccswitch/main.go | 22 ++- cmd/ccswitch/updatecheck.go | 222 +++++++++++++++++++++++++++++++ cmd/ccswitch/updatecheck_test.go | 72 ++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 cmd/ccswitch/updatecheck.go create mode 100644 cmd/ccswitch/updatecheck_test.go diff --git a/cmd/ccswitch/main.go b/cmd/ccswitch/main.go index b9816ce..bb42e51 100644 --- a/cmd/ccswitch/main.go +++ b/cmd/ccswitch/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "os/signal" "strings" tea "github.com/charmbracelet/bubbletea" @@ -98,6 +99,9 @@ func run(args []string) error { case "complete": // Internal: called by the snippets `ccswitch init` prints. return cmdComplete(args[1:]) + case "update-check": + // Internal: the detached helper kickUpdateCheck spawns. + return cmdUpdateCheck() case "new", "add", "create": return cmdNew(args[1:]) case "import": @@ -116,6 +120,7 @@ func run(args []string) error { return cmdUpgrade() case "version", "--version", "-v": fmt.Println("ccswitch " + version) + printUpdateNudges() return nil case "help", "--help", "-h": fmt.Print(usage) @@ -126,8 +131,12 @@ func run(args []string) error { } func interactive() error { + kickUpdateCheck() p := tea.NewProgram(newModel(), tea.WithAltScreen()) _, err := p.Run() + // After the alt screen is gone, so the nudge is what's left on the + // terminal rather than something the picker painted over. + printUpdateNudges() return err } @@ -185,7 +194,18 @@ func cmdRun(args []string) error { fmt.Fprintf(os.Stderr, "ccswitch: unsetting %s so the subscription login is used\n", k) } - if err := cmd.Run(); err != nil { + kickUpdateCheck() + // Ctrl-C is Claude Code's to handle: the terminal delivers it to the + // whole foreground group, and claude traps it (to cancel a response) + // while we — its wrapper — would die, dropping a shell prompt on top of + // a still-running TUI and losing the exit-code passthrough below. + signal.Ignore(os.Interrupt) + err = cmd.Run() + signal.Reset(os.Interrupt) + // Claude Code has the terminal for the whole session; the moment it hands + // the prompt back is when a nudge can actually be read. + printUpdateNudges() + if err != nil { // Pass Claude Code's own exit code straight through. var ee *exec.ExitError if ok := asExitError(err, &ee); ok { diff --git a/cmd/ccswitch/updatecheck.go b/cmd/ccswitch/updatecheck.go new file mode 100644 index 0000000..f8b0a80 --- /dev/null +++ b/cmd/ccswitch/updatecheck.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Passive update nudges. Neither ccswitch nor the claude binary it launches +// announces new versions on its own, so a detached helper refreshes a small +// cache at most once a day, and the moments you're back at a prompt — Claude +// Code exiting, the picker closing — print one line per stale tool. Nothing +// ever installs itself, and no launch waits on the network: nudges read only +// the cache, and the refresh runs in a separate process. + +// updateCheckEvery is how stale the cache may get before a refresh is spawned. +const updateCheckEvery = 24 * time.Hour + +// noUpdateCheckEnv silences the whole feature: no refresh, no nudges. +const noUpdateCheckEnv = "CCSWITCH_NO_UPDATE_CHECK" + +// claudeLatestURL is npm's dist-tag endpoint for Claude Code — the registry +// every install method tracks, and a stable one-field JSON answer. +const claudeLatestURL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest" + +type updateCache struct { + CheckedAt time.Time `json:"checked_at"` + CcswitchLatest string `json:"ccswitch_latest"` // without the leading v + ClaudeLatest string `json:"claude_latest"` + ClaudeHave string `json:"claude_installed"` +} + +func updateCachePath() (string, error) { + root, err := Root() + if err != nil { + return "", err + } + return filepath.Join(root, "update-check.json"), nil +} + +func loadUpdateCache() updateCache { + var c updateCache + path, err := updateCachePath() + if err != nil { + return c + } + data, err := os.ReadFile(path) + if err != nil { + return c + } + _ = json.Unmarshal(data, &c) + return c +} + +// kickUpdateCheck spawns the refresh helper if the cache has gone stale. It +// must never delay the caller, so all it does is fork and forget. +func kickUpdateCheck() { + if os.Getenv(noUpdateCheckEnv) != "" { + return + } + if time.Since(loadUpdateCache().CheckedAt) < updateCheckEvery { + return + } + exe, err := os.Executable() + if err != nil { + return + } + startDetached(exec.Command(exe, "update-check")) +} + +// cmdUpdateCheck is the detached helper behind kickUpdateCheck. It records +// whatever it managed to learn and stamps the cache either way, so an offline +// machine retries tomorrow instead of forking on every run. Silent by design: +// its console may be someone else's. +func cmdUpdateCheck() error { + c := loadUpdateCache() + c.CheckedAt = time.Now() + if rel, err := latestRelease(); err == nil { + c.CcswitchLatest = strings.TrimPrefix(rel.TagName, "v") + } + if v, err := npmLatestVersion(claudeLatestURL); err == nil { + c.ClaudeLatest = v + } + if v, err := installedClaudeVersion(); err == nil { + c.ClaudeHave = v + } + + path, err := updateCachePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func npmLatestVersion(url string) (string, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "ccswitch-update-check") + + resp, err := httpClient(30 * time.Second).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("npm registry returned %s", resp.Status) + } + var v struct { + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return "", err + } + if v.Version == "" { + return "", fmt.Errorf("npm registry answer has no version") + } + return v.Version, nil +} + +func installedClaudeVersion() (string, error) { + bin, err := ClaudeBinary() + if err != nil { + return "", err + } + // Generous deadline: `claude --version` boots node, and a cold start on a + // busy machine is slow. The helper is detached, so nobody is waiting. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := claudeCommand(ctx, bin, "--version").Output() + if err != nil { + return "", err + } + v := parseClaudeVersion(string(out)) + if v == "" { + return "", fmt.Errorf("could not find a version in %q", strings.TrimSpace(string(out))) + } + return v, nil +} + +// parseClaudeVersion pulls the version out of `claude --version` output — +// today "2.1.3 (Claude Code)", but any dotted number in any surrounding +// wording will do. +func parseClaudeVersion(out string) string { + for _, f := range strings.Fields(out) { + f = strings.Trim(strings.TrimPrefix(f, "v"), ",;()") + if f == "" || f[0] < '0' || f[0] > '9' || !strings.Contains(f, ".") { + continue + } + return f + } + return "" +} + +// newerVersion reports whether a is a strictly newer dotted version than b. +// Anything it can't parse compares as not-newer: a nudge must never fire on +// garbage. +func newerVersion(a, b string) bool { + if a == "" || b == "" { + return false + } + as, bs := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + av, bv := 0, 0 + var err error + if i < len(as) { + if av, err = strconv.Atoi(as[i]); err != nil { + return false + } + } + if i < len(bs) { + if bv, err = strconv.Atoi(bs[i]); err != nil { + return false + } + } + if av != bv { + return av > bv + } + } + return false +} + +// nudgesFor is the pure half of printUpdateNudges: one line per tool the +// cache says is behind. A tool whose installed version was never read stays +// silent — "you might be out of date" is not a nudge worth interrupting for. +func nudgesFor(c updateCache) []string { + var out []string + if newerVersion(c.CcswitchLatest, version) { + out = append(out, fmt.Sprintf("ccswitch v%s is available (you have v%s) — run: ccswitch upgrade", c.CcswitchLatest, version)) + } + if newerVersion(c.ClaudeLatest, c.ClaudeHave) { + out = append(out, fmt.Sprintf("Claude Code v%s is available (you have v%s) — run: claude update", c.ClaudeLatest, c.ClaudeHave)) + } + return out +} + +// printUpdateNudges reports what the cache already knows, on stderr so it +// never lands in eval'd or piped output. +func printUpdateNudges() { + if os.Getenv(noUpdateCheckEnv) != "" { + return + } + for _, line := range nudgesFor(loadUpdateCache()) { + fmt.Fprintln(os.Stderr, "ccswitch: "+line) + } +} diff --git a/cmd/ccswitch/updatecheck_test.go b/cmd/ccswitch/updatecheck_test.go new file mode 100644 index 0000000..54d0af1 --- /dev/null +++ b/cmd/ccswitch/updatecheck_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "strings" + "testing" +) + +func TestNewerVersion(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"0.1.8", "0.1.7", true}, + {"0.2.0", "0.1.9", true}, + {"1.0.0", "0.9.9", true}, + {"0.1.7", "0.1.7", false}, + {"0.1.6", "0.1.7", false}, + {"0.1.7.1", "0.1.7", true}, // longer wins when the prefix ties + {"0.1.7", "0.1.7.1", false}, + {"0.1.10", "0.1.9", true}, // numeric, not lexicographic + {"", "0.1.7", false}, + {"0.1.8", "", false}, + {"abc", "0.1.7", false}, // garbage never nudges + {"0.1.8", "abc", false}, + } + for _, c := range cases { + if got := newerVersion(c.a, c.b); got != c.want { + t.Errorf("newerVersion(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +func TestParseClaudeVersion(t *testing.T) { + cases := []struct { + out, want string + }{ + {"2.1.3 (Claude Code)", "2.1.3"}, + {"claude, version 1.0.61", "1.0.61"}, + {"v2.0.0", "2.0.0"}, + {"Claude Code", ""}, + {"", ""}, + } + for _, c := range cases { + if got := parseClaudeVersion(c.out); got != c.want { + t.Errorf("parseClaudeVersion(%q) = %q, want %q", c.out, got, c.want) + } + } +} + +func TestNudgesFor(t *testing.T) { + // Everything current: silence. + if n := nudgesFor(updateCache{CcswitchLatest: version, ClaudeLatest: "2.0.0", ClaudeHave: "2.0.0"}); len(n) != 0 { + t.Errorf("up-to-date cache must not nudge, got %v", n) + } + + // Claude Code behind: exactly one nudge, naming both versions. + n := nudgesFor(updateCache{ClaudeLatest: "2.1.0", ClaudeHave: "2.0.0"}) + if len(n) != 1 || !strings.Contains(n[0], "2.1.0") || !strings.Contains(n[0], "2.0.0") { + t.Errorf("stale Claude Code must nudge with both versions, got %v", n) + } + + // A latest with no installed version read stays quiet — nothing to compare. + if n := nudgesFor(updateCache{ClaudeLatest: "9.9.9"}); len(n) != 0 { + t.Errorf("unknown installed version must not nudge, got %v", n) + } + + // Both tools behind: two nudges, ccswitch's naming the upgrade command. + n = nudgesFor(updateCache{CcswitchLatest: "99.0.0", ClaudeLatest: "2.1.0", ClaudeHave: "2.0.0"}) + if len(n) != 2 || !strings.Contains(n[0], "ccswitch upgrade") { + t.Errorf("both stale must yield two nudges, got %v", n) + } +} From c9560b598240ea91a6fcbf0c181f34203262baab Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:28:30 +0800 Subject: [PATCH 6/6] docs: update nudges in the README; enforce line endings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh is piped straight to sh from raw.githubusercontent.com — a CRLF commit would break it with '\r: command not found'. The index is already all-LF; .gitattributes now keeps it that way, with PowerShell scripts as the one CRLF exception. --- .gitattributes | 17 +++++++++++++++++ README.md | 7 +++++++ 2 files changed, 24 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5730bdb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Normalize everything git detects as text to LF in the repo. +* text=auto + +# install.sh is piped straight to `sh` from raw.githubusercontent.com — a CRLF +# checkout or commit would break it with "\r: command not found". Keep every +# shell-adjacent file LF everywhere, even on Windows checkouts. +*.sh text eol=lf +*.go text eol=lf +*.yml text eol=lf +*.md text eol=lf + +# PowerShell is the one place CRLF is at home. +*.ps1 text eol=crlf + +# Never try to diff or convert binaries. +*.png binary +*.exe binary diff --git a/README.md b/README.md index 8c84fd9..574e079 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ isolation is partial (see below). Both installers — and `ccswitch upgrade` — verify what they download against the `SHA256SUMS` manifest published with each release before installing it. +Neither ccswitch nor Claude Code will surprise you with an update, but you +also shouldn't have to go looking: at most once a day, a background check +notes the latest release of each, and the moment you're back at a prompt — +Claude Code exiting, the picker closing — a one-liner tells you what's newer +and the command that updates it. No launch ever waits on the check, and +setting `CCSWITCH_NO_UPDATE_CHECK=1` turns it off entirely. + **Build from source** — needs Go 1.22+, produces one static `.exe`: ```powershell