diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e5fa420 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 358cb8a..37f10b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.gitignore b/.gitignore index 9f62d4e..534e31c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /ccswitch /ccswitch.exe *.exe +*.exe~ *.syso dist/ diff --git a/README.md b/README.md index f8454f9..8c84fd9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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 @@ -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 diff --git a/cmd/ccswitch/alert.go b/cmd/ccswitch/alert.go new file mode 100644 index 0000000..ea68e15 --- /dev/null +++ b/cmd/ccswitch/alert.go @@ -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)) +} diff --git a/cmd/ccswitch/complete.go b/cmd/ccswitch/complete.go index d620016..886d088 100644 --- a/cmd/ccswitch/complete.go +++ b/cmd/ccswitch/complete.go @@ -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"}, diff --git a/cmd/ccswitch/main.go b/cmd/ccswitch/main.go index aa8f37e..b9816ce 100644 --- a/cmd/ccswitch/main.go +++ b/cmd/ccswitch/main.go @@ -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 -- --resume extra args after -- go to claude + ccswitch run --best launch the signed-in profile with the most + rate-limit headroom ccswitch use 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 @@ -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 { diff --git a/cmd/ccswitch/upgrade.go b/cmd/ccswitch/upgrade.go index ea2bfbc..57b666c 100644 --- a/cmd/ccswitch/upgrade.go +++ b/cmd/ccswitch/upgrade.go @@ -1,7 +1,10 @@ package main import ( + "bufio" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -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 == "" { @@ -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) @@ -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 ` ` 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. diff --git a/cmd/ccswitch/upgrade_test.go b/cmd/ccswitch/upgrade_test.go index cafc566..d0dc7e9 100644 --- a/cmd/ccswitch/upgrade_test.go +++ b/cmd/ccswitch/upgrade_test.go @@ -140,6 +140,45 @@ func TestHTTPClientTimeouts(t *testing.T) { } } +func TestParseChecksums(t *testing.T) { + manifest := strings.Join([]string{ + "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8 ccswitch.exe", + "a3f5c1d2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a80e5751c0 *ccswitch-linux-amd64", + "", + "not a checksum line", + }, "\n") + + sums := parseChecksums(strings.NewReader(manifest)) + if len(sums) != 2 { + t.Fatalf("parsed %d entries, want 2", len(sums)) + } + if sums["ccswitch.exe"] != "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8" { + t.Errorf("wrong hash for ccswitch.exe: %s", sums["ccswitch.exe"]) + } + // The `*` binary-mode marker must not end up in the filename. + if _, ok := sums["ccswitch-linux-amd64"]; !ok { + t.Error("binary-mode entry (*name) was not stripped to its filename") + } +} + +func TestVerifyChecksum(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "asset") + writeFile(t, path, "hello") + // sha256("hello") + const good = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + + if err := verifyChecksum(path, "asset", map[string]string{"asset": good}); err != nil { + t.Errorf("matching checksum must verify: %v", err) + } + if err := verifyChecksum(path, "asset", map[string]string{"asset": strings.Repeat("0", 64)}); err == nil { + t.Error("a wrong hash must be rejected") + } + if err := verifyChecksum(path, "asset", map[string]string{"other": good}); err == nil { + t.Error("a manifest that doesn't list the asset must be rejected") + } +} + func TestProgressReaderTracksIdle(t *testing.T) { pr := &progressReader{r: strings.NewReader("hello")} pr.last.Store(time.Now().Add(-time.Hour).UnixNano()) diff --git a/cmd/ccswitch/usage.go b/cmd/ccswitch/usage.go index b19e6c2..a58c313 100644 --- a/cmd/ccswitch/usage.go +++ b/cmd/ccswitch/usage.go @@ -98,6 +98,74 @@ func FetchUsage(p Profile) (*Usage, error) { return u, nil } +// bestProfile picks the signed-in profile with the most rate-limit headroom +// and says why, e.g. "5h 12% · 7d 30%". A profile's score is its most-used +// window — that's the one that will stop you first — and lower wins. Profiles +// whose usage can't be read are skipped rather than guessed at; List() is +// most-recently-used first, so a tie goes to the account you know. +func bestProfile() (string, string, error) { + ps, err := List() + if err != nil { + return "", "", err + } + + type res struct { + ok bool + score float64 + why string + } + results := make([]res, len(ps)) + var wg sync.WaitGroup + for i, p := range ps { + if !p.SignedIn { + continue + } + wg.Add(1) + go func(i int, p Profile) { + defer wg.Done() + u, err := FetchUsage(p) + if err != nil { + return + } + score := 0.0 + var parts []string + if w := u.Session; w != nil { + if w.Utilization > score { + score = w.Utilization + } + parts = append(parts, fmt.Sprintf("5h %d%%", pct(w.Utilization))) + } + if w := u.Week; w != nil { + if w.Utilization > score { + score = w.Utilization + } + parts = append(parts, fmt.Sprintf("7d %d%%", pct(w.Utilization))) + } + results[i] = res{ok: true, score: score, why: strings.Join(parts, " · ")} + }(i, p) + } + wg.Wait() + + best := -1 + for i := range ps { + if !results[i].ok { + continue + } + if best < 0 || results[i].score < results[best].score { + best = i + } + } + if best < 0 { + for _, p := range ps { + if p.SignedIn { + return "", "", fmt.Errorf("could not read usage for any signed-in profile") + } + } + return "", "", fmt.Errorf("no profile is signed in") + } + return ps[best].Name, results[best].why, nil +} + // resetClock renders a reset time compactly: clock only when it's within a // day, weekday otherwise. func resetClock(t time.Time) string { diff --git a/cmd/ccswitch/watch.go b/cmd/ccswitch/watch.go index 6510ccd..8434748 100644 --- a/cmd/ccswitch/watch.go +++ b/cmd/ccswitch/watch.go @@ -114,6 +114,11 @@ var sTrack = lipgloss.NewStyle().Foreground(cFaint) var sClawd = lipgloss.NewStyle().Foreground( lipgloss.AdaptiveColor{Light: "#C1603F", Dark: "#D97757"}) +// watchAlertAt is when the panel speaks up rather than just turning a bar red +// — the same 90% at which meterStyle goes red, so what you hear and what you +// see never disagree. +const watchAlertAt = 90.0 + type watchModel struct { profiles []Profile usage map[string]*Usage @@ -121,6 +126,10 @@ type watchModel struct { active string linked string + // alerted remembers, per profile window, whether the last fetch was at or + // above watchAlertAt — the state machine behind alerting once per crossing. + alerted map[string]bool + every time.Duration updated time.Time pending int @@ -156,11 +165,12 @@ func runUsageWatch(every time.Duration) error { func newWatchModel(every time.Duration) watchModel { m := watchModel{ - usage: map[string]*Usage{}, - errs: map[string]string{}, - every: every, - width: 40, - height: 24, + usage: map[string]*Usage{}, + errs: map[string]string{}, + alerted: map[string]bool{}, + every: every, + width: 40, + height: 24, } m.reload() return m @@ -227,18 +237,20 @@ func (m watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case watchUsageMsg: + var alerts []tea.Cmd if msg.err != "" { m.errs[msg.name] = msg.err delete(m.usage, msg.name) } else { m.usage[msg.name] = msg.u delete(m.errs, msg.name) + alerts = m.checkAlerts(msg.name, msg.u) } if m.pending > 0 { m.pending-- } m.updated = time.Now() - return m, nil + return m, tea.Batch(alerts...) case clawdTickMsg: m.step++ @@ -264,6 +276,41 @@ func (m watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// checkAlerts compares a fresh fetch against the last one and returns a +// command per window that has just crossed into the red — bell plus a desktop +// notification, so the panel is useful even when it isn't in view. The +// first sighting of a window only records where it is: a panel opened on an +// account already in the red shows red, it doesn't beep at you. A window then +// has to drop back below the threshold before it can alert again, so one +// crossing is one alert, not one per refresh. +func (m watchModel) checkAlerts(name string, u *Usage) []tea.Cmd { + var cmds []tea.Cmd + for _, w := range []struct { + label string + win *UsageWindow + }{ + {"5-hour", u.Session}, + {"weekly", u.Week}, + {"weekly Opus", u.Opus}, + } { + if w.win == nil { + continue + } + key := name + "/" + w.label + above := w.win.Utilization >= watchAlertAt + prev, seen := m.alerted[key] + m.alerted[key] = above + if above && seen && !prev { + label, p := w.label, pct(w.win.Utilization) + cmds = append(cmds, func() tea.Msg { + alertUsage(name, label, p) + return nil + }) + } + } + return cmds +} + // --------------------------------------------------------------------------- // View // --------------------------------------------------------------------------- diff --git a/cmd/ccswitch/watch_test.go b/cmd/ccswitch/watch_test.go index 072193d..10ea046 100644 --- a/cmd/ccswitch/watch_test.go +++ b/cmd/ccswitch/watch_test.go @@ -114,6 +114,37 @@ func TestWatchArtTooWideIsDropped(t *testing.T) { } } +// One crossing is one alert: the first sighting only records state, staying in +// the red stays quiet, and dropping below the threshold re-arms the window. +func TestCheckAlertsFiresOncePerCrossing(t *testing.T) { + m := watchModel{alerted: map[string]bool{}} + at := func(util float64) *Usage { + return &Usage{Session: &UsageWindow{Utilization: util}} + } + + if got := m.checkAlerts("work", at(95)); len(got) != 0 { + t.Error("first sighting of a window already in the red must not alert") + } + if got := m.checkAlerts("work", at(96)); len(got) != 0 { + t.Error("staying above the threshold must not alert again") + } + if got := m.checkAlerts("work", at(50)); len(got) != 0 { + t.Error("dropping below the threshold is not an alert") + } + if got := m.checkAlerts("work", at(92)); len(got) != 1 { + t.Errorf("crossing from below fires exactly one alert, got %d", len(got)) + } + if got := m.checkAlerts("work", at(93)); len(got) != 0 { + t.Error("a crossing must not repeat while still above the threshold") + } + + // Windows are tracked per profile: another account crossing is its own alert. + m.checkAlerts("personal", at(10)) + if got := m.checkAlerts("personal", at(91)); len(got) != 1 { + t.Errorf("a second profile's crossing fires its own alert, got %d", len(got)) + } +} + func testWatchModel() watchModel { reset := time.Now().Add(2 * time.Hour) return watchModel{ diff --git a/install.ps1 b/install.ps1 index 94275a6..63fd1f5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -59,11 +59,46 @@ $asset = $release.assets | Where-Object { $_.name -eq "ccswitch.exe" } | Select- if (-not $asset) { Die "The latest release ($($release.tag_name)) has no ccswitch.exe asset attached." } +$sums = $release.assets | Where-Object { $_.name -eq "SHA256SUMS" } | Select-Object -First 1 +# Download to a temp file first, so a failed checksum never leaves a bad +# binary on the PATH. New-Item -ItemType Directory -Force -Path $bin | Out-Null $dest = Join-Path $bin "ccswitch.exe" +$tmp = Join-Path $env:TEMP ("ccswitch-" + [IO.Path]::GetRandomFileName() + ".exe") Info "Downloading ccswitch.exe ($($release.tag_name))..." -Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $dest -Headers $headers +Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $tmp -Headers $headers + +# --- Checksum ------------------------------------------------------------ + +# Releases before the manifest existed have nothing to verify against, so a +# missing SHA256SUMS is a loud warning; once one is present, any mismatch stops +# the install. +if ($sums) { + $manifest = (Invoke-WebRequest -Uri $sums.browser_download_url -Headers $headers -UseBasicParsing).Content + if ($manifest -is [byte[]]) { $manifest = [Text.Encoding]::UTF8.GetString($manifest) } + $expected = $null + foreach ($line in ($manifest -split "`n")) { + $parts = ($line.Trim() -split "\s+") + if ($parts.Count -eq 2 -and $parts[1].TrimStart("*") -eq "ccswitch.exe") { + $expected = $parts[0].ToLower() + } + } + if (-not $expected) { + Remove-Item $tmp -Force + Die "SHA256SUMS in release $($release.tag_name) does not list ccswitch.exe - refusing to install." + } + $actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + Remove-Item $tmp -Force + Die "Checksum mismatch for ccswitch.exe (got $actual, want $expected) - refusing to install." + } + Ok " [ok] SHA256 checksum verified" +} else { + Warn " [!] Release $($release.tag_name) has no SHA256SUMS manifest - checksum not verified." +} + +Move-Item -Path $tmp -Destination $dest -Force Ok "Installed to $dest" # --- PATH ---------------------------------------------------------------- diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..6b4b50e --- /dev/null +++ b/install.sh @@ -0,0 +1,101 @@ +#!/bin/sh +# install.sh - one-line installer for ccswitch on macOS and Linux. +# +# curl -fsSL https://raw.githubusercontent.com/NotAProgrammer187/claude-code-profiles/main/install.sh | sh +# +# Downloads the prebuilt binary for this platform from the latest GitHub +# release, verifies its SHA256 checksum, and installs it as ~/.local/bin/ccswitch +# (override with CCSWITCH_BIN_DIR). No Go, no git, no build step. + +set -eu + +repo="NotAProgrammer187/claude-code-profiles" +bin_dir="${CCSWITCH_BIN_DIR:-$HOME/.local/bin}" + +info() { printf '%s\n' "$*"; } +die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } + +# --- Platform ------------------------------------------------------------ + +os=$(uname -s) +arch=$(uname -m) +case "$os" in + Darwin) + case "$arch" in + arm64) asset="ccswitch-darwin-arm64" ;; + x86_64|amd64) asset="ccswitch-darwin-amd64" ;; + *) die "no prebuilt binary for macOS/$arch - build from source with 'go build ./cmd/ccswitch'" ;; + esac ;; + Linux) + case "$arch" in + x86_64|amd64) asset="ccswitch-linux-amd64" ;; + *) die "no prebuilt binary for Linux/$arch - build from source with 'go build ./cmd/ccswitch'" ;; + esac ;; + *) die "unsupported OS $os - on Windows, use install.ps1 instead" ;; +esac + +command -v curl >/dev/null 2>&1 || die "curl is required" + +# sha256sum on Linux, shasum on macOS. +if command -v sha256sum >/dev/null 2>&1; then + sha() { sha256sum "$1" | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + sha() { shasum -a 256 "$1" | awk '{print $1}'; } +else + die "need sha256sum or shasum to verify the download" +fi + +# --- Download ------------------------------------------------------------ + +info "Finding the latest release..." +release_json=$(curl -fsSL -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$repo/releases/latest") || + die "could not reach GitHub releases for $repo" + +tag=$(printf '%s' "$release_json" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') +[ -n "$tag" ] || die "could not read the latest release tag" + +base="https://github.com/$repo/releases/download/$tag" +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +info "Downloading $asset ($tag)..." +curl -fsSL -o "$tmp_dir/$asset" "$base/$asset" || die "download failed for $asset" + +# --- Checksum ------------------------------------------------------------ + +# Releases before the manifest existed have nothing to verify against, so a +# missing SHA256SUMS is a loud warning; once one is present, any mismatch +# stops the install. +if curl -fsSL -o "$tmp_dir/SHA256SUMS" "$base/SHA256SUMS" 2>/dev/null; then + expected=$(awk -v name="$asset" '{ f=$2; sub(/^\*/, "", f); if (f == name) print tolower($1) }' \ + "$tmp_dir/SHA256SUMS" | head -1) + [ -n "$expected" ] || die "SHA256SUMS in release $tag does not list $asset - refusing to install" + actual=$(sha "$tmp_dir/$asset") + [ "$actual" = "$expected" ] || + die "checksum mismatch for $asset (got $actual, want $expected) - refusing to install" + info "SHA256 checksum verified." +else + info "Warning: release $tag has no SHA256SUMS manifest - checksum not verified." +fi + +# --- Install ------------------------------------------------------------- + +mkdir -p "$bin_dir" +install -m 0755 "$tmp_dir/$asset" "$bin_dir/ccswitch" 2>/dev/null || + { cp "$tmp_dir/$asset" "$bin_dir/ccswitch" && chmod 0755 "$bin_dir/ccswitch"; } +info "Installed to $bin_dir/ccswitch" + +case ":$PATH:" in + *":$bin_dir:"*) ;; + *) info "Note: $bin_dir is not on your PATH - add this to your shell profile:" + info " export PATH=\"$bin_dir:\$PATH\"" ;; +esac + +if ! command -v claude >/dev/null 2>&1; then + info "Note: Claude Code (the 'claude' command) was not found on PATH." + info "ccswitch launches Claude Code, so install it first:" + info " https://docs.claude.com/en/docs/claude-code/setup" +fi + +info "Done. Run: ccswitch"