From f9991102657f5d05adb21f1ad34019e4588f14f7 Mon Sep 17 00:00:00 2001 From: Ryan <150203547+NotAProgrammer187@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:03:44 +0800 Subject: [PATCH] feat: ccswitch statusline puts the account in Claude Code's status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One line shaped for the statusLine setting — profile, model, and both rate-limit windows, amber at 70% and red with the reset time at 90%. Sharing it everywhere is one command: ccswitch defaults set statusLine '{"type":"command","command":"ccswitch statusline"}' The status line runs its command constantly, so this path is a pure read: usage comes from ~/.ccswitch/usage-cache.json, refreshed by a detached helper at most every two minutes (the update-check pattern), profile resolution matches directory names without reading any profile's .claude.json, and even start-up housekeeping is skipped. Sessions launched without ccswitch read the default ~/.claude and label themselves claude. --- README.md | 25 ++- cmd/ccswitch/main.go | 15 +- cmd/ccswitch/statusline.go | 262 ++++++++++++++++++++++++++++++++ cmd/ccswitch/statusline_test.go | 78 ++++++++++ 4 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 cmd/ccswitch/statusline.go create mode 100644 cmd/ccswitch/statusline_test.go diff --git a/README.md b/README.md index 7daba1c..6be98c9 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,30 @@ there instead, fill in `watchArt` in `cmd/ccswitch/watch.go` — one string per line, centred in the accent colour, with any line too wide for the panel dropped rather than wrapped. -### Pin a shell to a profile +### Your account, in Claude Code's status line + +Claude Code's status line can name the account the session is running as — +useful the moment you have more than one. `ccswitch statusline` prints one +line shaped for the `statusLine` setting: profile, model, and both rate-limit +windows, amber at 70% and red (with the reset time) at 90%, the same +thresholds as everywhere else: + +``` +work · Opus 4.5 · 5h 42% · wk 12% +``` + +Wire it into every profile at once — this is exactly what `defaults` is for: + +```powershell +ccswitch defaults set statusLine '{"type":"command","command":"ccswitch statusline"}' +``` + +(Or set that value in one profile's `settings.json` by hand.) The status line +runs the command constantly, so it never touches the network itself: the +numbers come from a small cache that a detached helper refreshes at most every +two minutes, and until the first refresh lands the line simply shows the +profile without numbers. Sessions launched without ccswitch work too — the +line reads the default `~/.claude` and labels itself `claude`. `ccswitch run` launches one session; `ccswitch use` points the shell itself at a profile, so every plain `claude` you type afterwards runs as that account. A diff --git a/cmd/ccswitch/main.go b/cmd/ccswitch/main.go index bb42e51..9df8079 100644 --- a/cmd/ccswitch/main.go +++ b/cmd/ccswitch/main.go @@ -30,6 +30,10 @@ const usage = `ccswitch — run Claude Code as any of your accounts, without log ccswitch usage show each account's rate-limit usage ccswitch usage --watch live usage panel to park beside Claude Code (--every 30s to change the refresh interval) + ccswitch statusline profile + usage as one line, for Claude Code's + statusLine setting — wire it into every profile: + ccswitch defaults set statusLine + '{"type":"command","command":"ccswitch statusline"}' ccswitch sync --from copy shared config (settings, CLAUDE.md, commands, agents, skills, MCP servers) into your other @@ -71,7 +75,11 @@ func main() { } func run(args []string) error { - cleanupOldBinary() + // The status line fires many times a minute from inside Claude Code, so + // that path skips even cheap start-up housekeeping — it must stay a read. + if len(args) == 0 || args[0] != "statusline" { + cleanupOldBinary() + } if len(args) == 0 { return interactive() @@ -84,6 +92,11 @@ func run(args []string) error { return cmdUse(args[1:]) case "usage", "limits": return cmdUsage(args[1:]) + case "statusline": + return cmdStatusline(args[1:]) + case "statusline-refresh": + // Internal: the detached helper cmdStatusline spawns. + return cmdStatuslineRefresh(args[1:]) case "sync": return cmdSync(args[1:]) case "defaults": diff --git a/cmd/ccswitch/statusline.go b/cmd/ccswitch/statusline.go new file mode 100644 index 0000000..73da31b --- /dev/null +++ b/cmd/ccswitch/statusline.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Claude Code's statusLine setting runs a command on every update and shows +// its first stdout line at the bottom of the session. ccswitch already knows +// which account a session is — it set CLAUDE_CONFIG_DIR — and how to read the +// rate-limit windows, so `ccswitch statusline` puts both where you're already +// looking: +// +// work · Opus 4.5 · 5h 42% · wk 12% +// +// The command runs often and must never block the status line on the network, +// so the numbers come from a small cache: statusline itself only reads it, and +// when an entry has gone stale it kicks a detached refresh (the same pattern +// as the update check) whose result shows up a tick or two later. First ever +// call shows just the profile; usage joins it once the first refresh lands. + +const ( + // statuslineStale is how old a cache entry may get before a refresh is + // kicked. The endpoint is undocumented and shared with Claude Code itself; + // a status line doesn't need fresher numbers than the picker shows. + statuslineStale = 2 * time.Minute + // statuslineKickEvery bounds how often a refresh may be spawned while one + // is already in flight — statusline can fire many times a minute, and each + // stale render would otherwise fork another fetch. + statuslineKickEvery = 30 * time.Second +) + +// usageCacheEntry is one config directory's last-known usage. A nil Usage +// with a recent FetchedAt means the last fetch failed and shouldn't be +// retried until the entry is stale again. +type usageCacheEntry struct { + FetchedAt time.Time `json:"fetched_at"` + KickedAt time.Time `json:"kicked_at,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +func usageCachePath() (string, error) { + root, err := Root() + if err != nil { + return "", err + } + return filepath.Join(root, "usage-cache.json"), nil +} + +// loadUsageCache reads the cache, keyed by lower-cased config directory — +// the one identifier that exists whether or not the session is a ccswitch +// profile. A missing or mangled file is an empty cache; it's regenerable. +func loadUsageCache() map[string]usageCacheEntry { + out := map[string]usageCacheEntry{} + p, err := usageCachePath() + if err != nil { + return out + } + b, err := os.ReadFile(p) + if err != nil { + return out + } + _ = json.Unmarshal(b, &out) + if out == nil { + out = map[string]usageCacheEntry{} + } + return out +} + +func saveUsageCache(c map[string]usageCacheEntry) error { + p, err := usageCachePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + // Write-then-rename: statusline and its refresher run concurrently, and a + // torn write would show up as a mangled (silently dropped) cache. + tmp := p + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, p) +} + +func usageCacheKey(dir string) string { + return strings.ToLower(filepath.Clean(dir)) +} + +// statuslineTarget resolves which account this status line describes: the +// ccswitch profile the session's CLAUDE_CONFIG_DIR points at, a foreign +// config directory by its base name, or the default ~/.claude for a session +// launched without ccswitch at all. +func statuslineTarget() (label, dir string) { + if d := ActiveConfigDir(); d != "" { + if n := profileNameForDir(d); n != "" { + return n, d + } + return filepath.Base(d), d + } + d, err := DefaultClaudeDir() + if err != nil { + return "claude", "" + } + return "claude", d +} + +// profileNameForDir matches a config directory against the profiles by name +// alone. Unlike List it reads no per-profile metadata — .claude.json can run +// to megabytes, and this runs on every status line tick. +func profileNameForDir(dir string) string { + base, err := profilesDir() + if err != nil { + return "" + } + entries, err := os.ReadDir(base) + if err != nil { + return "" + } + for _, e := range entries { + if e.IsDir() && !strings.HasPrefix(e.Name(), ".") && + sameDir(filepath.Join(base, e.Name()), dir) { + return e.Name() + } + } + return "" +} + +// statuslineInput is the JSON Claude Code pipes to the statusLine command. +// Only the model name is read; everything else the session shows already. +type statuslineInput struct { + Model struct { + DisplayName string `json:"display_name"` + } `json:"model"` +} + +// readStatuslineModel parses the piped session JSON, and reads nothing at all +// on an interactive terminal — someone trying the command by hand must not +// find it hanging on stdin. +func readStatuslineModel(r io.Reader) string { + var in statuslineInput + data, err := io.ReadAll(io.LimitReader(r, 256*1024)) + if err != nil || json.Unmarshal(data, &in) != nil { + return "" + } + return strings.TrimSpace(in.Model.DisplayName) +} + +// ANSI colours, written directly rather than through lipgloss: the status +// line is not our terminal, and Claude Code renders the escape codes as-is. +const ( + ansiReset = "\x1b[0m" + ansiDim = "\x1b[2m" + ansiYellow = "\x1b[33m" + ansiRed = "\x1b[31m" +) + +// statuslineText renders the one line: label, model if known, then a part per +// rate-limit window — amber from 70%, red from 90%, the thresholds every +// other view uses. Nil usage (not fetched yet, or the fetch failed) renders +// what's left; the line degrades, it never errors. +func statuslineText(label, model string, u *Usage) string { + parts := []string{label} + if model != "" { + parts = append(parts, model) + } + if u != nil { + if w := u.Session; w != nil { + parts = append(parts, statuslineWindow("5h", w)) + } + if w := u.Week; w != nil { + parts = append(parts, statuslineWindow("wk", w)) + } + if w := u.Opus; w != nil && w.Utilization > 0 { + parts = append(parts, statuslineWindow("opus", w)) + } + } + return strings.Join(parts, ansiDim+" · "+ansiReset) +} + +func statuslineWindow(label string, w *UsageWindow) string { + s := fmt.Sprintf("%s %d%%", label, pct(w.Utilization)) + switch { + case w.Utilization >= 90: + s = ansiRed + s + ansiReset + if !w.ResetsAt.IsZero() { + s += ansiDim + " resets " + resetClock(w.ResetsAt) + ansiReset + } + case w.Utilization >= 70: + s = ansiYellow + s + ansiReset + } + return s +} + +// cmdStatusline prints the line and exits. All it may do synchronously is +// read files; anything slower happens in the detached refresher. +func cmdStatusline(args []string) error { + if len(args) > 0 { + return fmt.Errorf("statusline takes no arguments — point Claude Code's statusLine setting at it (see README)") + } + + model := "" + if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice == 0 { + model = readStatuslineModel(os.Stdin) + } + + label, dir := statuslineTarget() + var u *Usage + if dir != "" { + key := usageCacheKey(dir) + cache := loadUsageCache() + entry := cache[key] + u = entry.Usage + if time.Since(entry.FetchedAt) > statuslineStale && + time.Since(entry.KickedAt) > statuslineKickEvery { + // Stamp before spawning, so a burst of statusline calls in the + // same stale window forks one refresher, not one each. + entry.KickedAt = time.Now() + cache[key] = entry + _ = saveUsageCache(cache) + if exe, err := os.Executable(); err == nil { + startDetached(exec.Command(exe, "statusline-refresh", dir)) + } + } + } + + fmt.Println(statuslineText(label, model, u)) + return nil +} + +// cmdStatuslineRefresh is the detached helper behind cmdStatusline: fetch one +// directory's usage, record whatever happened, exit. A failed fetch is +// recorded too — with a fresh FetchedAt and no usage — so the status line +// shows less rather than refetching on every render. Silent by design: its +// console is the session's. +func cmdStatuslineRefresh(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: ccswitch statusline-refresh ") + } + dir := args[0] + u, _ := FetchUsage(Profile{Dir: dir}) + + key := usageCacheKey(dir) + cache := loadUsageCache() + entry := cache[key] + entry.FetchedAt = time.Now() + entry.KickedAt = time.Time{} + entry.Usage = u + cache[key] = entry + return saveUsageCache(cache) +} diff --git a/cmd/ccswitch/statusline_test.go b/cmd/ccswitch/statusline_test.go new file mode 100644 index 0000000..360adc6 --- /dev/null +++ b/cmd/ccswitch/statusline_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestStatuslineText(t *testing.T) { + // No usage yet: just the label (and model), no stray separators. + if got := statuslineText("work", "", nil); got != "work" { + t.Errorf("bare label rendered %q", got) + } + got := statuslineText("work", "Opus 4.5", nil) + if !strings.Contains(got, "work") || !strings.Contains(got, "Opus 4.5") { + t.Errorf("label+model rendered %q", got) + } + + u := &Usage{ + Session: &UsageWindow{Utilization: 42}, + Week: &UsageWindow{Utilization: 12}, + } + got = statuslineText("work", "Opus 4.5", u) + for _, want := range []string{"work", "Opus 4.5", "5h 42%", "wk 12%"} { + if !strings.Contains(got, want) { + t.Errorf("rendered %q, missing %q", got, want) + } + } + + // Opus only earns a slot once it's been used, like every other view. + u.Opus = &UsageWindow{Utilization: 0} + if got := statuslineText("w", "", u); strings.Contains(got, "opus") { + t.Errorf("unused opus window must not render, got %q", got) + } + u.Opus.Utilization = 31 + if got := statuslineText("w", "", u); !strings.Contains(got, "opus 31%") { + t.Errorf("used opus window must render, got %q", got) + } +} + +func TestStatuslineWindowThresholds(t *testing.T) { + reset := time.Now().Add(2 * time.Hour) + + if got := statuslineWindow("5h", &UsageWindow{Utilization: 42}); strings.Contains(got, "\x1b[3") { + t.Errorf("42%% must be uncoloured, got %q", got) + } + if got := statuslineWindow("5h", &UsageWindow{Utilization: 71}); !strings.Contains(got, ansiYellow) { + t.Errorf("71%% must be amber, got %q", got) + } + got := statuslineWindow("5h", &UsageWindow{Utilization: 93, ResetsAt: reset}) + if !strings.Contains(got, ansiRed) { + t.Errorf("93%% must be red, got %q", got) + } + // The red case is when you'd switch accounts, so it carries the reset time. + if !strings.Contains(got, "resets") { + t.Errorf("red window must show its reset time, got %q", got) + } +} + +func TestReadStatuslineModel(t *testing.T) { + if got := readStatuslineModel(strings.NewReader(`{"model":{"display_name":"Opus 4.5"}}`)); got != "Opus 4.5" { + t.Errorf("got %q", got) + } + // Anything unparseable degrades to no model, never an error. + for _, in := range []string{"", "not json", `{"model":{}}`} { + if got := readStatuslineModel(strings.NewReader(in)); got != "" { + t.Errorf("readStatuslineModel(%q) = %q, want empty", in, got) + } + } +} + +func TestUsageCacheKeyFoldsCase(t *testing.T) { + a := usageCacheKey(`C:\Users\R\.ccswitch\profiles\Work`) + b := usageCacheKey(`c:\users\r\.ccswitch\profiles\work`) + if a != b { + t.Errorf("two spellings of one directory got different keys: %q vs %q", a, b) + } +}