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
17 changes: 17 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions cmd/ccswitch/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
23 changes: 13 additions & 10 deletions cmd/ccswitch/launch.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -45,23 +46,25 @@ 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
cmd.Stderr = os.Stderr
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 {
Expand Down
14 changes: 14 additions & 0 deletions cmd/ccswitch/launch_other.go
Original file line number Diff line number Diff line change
@@ -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...)
}
30 changes: 30 additions & 0 deletions cmd/ccswitch/launch_windows.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 21 additions & 1 deletion cmd/ccswitch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"strings"

tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -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":
Expand All @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 39 additions & 7 deletions cmd/ccswitch/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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()
}

Expand All @@ -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()
}
Expand Down
103 changes: 103 additions & 0 deletions cmd/ccswitch/store_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading