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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: ci

# Run the test suite on every push and pull request, on both the platform the
# tool is built for first (Windows) and the platform the release builds run on
# (Linux). release.yml only builds — this is what actually verifies a change.

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.22"

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ jobs:
path: dist
merge-multiple: true

# install.ps1, install.sh and `ccswitch upgrade` all verify what they
# download against this file, so it must list every asset by exact name.
- name: Generate SHA256SUMS
run: cd dist && sha256sum * > SHA256SUMS

- name: Publish release
uses: softprops/action-gh-release@v2
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/ccswitch
/ccswitch.exe
*.exe
*.exe~
*.syso
dist/

Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,22 @@ Grab `ccswitch.exe` from the
[Releases page](https://github.com/NotAProgrammer187/claude-code-profiles/releases)
and put it anywhere on your PATH.)

**macOS / Linux.** Download the matching binary from the
**macOS / Linux.** Downloads the matching prebuilt binary and installs it to
`~/.local/bin/ccswitch`:

```sh
curl -fsSL https://raw.githubusercontent.com/NotAProgrammer187/claude-code-profiles/main/install.sh | sh
```

(Or grab `ccswitch-darwin-arm64`, `ccswitch-darwin-amd64`, or
`ccswitch-linux-amd64` from the
[Releases page](https://github.com/NotAProgrammer187/claude-code-profiles/releases)
— `ccswitch-darwin-arm64`, `ccswitch-darwin-amd64`, or `ccswitch-linux-amd64` —
then `chmod +x` it and move it onto your PATH (e.g. `~/bin`). On macOS, credential
yourself, `chmod +x` it and move it onto your PATH.) On macOS, credential
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.

**Build from source** — needs Go 1.22+, produces one static `.exe`:

```powershell
Expand All @@ -41,6 +51,7 @@ cd claude-code-profiles
| `ccswitch run work` | launch straight into a profile |
| `ccswitch run` | launch the profile this directory is linked to (see below) |
| `ccswitch run work -- --resume` | anything after `--` is passed to `claude` |
| `ccswitch run --best` | launch the signed-in account with the most rate-limit headroom |
| `ccswitch use work` | pin the current shell to a profile (see below) |
| `ccswitch init pwsh` | shell integration: `use` applies itself, plus tab completion |
| `ccswitch link work` | use this profile for the current directory and below |
Expand Down Expand Up @@ -80,6 +91,13 @@ the same numbers in the terminal. This reads the endpoint Claude Code's own
(nothing is written or refreshed), and if the endpoint ever changes shape the
rows simply omit it.

Don't want to think about it at all? `ccswitch run --best` checks every
signed-in account and launches the one with the most headroom — an account's
score is its most-used window, since that's the one that will stop you first,
and it prints what it picked and why (`work has the most headroom (5h 12% ·
7d 30%)`). Accounts whose usage can't be read are skipped rather than guessed
at.

### A usage panel beside Claude Code

`ccswitch usage` answers the question once. `ccswitch usage --watch` keeps
Expand All @@ -90,7 +108,13 @@ windows, with the reset time under each.
![ccswitch usage --watch — a live rate-limit panel](docs/usage-watch.png)

Bars turn amber past 70% and red past 90% — the same thresholds the picker
uses, so the two views never disagree about what counts as getting close. The
uses, so the two views never disagree about what counts as getting close. And
because the panel is most useful when you're *not* looking at it, a window
crossing into the red also rings the terminal bell and posts a desktop
notification — a toast on Windows, Notification Center on macOS, `notify-send`
on Linux. One crossing is one alert: opening the panel on an account
already in the red shows red quietly, and a window has to drop back below 90%
before it will alert again. The
account your shell is pinned to is tagged `◂ shell`; a profile this directory
is linked to is tagged `◂ here`. Opus gets its own meter only once you've
actually used it, so plans without a separate Opus window don't show a
Expand Down
71 changes: 71 additions & 0 deletions cmd/ccswitch/alert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package main

import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
)

// alertUsage tells the user an account has crossed the red threshold, in a way
// that works when the panel isn't in view: the terminal bell always — most
// terminals sound or flash, and taskbars badge the window — plus a desktop
// notification where the platform has one built in. Failures are silent; an
// alert is a courtesy, never worth taking the panel down for.
func alertUsage(profile, window string, pct int) {
// Stderr, not stdout: bubbletea owns stdout while the panel is up, and a
// BEL has no glyph so nothing visible can be disturbed either way.
fmt.Fprint(os.Stderr, "\a")
notify("ccswitch", fmt.Sprintf("%s: %s window at %d%%", profile, window, pct))
}

// notify posts a desktop notification with whatever the platform ships:
// a WinRT toast on Windows, Notification Center via osascript on macOS,
// notify-send on Linux. Nothing to install; a platform (or desktop) without
// the mechanism just gets the bell.
func notify(title, body string) {
switch runtime.GOOS {
case "windows":
toast(title, body)
case "darwin":
// AppleScript strings escape only backslash and double-quote.
esc := func(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
script := fmt.Sprintf(`display notification "%s" with title "%s"`, esc(body), esc(title))
startDetached(exec.Command("osascript", "-e", script))
case "linux":
startDetached(exec.Command("notify-send", "--app-name=ccswitch", title, body))
}
}

// startDetached fires a command and forgets it: the panel must never wait on a
// notification, and whether it actually appeared is not ours to check.
func startDetached(cmd *exec.Cmd) {
if err := cmd.Start(); err == nil {
go func() { _ = cmd.Wait() }()
}
}

// powershellAppID is the AppUserModelID toasts are posted under. Toasts from
// an ID Windows has never seen are dropped silently, so borrow the one every
// machine has registered: PowerShell's own.
const powershellAppID = `{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe`

// toast posts a Windows notification via PowerShell's WinRT bridge — nothing
// to install or configure.
func toast(title, body string) {
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
script := fmt.Sprintf(`
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$texts = $xml.GetElementsByTagName('text')
$null = $texts.Item(0).AppendChild($xml.CreateTextNode('%s'))
$null = $texts.Item(1).AppendChild($xml.CreateTextNode('%s'))
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show([Windows.UI.Notifications.ToastNotification]::new($xml))
`, esc(title), esc(body), esc(powershellAppID))

startDetached(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script))
}
1 change: 1 addition & 0 deletions cmd/ccswitch/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ var completeCommands = []string{
}

var completeFlags = map[string][]string{
"run": {"--best"},
"use": {"--unset", "--shell"},
"sync": {"--from", "--to", "--only", "--dry-run", "--yes"},
"usage": {"--watch", "--every"},
Expand Down
22 changes: 20 additions & 2 deletions cmd/ccswitch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const usage = `ccswitch — run Claude Code as any of your accounts, without log
uses the profile this directory is linked to)
ccswitch run <name> -- --resume
extra args after -- go to claude
ccswitch run --best launch the signed-in profile with the most
rate-limit headroom
ccswitch use <name> pin this shell to a profile (prints the command;
eval it, e.g. Invoke-Expression (ccswitch use work))
ccswitch use --unset print the command that unpins this shell
Expand Down Expand Up @@ -133,13 +135,29 @@ func cmdRun(args []string) error {
// No profile named: fall back to the one this directory is linked to, so
// `ccswitch run` inside a linked tree just works.
var name string
best := false
rest := args
if len(args) > 0 && args[0] != "--" {
name, rest = args[0], args[1:]
if len(rest) > 0 && (rest[0] == "--best" || rest[0] == "-b") {
best = true
rest = rest[1:]
}
if !best && len(rest) > 0 && rest[0] != "--" {
name, rest = rest[0], rest[1:]
}
if best && len(rest) > 0 && rest[0] != "--" {
return fmt.Errorf("--best picks the profile for you — drop %q, or put claude args after --", rest[0])
}
if len(rest) > 0 && rest[0] == "--" {
rest = rest[1:]
}
if best {
chosen, why, err := bestProfile()
if err != nil {
return err
}
name = chosen
fmt.Fprintf(os.Stderr, "ccswitch: %s has the most headroom (%s)\n", name, why)
}
if name == "" {
linked, source, ok := CurrentDirProfile()
if !ok {
Expand Down
94 changes: 91 additions & 3 deletions cmd/ccswitch/upgrade.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package main

import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -144,11 +147,13 @@ func cmdUpgrade() error {
return nil
}

var url string
var url, sumsURL string
for _, a := range rel.Assets {
if a.Name == want {
switch a.Name {
case want:
url = a.URL
break
case checksumAsset:
sumsURL = a.URL
}
}
if url == "" {
Expand All @@ -170,6 +175,25 @@ func cmdUpgrade() error {
return err
}

// Verify what we downloaded before swapping it in. Releases before the
// manifest existed have nothing to check against, so a missing manifest is
// a loud warning rather than an error — but once one is present, any
// mismatch is fatal.
if sumsURL == "" {
fmt.Printf("Warning: release %s has no %s manifest — checksum not verified.\n", rel.TagName, checksumAsset)
} else {
sums, err := fetchChecksums(sumsURL)
if err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("could not fetch %s: %w", checksumAsset, err)
}
if err := verifyChecksum(tmp, want, sums); err != nil {
_ = os.Remove(tmp)
return err
}
fmt.Println("Checksum verified.")
}

// Swap the new binary into place. Windows can't overwrite a running exe,
// so move the current one aside first; it's removed on the next launch.
old, err := asidePath(self)
Expand Down Expand Up @@ -280,6 +304,70 @@ func download(url, dst, template string) error {
return out.Close()
}

// checksumAsset is the manifest release.yml publishes beside the binaries:
// one `<sha256> <filename>` line per asset, as written by sha256sum.
const checksumAsset = "SHA256SUMS"

// fetchChecksums downloads the manifest and returns filename → expected hash.
func fetchChecksums(url string) (map[string]string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "ccswitch-upgrade")

// The manifest is a few hundred bytes; a total deadline is fine.
resp, err := httpClient(30 * time.Second).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed: %s", resp.Status)
}
return parseChecksums(resp.Body), nil
}

// parseChecksums reads sha256sum output: hash, whitespace, filename — with the
// `*` binary-mode marker some tools prefix to the name stripped off.
func parseChecksums(r io.Reader) map[string]string {
out := map[string]string{}
sc := bufio.NewScanner(r)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) != 2 {
continue
}
name := strings.TrimPrefix(fields[1], "*")
out[name] = strings.ToLower(fields[0])
}
return out
}

// verifyChecksum hashes path and compares it to the manifest entry for name.
// A manifest that doesn't mention the asset at all is as fatal as a mismatch —
// it means the release wasn't built the way we expect.
func verifyChecksum(path, name string, sums map[string]string) error {
want, ok := sums[name]
if !ok {
return fmt.Errorf("%s does not list %s — refusing to install", checksumAsset, name)
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
got := hex.EncodeToString(h.Sum(nil))
if got != want {
return fmt.Errorf("checksum mismatch for %s: got %s, want %s — refusing to install", name, got, want)
}
return nil
}

// watchStall cancels the request if the body stops producing bytes for
// stallTimeout, and reports whether that's why it was cancelled — otherwise the
// failure surfaces as a bare "context canceled" that explains nothing.
Expand Down
Loading
Loading