diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index df40f7d..bb5f39e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -28,6 +28,26 @@ "authentication" ], "category": "security" + }, + { + "name": "claude-switcher", + "source": "./plugins/claude-switcher", + "description": "Switch between multiple Claude Code accounts instantly by swapping credential profiles", + "version": "2.0.0", + "author": { + "name": "moukrea" + }, + "homepage": "https://github.com/moukrea/claude-code-plugins", + "repository": "https://github.com/moukrea/claude-code-plugins", + "license": "MIT", + "keywords": [ + "auth", + "account", + "switch", + "profile", + "credentials" + ], + "category": "productivity" } ] } diff --git a/.github/scripts/validate-plugins.sh b/.github/scripts/validate-plugins.sh index 908bb24..5b5caa6 100755 --- a/.github/scripts/validate-plugins.sh +++ b/.github/scripts/validate-plugins.sh @@ -27,8 +27,7 @@ echo "marketplace.json is valid." echo "Validating plugin directories..." -for PLUGIN_DIR in plugins/*/; do - PLUGIN=$(basename "$PLUGIN_DIR") +for PLUGIN in $(jq -r '.plugins[].name' .claude-plugin/marketplace.json); do echo " Checking plugin: $PLUGIN" # Verify valid JSON diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 338dc4a..aa8ffb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,7 @@ claude-code-plugins/ │ ├── release.yml # Auto-release on main │ └── tag-release.yml # GitHub release creation └── plugins/ + ├── claude-switcher/ # Account switching plugin └── opaq/ # Credential security plugin ``` diff --git a/README.md b/README.md index afa5d7f..09eb9a4 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Productivity and security plugins for [Claude Code](https://claude.ai/code). | Plugin | Description | |--------|-------------| | [opaq](plugins/opaq/) | Secure credential access — use secrets in commands without ever exposing them | +| [claude-switcher](plugins/claude-switcher/) | Switch between multiple Claude Code accounts instantly by swapping credential profiles | ## Installation @@ -22,6 +23,7 @@ Install plugins: ``` /plugin install opaq@moukrea-plugins +/plugin install claude-switcher@moukrea-plugins ``` ### From a local clone diff --git a/plugins/claude-switcher/.claude-plugin/plugin.json b/plugins/claude-switcher/.claude-plugin/plugin.json new file mode 100644 index 0000000..0480a56 --- /dev/null +++ b/plugins/claude-switcher/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "claude-switcher", + "version": "2.0.0", + "description": "Switch between multiple Claude Code accounts instantly by swapping credential profiles", + "author": { + "name": "Emeric Commenge" + }, + "license": "MIT", + "keywords": ["auth", "account", "switch", "profile", "credentials"], + "hooks": "./hooks/hooks.json", + "commands": "./commands/" +} diff --git a/plugins/claude-switcher/README.md b/plugins/claude-switcher/README.md new file mode 100644 index 0000000..7a959c4 --- /dev/null +++ b/plugins/claude-switcher/README.md @@ -0,0 +1,140 @@ +# claude-switcher + +Claude Code plugin for switching between multiple Claude Max accounts. Auto-switches on rate limits and switches back at reset time. + +## Why + +If you have both a company Claude Max subscription (limited usage) and a personal one, switching accounts normally requires `claude auth logout` then `claude auth login` every time. + +claude-switcher saves named profiles and restores them instantly -- zero network calls. Plus it auto-switches to your fallback account when you hit rate limits, and auto-switches back when the primary resets. + +## Install + +Register as a Claude Code plugin: + +```bash +claude --plugin-dir /path/to/claude-switcher +``` + +Or add to `~/.claude/settings.json`: + +```json +{ + "plugins": ["/path/to/claude-switcher"] +} +``` + +**Requires**: bash 4.0+, [jq](https://jqlang.github.io/jq/download/) + +## Quick Start + +```bash +# Set up rate limit capture +./scripts/claude-switcher.sh setup-plugin + +# Save your current account +./scripts/claude-switcher.sh save work + +# Log in to the other account +claude auth logout && claude auth login + +# Save that one too +./scripts/claude-switcher.sh save personal + +# Configure auto-switching +./scripts/claude-switcher.sh auto-config enable +./scripts/claude-switcher.sh auto-config primary work +./scripts/claude-switcher.sh auto-config fallback personal +./scripts/claude-switcher.sh auto-config threshold 97 +./scripts/claude-switcher.sh auto-config daily-reset 15:00 Europe/Paris +``` + +## Slash Commands + +Once installed as a plugin, use these in Claude Code sessions: + +| Command | Description | +|---------|-------------| +| `/switch ` | Switch to a profile | +| `/switch prev` | Switch to previous profile | +| `/profiles` | List all profiles | +| `/limit-hit` | Manually trigger fallback (rate limit) | +| `/auto-config` | View/edit auto-switch configuration | +| `/setup` | Set up rate limit capture in status line | + +## Auto-Switch + +When enabled, claude-switcher automatically manages account switching: + +1. **Preemptive**: A `PostToolUse` hook reads real rate limit data (captured from the status line) and switches at a configurable threshold (default 97%) BEFORE hitting the actual limit +2. **On rate limit**: A `StopFailure` hook detects actual rate limit errors as a safety net +3. **On reset**: A `SessionStart` hook checks if the primary account's limit has reset and switches back +4. **Manual**: Use `/limit-hit` if auto-detection misses a rate limit + +### Configuration + +```bash +./scripts/claude-switcher.sh auto-config show # View config +./scripts/claude-switcher.sh setup-plugin # Inject rate limit capture into status line +./scripts/claude-switcher.sh auto-config enable # Enable +./scripts/claude-switcher.sh auto-config primary work # Set primary +./scripts/claude-switcher.sh auto-config fallback personal # Add fallback +./scripts/claude-switcher.sh auto-config threshold 97 # Switch at 97% real usage +./scripts/claude-switcher.sh auto-config daily-reset 15:00 Europe/Paris +./scripts/claude-switcher.sh auto-config weekly-reset Monday 10:00 +./scripts/claude-switcher.sh auto-config reset-state # Clear state +``` + +## CLI Reference + +All commands are also available via the script directly: + +``` +./scripts/claude-switcher.sh [options] + +Profile Management: + save [--force] Save current auth as a profile + use Switch to a profile + prev, - Switch to previous profile + list List profiles + show Profile details + status Active profile + auto-switch state + delete Delete a profile + rename Rename a profile + setup Interactive first-time setup + +Auto-Switch: + auto-config [subcmd] Configure auto-switching + limit-hit Manually trigger fallback + +Other: + uninstall Remove plugin registration + help Show help + version Show version +``` + +## How It Works + +**Profile switching**: Copies OAuth tokens from `~/.claude-switcher/profiles//` back to `~/.claude/.credentials.json` and surgically updates only the `oauthAccount` key in `~/.claude.json`. + +**Rate limit capture**: The `/setup` command injects a snippet into your Claude Code status line script. The status line receives real rate limit data from Claude Code (five_hour and seven_day percentages) on every refresh, and the snippet writes this to `~/.claude-switcher/rate-limits.json`. + +**Preemptive switching**: The `PostToolUse` hook (async, non-blocking) reads the captured rate limit data after every tool call. When either the 5-hour or 7-day usage exceeds the configured threshold, it switches to the fallback before hitting the actual limit. + +**Rate limit detection**: The `StopFailure` hook serves as a safety net. It analyzes errors for rate-limit patterns and switches if the preemptive system missed. + +**Time-based switch-back**: The `SessionStart` hook checks if the configured daily reset time has passed. If the primary account was rate-limited but has since reset, it auto-switches back. + +## Security + +- Profile directories: `chmod 700` +- Credential files: `chmod 600` +- No credentials sent over the network +- Atomic writes (temp file + mv) +- Backup state preserved before every switch + +## Uninstall + +```bash +./scripts/claude-switcher.sh uninstall +``` diff --git a/plugins/claude-switcher/commands/auto-config.md b/plugins/claude-switcher/commands/auto-config.md new file mode 100644 index 0000000..36f46c2 --- /dev/null +++ b/plugins/claude-switcher/commands/auto-config.md @@ -0,0 +1,37 @@ +--- +name: auto-config +description: "View or configure auto-switching settings for Claude Code account profiles. Use when the user asks about auto-switch configuration, wants to change primary/fallback profiles, or adjust reset times." +argument-hint: "[show | enable | disable | primary | fallback | threshold | daily-reset [tz] | weekly-reset [HH:MM] | reset-state]" +allowed-tools: Bash +--- + +# Auto-Switch Configuration + +Manage automatic profile switching configuration. + +If no argument provided, show the current config: +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh auto-config show +``` + +If argument provided, run the matching subcommand: +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh auto-config $ARGUMENTS +``` + +After showing config, explain what each setting means: +- **Primary**: The preferred account, consumed first +- **Fallbacks**: Accounts to switch to when primary hits rate limits +- **Daily reset**: When the primary account's daily session limit resets (from the Claude usage screen) +- **Weekly reset**: When the weekly limit resets + +If the user is setting up auto-switch for the first time, guide them through: +1. Run `/setup` first to enable rate limit capture in the status line +2. Enable: `auto-config enable` +3. Set primary: `auto-config primary work` +4. Add fallback: `auto-config fallback personal` +5. Set threshold: `auto-config threshold 97` (switch at 97% real usage) +6. Set daily reset: `auto-config daily-reset 15:00 Europe/Paris` +7. Set weekly reset: `auto-config weekly-reset Monday 10:00` + +The **threshold** controls preemptive switching. The PostToolUse hook reads real rate limit data (five_hour and seven_day percentages captured by the status line) and switches to the fallback when either exceeds the threshold. diff --git a/plugins/claude-switcher/commands/limit-hit.md b/plugins/claude-switcher/commands/limit-hit.md new file mode 100644 index 0000000..67bb08f --- /dev/null +++ b/plugins/claude-switcher/commands/limit-hit.md @@ -0,0 +1,21 @@ +--- +name: limit-hit +description: "Manually trigger a rate limit fallback switch. Use when Claude Code hits a rate limit and auto-detection didn't catch it, or when the user says they've hit their limit." +allowed-tools: Bash +--- + +# Rate Limit Hit -- Manual Fallback + +The user is reporting they've hit a rate limit. Trigger the fallback switch: + +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh limit-hit +``` + +After running: +1. Tell the user which profile they've been switched to +2. Explain that the tool will auto-switch back to the primary profile at the configured reset time +3. If it fails (auto-switch not enabled or no fallback configured), help them set it up: + - `$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh auto-config enable` + - `$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh auto-config primary ` + - `$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh auto-config fallback ` diff --git a/plugins/claude-switcher/commands/profiles.md b/plugins/claude-switcher/commands/profiles.md new file mode 100644 index 0000000..a8a484a --- /dev/null +++ b/plugins/claude-switcher/commands/profiles.md @@ -0,0 +1,20 @@ +--- +name: profiles +description: "List all saved Claude Code account profiles and show which one is active. Use when the user asks about their accounts, profiles, or wants to see available options." +allowed-tools: Bash +--- + +# List Claude Code Account Profiles + +Show the user their saved profiles: + +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh list +``` + +Present the output to the user. The active profile is marked with `*`. + +If the user wants more details about a specific profile: +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh show +``` diff --git a/plugins/claude-switcher/commands/setup.md b/plugins/claude-switcher/commands/setup.md new file mode 100644 index 0000000..9183b15 --- /dev/null +++ b/plugins/claude-switcher/commands/setup.md @@ -0,0 +1,21 @@ +--- +name: setup +description: "Set up claude-switcher rate limit capture by injecting a snippet into the Claude Code status line script. Use when the user first installs the plugin or asks to configure rate limit tracking." +allowed-tools: Bash +--- + +# Set Up Claude-Switcher + +Run the setup command to configure rate limit capture: + +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh setup-plugin +``` + +This injects a small snippet into the user's Claude Code status line script that captures real rate limit data (five_hour and seven_day percentages) to `~/.claude-switcher/rate-limits.json`. This data is then used by the auto-switch system to preemptively switch profiles before hitting rate limits. + +After setup, guide the user through configuring auto-switch if not done already: +1. Save both profiles +2. Enable auto-switch +3. Set primary and fallback profiles +4. Set threshold percentage diff --git a/plugins/claude-switcher/commands/switch.md b/plugins/claude-switcher/commands/switch.md new file mode 100644 index 0000000..73ae249 --- /dev/null +++ b/plugins/claude-switcher/commands/switch.md @@ -0,0 +1,26 @@ +--- +name: switch +description: "Switch to a different Claude Code account profile. Use when the user wants to change which Claude subscription they're using (e.g., switching between work and personal accounts)." +argument-hint: "[profile-name | prev]" +allowed-tools: Bash +--- + +# Switch Claude Code Account Profile + +The user wants to switch their Claude Code account. Run the switcher command: + +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh use $ARGUMENTS +``` + +After switching: +1. Report the result to the user (which profile is now active, the email and subscription type) +2. Note that the switch takes effect for NEW Claude Code sessions -- the current session may still use the previous account's tokens until restarted +3. If the user ran `/switch prev` or `/switch -`, explain they switched back to their previous profile + +If no argument was provided, show the available profiles: +```bash +$CLAUDE_PLUGIN_ROOT/scripts/claude-switcher.sh list +``` + +Then ask the user which profile they want to switch to. diff --git a/plugins/claude-switcher/hooks/hooks.json b/plugins/claude-switcher/hooks/hooks.json new file mode 100644 index 0000000..d473bb8 --- /dev/null +++ b/plugins/claude-switcher/hooks/hooks.json @@ -0,0 +1,20 @@ +{ + "hooks": [ + { + "event": "SessionStart", + "type": "command", + "command": "$CLAUDE_PLUGIN_ROOT/scripts/session-start.sh" + }, + { + "event": "PostToolUse", + "type": "command", + "command": "$CLAUDE_PLUGIN_ROOT/scripts/on-post-tool-use.sh", + "async": true + }, + { + "event": "StopFailure", + "type": "command", + "command": "$CLAUDE_PLUGIN_ROOT/scripts/on-stop-failure.sh" + } + ] +} diff --git a/plugins/claude-switcher/init.sh b/plugins/claude-switcher/init.sh new file mode 100755 index 0000000..f8a288f --- /dev/null +++ b/plugins/claude-switcher/init.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -e + +echo "=== Prerequisites ===" +if ! command -v jq >/dev/null 2>&1; then + echo "error: jq required but not installed" + if command -v apt >/dev/null 2>&1; then + echo " Install: sudo apt install jq" + elif command -v brew >/dev/null 2>&1; then + echo " Install: brew install jq" + elif command -v dnf >/dev/null 2>&1; then + echo " Install: sudo dnf install jq" + elif command -v pacman >/dev/null 2>&1; then + echo " Install: sudo pacman -S jq" + fi + exit 1 +fi +echo "jq $(jq --version)" + +echo "=== Smoke test ===" +if [ -f scripts/claude-switcher.sh ]; then + sh scripts/claude-switcher.sh --help >/dev/null 2>&1 && echo "CLI: ok" || echo "CLI: not ready yet" +else + echo "CLI: not built yet" +fi + +echo "=== Ready ===" diff --git a/plugins/claude-switcher/scripts/claude-switcher.sh b/plugins/claude-switcher/scripts/claude-switcher.sh new file mode 100755 index 0000000..7b1a6db --- /dev/null +++ b/plugins/claude-switcher/scripts/claude-switcher.sh @@ -0,0 +1,93 @@ +#!/bin/sh +set -eu + +# claude-switcher -- Switch between multiple Claude Code accounts +# Entry point: sources library modules and dispatches commands + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LIB_DIR="${SCRIPT_DIR}/lib" + +# Source library modules in dependency order +. "${LIB_DIR}/vars.sh" +. "${LIB_DIR}/helpers.sh" +. "${LIB_DIR}/profiles.sh" +. "${LIB_DIR}/auto-state.sh" +. "${LIB_DIR}/rate-limits.sh" +. "${LIB_DIR}/auto-config.sh" +. "${LIB_DIR}/status.sh" +. "${LIB_DIR}/setup.sh" +. "${LIB_DIR}/completions.sh" + +cmd_help() { + cat <<'HELP' +Usage: claude-switcher [options] + +Switch between multiple Claude Code accounts instantly. +Auto-switches on rate limits and switches back at reset time. + +Commands: + save [--force] Save current auth state as a named profile + use Switch to a saved profile + prev, - Switch to the previous profile + list List all saved profiles + show Show details of a profile + status Show active profile and live auth status + delete Delete a saved profile + rename Rename a profile + setup Interactive first-time setup + +Auto-switch: + setup-plugin Set up rate limit capture in status line + auto-config [subcmd] Configure auto-switching + show Show config, rate limits, and state + enable / disable Toggle auto-switching + primary Set primary (preferred) profile + fallback Add a fallback profile + threshold Set preemptive switch % (default: 97) + daily-reset [tz] Set daily limit reset time + weekly-reset [HH:MM] Set weekly limit reset day/time + reset-state Clear auto-switch state + limit-hit Manually trigger fallback switch + +Other: + uninstall Remove claude-switcher + help Show this help + version Show version +HELP +} + +cmd_version() { + echo "claude-switcher $VERSION" +} + +main() { + check_jq + ensure_dirs + init_config + + cmd="${1:-help}" + shift 2>/dev/null || true + + case "$cmd" in + save) cmd_save "$@" ;; + use) cmd_use "${1:-}" ;; + prev|-) cmd_prev ;; + list|ls) cmd_list ;; + show) cmd_show "${1:-}" ;; + status) cmd_status ;; + delete|rm) cmd_delete "${1:-}" ;; + rename|mv) cmd_rename "${1:-}" "${2:-}" ;; + setup) cmd_setup ;; + auto-config) cmd_auto_config "${1:-show}" "${2:-}" "${3:-}" ;; + limit-hit) cmd_limit_hit ;; + check-limits) check_rate_limits_and_switch ;; + setup-plugin) cmd_setup_plugin ;; + uninstall) cmd_uninstall ;; + completions) cmd_completions "${1:-bash}" ;; + help|--help|-h) cmd_help ;; + version|--version|-v) cmd_version ;; + *) die "unknown command: $cmd. Run 'claude-switcher help' for usage." ;; + esac +} + +main "$@" diff --git a/plugins/claude-switcher/scripts/lib/auto-config.sh b/plugins/claude-switcher/scripts/lib/auto-config.sh new file mode 100644 index 0000000..760f58d --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/auto-config.sh @@ -0,0 +1,138 @@ +# shellcheck shell=bash +# Auto-switch configuration commands for claude-switcher +# Sourced by the main entry point -- not executed directly + +cmd_auto_config() { + init_auto_switch_config + + local subcmd="${1:-show}" + shift 2>/dev/null || true + + case "$subcmd" in + show) + echo "Auto-switch configuration:" + echo "" + local enabled primary daily_time daily_tz weekly_day weekly_time + enabled=$(get_auto_config_value "enabled") + primary=$(get_auto_config_value "primary_profile") + daily_time=$(get_auto_config_value "daily_reset_time") + daily_tz=$(get_auto_config_value "daily_reset_timezone") + weekly_day=$(get_auto_config_value "weekly_reset_day") + weekly_time=$(get_auto_config_value "weekly_reset_time") + + echo " Enabled: ${enabled:-false}" + echo " Primary: ${primary:-(not set)}" + + local fallbacks threshold + fallbacks=$(jq -r '.fallback_profiles | join(", ")' "$AUTO_SWITCH_CONFIG" 2>/dev/null) + threshold=$(get_auto_config_value "preemptive_switch_percent") + + echo " Fallbacks: ${fallbacks:-(none)}" + echo " Threshold: ${threshold:-97}%" + echo " Daily reset: ${daily_time} ${daily_tz}" + echo " Weekly reset: ${weekly_day} ${weekly_time} ${daily_tz}" + + echo "" + echo " Rate limits: $(format_rate_limits)" + + if [ -f "$AUTO_SWITCH_STATE" ]; then + local on_fb + on_fb=$(get_auto_switch_state "on_fallback") + if [ "$on_fb" = "true" ]; then + local orig fb_prof reason next + orig=$(get_auto_switch_state "original_profile") + fb_prof=$(get_auto_switch_state "fallback_profile") + reason=$(get_auto_switch_state "reason") + next=$(get_auto_switch_state "next_reset") + echo "" + echo " ** ON FALLBACK **" + echo " Original: $orig" + echo " Using: $fb_prof" + echo " Reason: $reason" + echo " Next reset: ${next:-(unknown)}" + fi + fi + ;; + enable) + set_auto_config_value "enabled" "true" + echo "Auto-switch enabled." + ;; + disable) + set_auto_config_value "enabled" "false" + echo "Auto-switch disabled." + ;; + primary) + local name="${1:-}" + [ -z "$name" ] && die "usage: claude-switcher auto-config primary " + validate_profile_name "$name" + profile_exists "$name" || die "profile '$name' not found." + set_auto_config_value "primary_profile" "$name" + echo "Primary profile set to \"$name\"" + ;; + fallback) + local name="${1:-}" + [ -z "$name" ] && die "usage: claude-switcher auto-config fallback " + validate_profile_name "$name" + profile_exists "$name" || die "profile '$name' not found." + local current_fallbacks + current_fallbacks=$(jq -c '.fallback_profiles // []' "$AUTO_SWITCH_CONFIG") + local new_fallbacks + new_fallbacks=$(echo "$current_fallbacks" | jq --arg n "$name" '. + [$n] | unique') + set_auto_config_value "fallback_profiles" "$new_fallbacks" + echo "Added \"$name\" to fallback profiles." + ;; + daily-reset) + local time="${1:-}" tz="${2:-}" + [ -z "$time" ] && die "usage: claude-switcher auto-config daily-reset [timezone]" + if ! echo "$time" | grep -qE '^[0-9]{1,2}:[0-9]{2}$'; then + die "invalid time format. Use HH:MM (e.g., 15:00)" + fi + local hour=${time%%:*} min=${time##*:} + if [ "$hour" -gt 23 ] || [ "$min" -gt 59 ]; then + die "invalid time '$time'. Hours must be 0-23, minutes 0-59." + fi + set_auto_config_value "daily_reset_time" "$time" + [ -n "$tz" ] && set_auto_config_value "daily_reset_timezone" "$tz" + echo "Daily reset set to $time${tz:+ ($tz)}" + ;; + weekly-reset) + local day="${1:-}" time="${2:-}" + [ -z "$day" ] && die "usage: claude-switcher auto-config weekly-reset [HH:MM]" + set_auto_config_value "weekly_reset_day" "$day" + [ -n "$time" ] && set_auto_config_value "weekly_reset_time" "$time" + echo "Weekly reset set to $day${time:+ at $time}" + ;; + threshold) + local pct="${1:-}" + [ -z "$pct" ] && die "usage: claude-switcher auto-config threshold " + if ! echo "$pct" | grep -qE '^[0-9]+$' || [ "$pct" -lt 1 ] || [ "$pct" -gt 100 ]; then + die "threshold must be a number between 1 and 100." + fi + set_auto_config_value "preemptive_switch_percent" "$pct" + echo "Preemptive switch threshold set to ${pct}%" + ;; + reset-state) + clear_auto_switch_state + echo "Auto-switch state cleared." + ;; + *) + die "unknown auto-config subcommand: $subcmd. Use: show, enable, disable, primary, fallback, threshold, daily-reset, weekly-reset, reset-state" + ;; + esac +} + +cmd_limit_hit() { + init_auto_switch_config + local enabled + enabled=$(get_auto_config_value "enabled") + if [ "$enabled" != "true" ]; then + die "auto-switch is not enabled. Run 'claude-switcher auto-config enable' first." + fi + + local primary + primary=$(get_auto_config_value "primary_profile") + [ -z "$primary" ] && die "no primary profile configured. Run 'claude-switcher auto-config primary ' first." + + echo "Rate limit hit. Switching to fallback..." + do_auto_switch_to_fallback "rate_limit_manual" +} diff --git a/plugins/claude-switcher/scripts/lib/auto-state.sh b/plugins/claude-switcher/scripts/lib/auto-state.sh new file mode 100644 index 0000000..6253142 --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/auto-state.sh @@ -0,0 +1,194 @@ +# shellcheck shell=bash +# Auto-switch state and configuration management for claude-switcher +# Sourced by the main entry point -- not executed directly + +init_auto_switch_config() { + if [ ! -f "$AUTO_SWITCH_CONFIG" ]; then + cat > "$AUTO_SWITCH_CONFIG" <<'JSON' +{ + "enabled": false, + "primary_profile": null, + "fallback_profiles": [], + "daily_reset_time": "15:00", + "daily_reset_timezone": "Europe/Paris", + "weekly_reset_day": "Monday", + "weekly_reset_time": "10:00", + "preemptive_switch_percent": 97 +} +JSON + chmod 600 "$AUTO_SWITCH_CONFIG" + else + local needs_update=false + if ! jq -e '.preemptive_switch_percent' "$AUTO_SWITCH_CONFIG" >/dev/null 2>&1; then + needs_update=true + fi + if jq -e '.estimated_daily_capacity' "$AUTO_SWITCH_CONFIG" >/dev/null 2>&1; then + needs_update=true + fi + if [ "$needs_update" = true ]; then + local tmp + tmp=$(mktemp "${AUTO_SWITCH_CONFIG}.XXXXXX") + jq '. + {preemptive_switch_percent: (.preemptive_switch_percent // 97)} | del(.estimated_daily_capacity)' "$AUTO_SWITCH_CONFIG" > "$tmp" && mv "$tmp" "$AUTO_SWITCH_CONFIG" + chmod 600 "$AUTO_SWITCH_CONFIG" + fi + fi +} + +get_auto_config_value() { + jq -r ".$1 // empty" "$AUTO_SWITCH_CONFIG" 2>/dev/null +} + +set_auto_config_value() { + local key="$1" value="$2" + local tmp + tmp=$(mktemp "${AUTO_SWITCH_CONFIG}.XXXXXX") + if [ "$value" = "true" ] || [ "$value" = "false" ] || [ "$value" = "null" ]; then + jq ".$key = $value" "$AUTO_SWITCH_CONFIG" > "$tmp" && mv "$tmp" "$AUTO_SWITCH_CONFIG" + elif [ "${value#\[}" != "$value" ]; then + # Starts with [ -- JSON array + jq --argjson v "$value" ".$key = \$v" "$AUTO_SWITCH_CONFIG" > "$tmp" && mv "$tmp" "$AUTO_SWITCH_CONFIG" + elif echo "$value" | grep -qE '^[0-9]+$'; then + # Pure integer + jq --argjson v "$value" ".$key = \$v" "$AUTO_SWITCH_CONFIG" > "$tmp" && mv "$tmp" "$AUTO_SWITCH_CONFIG" + else + jq --arg v "$value" ".$key = \$v" "$AUTO_SWITCH_CONFIG" > "$tmp" && mv "$tmp" "$AUTO_SWITCH_CONFIG" + fi + chmod 600 "$AUTO_SWITCH_CONFIG" +} + +get_auto_switch_state() { + if [ -f "$AUTO_SWITCH_STATE" ]; then + jq -r ".$1 // empty" "$AUTO_SWITCH_STATE" 2>/dev/null + fi +} + +set_auto_switch_state() { + local on_fallback="$1" original="$2" fallback="$3" reason="$4" + local now + now=$(date +%s) + local next_reset + next_reset=$(compute_next_reset) + cat > "$AUTO_SWITCH_STATE" </dev/null || date "+%Y-%m-%d") + target_epoch=$(TZ="$reset_tz" date -d "${today_date} ${reset_time}" "+%s" 2>/dev/null || echo "") + + if [ -z "$target_epoch" ]; then + # macOS/BSD fallback + target_epoch=$(python3 -c " +import datetime, zoneinfo +tz = zoneinfo.ZoneInfo('$reset_tz') +now = datetime.datetime.now(tz) +reset = now.replace(hour=${reset_time%%:*}, minute=${reset_time##*:}, second=0, microsecond=0) +if reset <= now: + reset += datetime.timedelta(days=1) +print(int(reset.timestamp())) +" 2>/dev/null || echo "") + else + if [ "$target_epoch" -le "$now_epoch" ]; then + target_epoch=$((target_epoch + 86400)) + fi + fi + + if [ -n "$target_epoch" ]; then + TZ="$reset_tz" date -d "@$target_epoch" "+%Y-%m-%dT%H:%M:%S%:z" 2>/dev/null || \ + date -r "$target_epoch" "+%Y-%m-%dT%H:%M:%S%z" 2>/dev/null || \ + echo "$target_epoch" + fi +} + +is_past_reset_time() { + local next_reset + next_reset=$(get_auto_switch_state "next_reset") + [ -z "$next_reset" ] && return 1 + + local now_epoch reset_epoch + now_epoch=$(date +%s) + + reset_epoch=$(date -d "$next_reset" "+%s" 2>/dev/null || echo "") + if [ -z "$reset_epoch" ]; then + case "$next_reset" in + [0-9]*) reset_epoch="$next_reset" ;; + esac + fi + + [ -n "$reset_epoch" ] && [ "$now_epoch" -ge "$reset_epoch" ] +} + +get_next_fallback() { + local current_profile="$1" + local fallbacks + fallbacks=$(jq -r '.fallback_profiles[]' "$AUTO_SWITCH_CONFIG" 2>/dev/null) + [ -z "$fallbacks" ] && return 1 + + local result="" + while IFS= read -r fb; do + if [ "$fb" != "$current_profile" ] && profile_exists "$fb"; then + result="$fb" + break + fi + done <&2 + return 1 + } + + set_auto_switch_state "true" "${primary:-$current}" "$fallback" "$reason" + cmd_use "$fallback" +} + +do_auto_switch_back() { + local original + original=$(get_auto_switch_state "original_profile") + [ -z "$original" ] && return 1 + + if ! profile_exists "$original"; then + echo "error: original profile '$original' no longer exists." >&2 + return 1 + fi + + clear_auto_switch_state + cmd_use "$original" +} diff --git a/plugins/claude-switcher/scripts/lib/completions.sh b/plugins/claude-switcher/scripts/lib/completions.sh new file mode 100644 index 0000000..0801351 --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/completions.sh @@ -0,0 +1,94 @@ +# shellcheck shell=bash +# Shell completion generators for claude-switcher +# Sourced by the main entry point -- not executed directly + +cmd_completions() { + local shell="${1:-bash}" + case "$shell" in + bash) + cat <<'BASH_COMP' +_claude_switcher() { + local cur prev commands profiles + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + commands="save use prev list show status delete rename setup setup-plugin auto-config limit-hit uninstall help version completions" + + case "$prev" in + use|delete|show) + profiles=$(ls "${HOME}/.claude-switcher/profiles/" 2>/dev/null) + COMPREPLY=($(compgen -W "$profiles" -- "$cur")) + return 0 + ;; + rename) + if [ "$COMP_CWORD" -eq 2 ]; then + profiles=$(ls "${HOME}/.claude-switcher/profiles/" 2>/dev/null) + COMPREPLY=($(compgen -W "$profiles" -- "$cur")) + fi + return 0 + ;; + claude-switcher) + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + return 0 + ;; + esac + + COMPREPLY=($(compgen -W "$commands" -- "$cur")) +} +complete -F _claude_switcher claude-switcher +BASH_COMP + ;; + zsh) + cat <<'ZSH_COMP' +#compdef claude-switcher + +_claude_switcher() { + local -a commands profiles + commands=( + 'save:Save current auth as a named profile' + 'use:Switch to a saved profile' + 'prev:Switch to previous profile' + 'list:List all saved profiles' + 'show:Show profile details' + 'status:Show active profile and auth status' + 'delete:Delete a saved profile' + 'rename:Rename a profile' + 'setup:Interactive first-time setup' + 'setup-plugin:Set up rate limit capture' + 'auto-config:Configure auto-switching' + 'limit-hit:Trigger fallback switch' + 'uninstall:Remove claude-switcher' + 'help:Show help' + 'version:Show version' + 'completions:Generate shell completions' + ) + + _get_profiles() { + local -a profiles + profiles=(${(f)"$(ls "${HOME}/.claude-switcher/profiles/" 2>/dev/null)"}) + _describe 'profile' profiles + } + + case "$words[2]" in + use|delete|show) + _get_profiles + ;; + rename) + if (( CURRENT == 3 )); then + _get_profiles + fi + ;; + *) + _describe 'command' commands + ;; + esac +} + +_claude_switcher "$@" +ZSH_COMP + ;; + *) + die "unknown shell: $shell. Supported: bash, zsh" + ;; + esac +} diff --git a/plugins/claude-switcher/scripts/lib/helpers.sh b/plugins/claude-switcher/scripts/lib/helpers.sh new file mode 100644 index 0000000..3e5705d --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/helpers.sh @@ -0,0 +1,150 @@ +# shellcheck shell=bash +# Utility functions for claude-switcher +# Sourced by the main entry point -- not executed directly + +die() { + echo "error: $*" >&2 + exit 1 +} + +check_jq() { + if ! command -v jq >/dev/null 2>&1; then + install_hint="" + if command -v apt >/dev/null 2>&1; then + install_hint="sudo apt install jq" + elif command -v brew >/dev/null 2>&1; then + install_hint="brew install jq" + elif command -v dnf >/dev/null 2>&1; then + install_hint="sudo dnf install jq" + elif command -v pacman >/dev/null 2>&1; then + install_hint="sudo pacman -S jq" + fi + die "jq is required but not installed.${install_hint:+ Install: $install_hint}" + fi +} + +ensure_dirs() { + mkdir -p "$PROFILES_DIR" && chmod 700 "$SWITCHER_DIR" "$PROFILES_DIR" + mkdir -p "$BACKUP_DIR" && chmod 700 "$BACKUP_DIR" +} + +init_config() { + if [ ! -f "$CONFIG_FILE" ]; then + echo '{"active_profile":null,"previous_profile":null}' | jq . > "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" + fi +} + +get_config_value() { + jq -r ".$1 // empty" "$CONFIG_FILE" 2>/dev/null +} + +set_config_value() { + local key="$1" value="$2" + local tmp + tmp=$(mktemp "${CONFIG_FILE}.XXXXXX") + jq --arg v "$value" ".$key = \$v" "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" +} + +validate_profile_name() { + local name="$1" + case "$name" in + *[!a-zA-Z0-9_-]*) die "invalid profile name '$name'. Use only letters, numbers, hyphens, and underscores." ;; + '') die "profile name cannot be empty." ;; + esac +} + +profile_exists() { + [ -d "${PROFILES_DIR}/$1" ] && [ -f "${PROFILES_DIR}/$1/credentials.json" ] +} + +check_credentials_exist() { + if [ ! -f "$CLAUDE_CREDS" ]; then + die "no credentials found at $CLAUDE_CREDS. Log in first: claude auth login" + fi +} + +check_config_exists() { + if [ ! -f "$CLAUDE_CONFIG" ]; then + die "no config found at $CLAUDE_CONFIG. Claude Code may not be installed." + fi +} + +validate_json_file() { + local file="$1" desc="$2" + if ! jq empty "$file" 2>/dev/null; then + die "$desc at $file is malformed JSON." + fi +} + +check_active_sessions() { + local session_dir="${CLAUDE_DIR}/sessions" + if [ -d "$session_dir" ]; then + for session_file in "$session_dir"/*.json; do + [ -f "$session_file" ] || continue + local pid + pid=$(jq -r '.pid // empty' "$session_file" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "warning: active Claude Code session detected (PID $pid). Switching mid-session may cause issues." >&2 + return 0 + fi + done + fi +} + +format_timestamp() { + date -d "@$1" "+%Y-%m-%d %H:%M" 2>/dev/null || date -r "$1" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "unknown" +} + +check_token_expiry() { + local creds_file="$1" + local expires_at + expires_at=$(jq -r '.claudeAiOauth.expiresAt // empty' "$creds_file" 2>/dev/null) + if [ -n "$expires_at" ]; then + local now_ms + now_ms=$(date +%s%3N 2>/dev/null || echo "$(($(date +%s) * 1000))") + if [ "$expires_at" -lt "$now_ms" ] 2>/dev/null; then + return 1 + fi + fi + return 0 +} + +get_profile_email() { + jq -r '.emailAddress // "unknown"' "$1" 2>/dev/null +} + +get_profile_org() { + jq -r '.organizationName // empty' "$1" 2>/dev/null +} + +get_profile_sub() { + local creds_file="$1" meta_file="$2" + local sub + sub=$(jq -r '.claudeAiOauth.subscriptionType // empty' "$creds_file" 2>/dev/null) + if [ -z "$sub" ]; then + sub=$(jq -r '.subscriptionType // "unknown"' "$meta_file" 2>/dev/null) + fi + echo "$sub" +} + +format_profile_summary() { + local creds_file="$1" meta_file="$2" + local email org sub + email=$(get_profile_email "$meta_file") + org=$(get_profile_org "$meta_file") + sub=$(get_profile_sub "$creds_file" "$meta_file") + echo "${email}${org:+, $org}, ${sub} subscription" +} + +backup_current_state() { + if [ -f "$CLAUDE_CREDS" ]; then + cp "$CLAUDE_CREDS" "${BACKUP_DIR}/credentials.json" + chmod 600 "${BACKUP_DIR}/credentials.json" + fi + if [ -f "$CLAUDE_CONFIG" ]; then + jq '.oauthAccount // empty' "$CLAUDE_CONFIG" > "${BACKUP_DIR}/account-metadata.json" 2>/dev/null + chmod 600 "${BACKUP_DIR}/account-metadata.json" + fi +} diff --git a/plugins/claude-switcher/scripts/lib/profiles.sh b/plugins/claude-switcher/scripts/lib/profiles.sh new file mode 100644 index 0000000..1d60a5e --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/profiles.sh @@ -0,0 +1,237 @@ +# shellcheck shell=bash +# Profile management commands for claude-switcher +# Sourced by the main entry point -- not executed directly + +cmd_save() { + local name="" force=false + while [ $# -gt 0 ]; do + case "$1" in + --force|-f) force=true; shift ;; + -*) die "unknown option: $1" ;; + *) name="$1"; shift ;; + esac + done + + [ -z "$name" ] && die "usage: claude-switcher save [--force]" + validate_profile_name "$name" + check_credentials_exist + check_config_exists + validate_json_file "$CLAUDE_CREDS" "credentials" + validate_json_file "$CLAUDE_CONFIG" "config" + + if profile_exists "$name" && [ "$force" != true ]; then + die "profile '$name' already exists. Use --force to overwrite." + fi + + local profile_dir="${PROFILES_DIR}/${name}" + mkdir -p "$profile_dir" && chmod 700 "$profile_dir" + + cp "$CLAUDE_CREDS" "${profile_dir}/credentials.json" + chmod 600 "${profile_dir}/credentials.json" + + jq '.oauthAccount // {}' "$CLAUDE_CONFIG" > "${profile_dir}/account-metadata.json" + chmod 600 "${profile_dir}/account-metadata.json" + + echo "{\"saved_at\":$(date +%s),\"last_used\":$(date +%s)}" | jq . > "${profile_dir}/meta.json" + chmod 600 "${profile_dir}/meta.json" + + set_config_value "active_profile" "$name" + + local summary + summary=$(format_profile_summary "${profile_dir}/credentials.json" "${profile_dir}/account-metadata.json") + echo "Saved profile \"${name}\" ($summary)" +} + +cmd_use() { + local name="$1" + [ -z "${name:-}" ] && die "usage: claude-switcher use " + + if [ "$name" = "prev" ] || [ "$name" = "-" ]; then + name=$(get_config_value "previous_profile") + [ -z "$name" ] && die "no previous profile to switch to." + fi + + validate_profile_name "$name" + profile_exists "$name" || die "profile '$name' not found. Run 'claude-switcher list' to see available profiles." + + local current + current=$(get_config_value "active_profile") + if [ "$current" = "$name" ]; then + echo "Already on profile \"${name}\"" + return 0 + fi + + local profile_dir="${PROFILES_DIR}/${name}" + + validate_json_file "${profile_dir}/credentials.json" "profile credentials" + validate_json_file "${profile_dir}/account-metadata.json" "profile account metadata" + + if ! check_token_expiry "${profile_dir}/credentials.json"; then + echo "warning: tokens in profile '$name' appear expired. You may need to re-authenticate and re-save." >&2 + fi + + check_active_sessions + + backup_current_state + + check_config_exists + local tmp_creds tmp_config + tmp_creds=$(mktemp "${CLAUDE_CREDS}.XXXXXX") + cp "${profile_dir}/credentials.json" "$tmp_creds" + chmod 600 "$tmp_creds" + mv "$tmp_creds" "$CLAUDE_CREDS" + + local account_data + account_data=$(cat "${profile_dir}/account-metadata.json") + tmp_config=$(mktemp "${CLAUDE_CONFIG}.XXXXXX") + jq --argjson acct "$account_data" '.oauthAccount = $acct' "$CLAUDE_CONFIG" > "$tmp_config" + chmod 600 "$tmp_config" + mv "$tmp_config" "$CLAUDE_CONFIG" + + if [ -n "$current" ]; then + set_config_value "previous_profile" "$current" + fi + set_config_value "active_profile" "$name" + + local meta_file="${profile_dir}/meta.json" + if [ -f "$meta_file" ]; then + local tmp_meta + tmp_meta=$(mktemp "${meta_file}.XXXXXX") + jq --arg ts "$(date +%s)" '.last_used = ($ts | tonumber)' "$meta_file" > "$tmp_meta" && mv "$tmp_meta" "$meta_file" + chmod 600 "$meta_file" + fi + + local summary + summary=$(format_profile_summary "${profile_dir}/credentials.json" "${profile_dir}/account-metadata.json") + echo "Switched to profile \"${name}\" ($summary)" +} + +cmd_list() { + if [ ! -d "$PROFILES_DIR" ] || [ -z "$(ls -A "$PROFILES_DIR" 2>/dev/null)" ]; then + echo "No profiles saved. Run 'claude-switcher save ' to create one." + return 0 + fi + + local active + active=$(get_config_value "active_profile") + + printf "%-2s %-15s %-35s %-20s %s\n" "" "PROFILE" "EMAIL" "ORG" "TYPE" + for profile_dir in "$PROFILES_DIR"/*/; do + [ -d "$profile_dir" ] || continue + local name + name=$(basename "$profile_dir") + local meta_file="${profile_dir}/account-metadata.json" + [ -f "$meta_file" ] || continue + + local creds_file="${profile_dir}/credentials.json" + local email org sub marker="" + email=$(get_profile_email "$meta_file") + org=$(get_profile_org "$meta_file") + [ -z "$org" ] && org="-" + sub=$(get_profile_sub "$creds_file" "$meta_file") + + if [ "$name" = "$active" ]; then + marker="*" + fi + + printf "%-2s %-15s %-35s %-20s %s\n" "$marker" "$name" "$email" "$org" "$sub" + done +} + +cmd_delete() { + local name="$1" + [ -z "${name:-}" ] && die "usage: claude-switcher delete " + validate_profile_name "$name" + profile_exists "$name" || die "profile '$name' not found." + + local active + active=$(get_config_value "active_profile") + if [ "$name" = "$active" ]; then + printf "Profile '%s' is currently active. Delete anyway? [y/N] " "$name" + read -r confirm + case "$confirm" in + [yY]|[yY][eE][sS]) ;; + *) echo "Cancelled."; return 0 ;; + esac + set_config_value "active_profile" "" + fi + + rm -rf "${PROFILES_DIR:?}/${name}" + echo "Deleted profile \"${name}\"" +} + +cmd_rename() { + local old_name="${1:-}" new_name="${2:-}" + [ -z "$old_name" ] || [ -z "$new_name" ] && die "usage: claude-switcher rename " + validate_profile_name "$old_name" + validate_profile_name "$new_name" + profile_exists "$old_name" || die "profile '$old_name' not found." + profile_exists "$new_name" && die "profile '$new_name' already exists." + + mv "${PROFILES_DIR}/${old_name}" "${PROFILES_DIR}/${new_name}" + + local active previous + active=$(get_config_value "active_profile") + previous=$(get_config_value "previous_profile") + [ "$active" = "$old_name" ] && set_config_value "active_profile" "$new_name" + [ "$previous" = "$old_name" ] && set_config_value "previous_profile" "$new_name" + + echo "Renamed profile \"${old_name}\" to \"${new_name}\"" +} + +cmd_show() { + local name="${1:-}" + [ -z "$name" ] && die "usage: claude-switcher show " + validate_profile_name "$name" + profile_exists "$name" || die "profile '$name' not found." + + local profile_dir="${PROFILES_DIR}/${name}" + local meta_file="${profile_dir}/account-metadata.json" + local creds_file="${profile_dir}/credentials.json" + local info_file="${profile_dir}/meta.json" + + local active + active=$(get_config_value "active_profile") + local active_marker="" + [ "$name" = "$active" ] && active_marker=" (active)" + + echo "Profile: ${name}${active_marker}" + + if [ -f "$meta_file" ]; then + local email org sub display_name + email=$(get_profile_email "$meta_file") + org=$(get_profile_org "$meta_file") + sub=$(get_profile_sub "$creds_file" "$meta_file") + display_name=$(jq -r '.displayName // empty' "$meta_file") + echo " Email: $email" + [ -n "$display_name" ] && echo " Display Name: $display_name" + [ -n "$org" ] && echo " Organization: $org" + echo " Subscription: $sub" + fi + + if [ -f "$creds_file" ]; then + local tier expires_at + tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$creds_file") + expires_at=$(jq -r '.claudeAiOauth.expiresAt // empty' "$creds_file") + echo " Rate Limit: $tier" + if [ -n "$expires_at" ]; then + local expires_sec=$((expires_at / 1000)) + echo " Token Expiry: $(format_timestamp "$expires_sec")" + if ! check_token_expiry "$creds_file"; then + echo " ** TOKEN EXPIRED ** -- re-authenticate and re-save this profile" + fi + fi + fi + + if [ -f "$info_file" ]; then + local saved_at last_used + saved_at=$(jq -r '.saved_at // empty' "$info_file") + last_used=$(jq -r '.last_used // empty' "$info_file") + [ -n "$saved_at" ] && echo " Saved: $(format_timestamp "$saved_at")" + [ -n "$last_used" ] && echo " Last Used: $(format_timestamp "$last_used")" + fi +} + +cmd_prev() { + cmd_use "prev" +} diff --git a/plugins/claude-switcher/scripts/lib/rate-limits.sh b/plugins/claude-switcher/scripts/lib/rate-limits.sh new file mode 100644 index 0000000..6e6195d --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/rate-limits.sh @@ -0,0 +1,80 @@ +# shellcheck shell=bash +# Rate limit monitoring for claude-switcher +# Sourced by the main entry point -- not executed directly + +get_rate_limit_five_hour() { + if [ -f "$RATE_LIMITS_FILE" ]; then + jq -r '.five_hour.percent // 0' "$RATE_LIMITS_FILE" 2>/dev/null + else + echo "0" + fi +} + +get_rate_limit_seven_day() { + if [ -f "$RATE_LIMITS_FILE" ]; then + jq -r '.seven_day.percent // 0' "$RATE_LIMITS_FILE" 2>/dev/null + else + echo "0" + fi +} + +get_max_rate_limit_percent() { + local five_hour seven_day + five_hour=$(get_rate_limit_five_hour) + seven_day=$(get_rate_limit_seven_day) + local five_int seven_int + five_int=${five_hour%.*} + seven_int=${seven_day%.*} + five_int=${five_int:-0} + seven_int=${seven_int:-0} + if [ "$five_int" -ge "$seven_int" ] 2>/dev/null; then + echo "$five_int" + else + echo "$seven_int" + fi +} + +check_rate_limits_and_switch() { + init_auto_switch_config + + local enabled + enabled=$(get_auto_config_value "enabled") + if [ "$enabled" != "true" ]; then + return + fi + + if [ -f "$AUTO_SWITCH_STATE" ]; then + local on_fb + on_fb=$(get_auto_switch_state "on_fallback") + if [ "$on_fb" = "true" ]; then + return + fi + fi + + if [ ! -f "$RATE_LIMITS_FILE" ]; then + return + fi + + local threshold max_pct + threshold=$(get_auto_config_value "preemptive_switch_percent") + threshold="${threshold:-97}" + max_pct=$(get_max_rate_limit_percent) + + if [ "$max_pct" -ge "$threshold" ] 2>/dev/null; then + local five_hour seven_day + five_hour=$(get_rate_limit_five_hour) + seven_day=$(get_rate_limit_seven_day) + do_auto_switch_to_fallback "preemptive_5h:${five_hour}%_7d:${seven_day}%" 2>&1 || true + fi +} + +format_rate_limits() { + if [ ! -f "$RATE_LIMITS_FILE" ]; then + echo "(no data -- run /setup to enable rate limit capture)" + return + fi + local five_hour seven_day + five_hour=$(get_rate_limit_five_hour) + seven_day=$(get_rate_limit_seven_day) + echo "5h: ${five_hour}%, 7d: ${seven_day}%" +} diff --git a/plugins/claude-switcher/scripts/lib/setup.sh b/plugins/claude-switcher/scripts/lib/setup.sh new file mode 100644 index 0000000..64aa759 --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/setup.sh @@ -0,0 +1,237 @@ +# shellcheck shell=bash +# Setup, uninstall, and plugin configuration for claude-switcher +# Sourced by the main entry point -- not executed directly + +STATUSLINE_SNIPPET_MARKER="# claude-switcher: capture rate limits" + +cmd_setup() { + echo "claude-switcher setup" + echo "=====================" + echo "" + + ensure_dirs + init_config + + local current_email="" + if [ -f "$CLAUDE_CREDS" ] && [ -f "$CLAUDE_CONFIG" ]; then + current_email=$(jq -r '.oauthAccount.emailAddress // empty' "$CLAUDE_CONFIG" 2>/dev/null) + fi + + if [ -n "$current_email" ]; then + echo "You are currently logged in as: $current_email" + printf "Save this as your first profile? [Y/n] " + read -r confirm + case "$confirm" in + [nN]|[nN][oO]) ;; + *) + printf "Profile name for this account: " + read -r name1 + [ -z "$name1" ] && die "profile name cannot be empty." + cmd_save "$name1" + echo "" + ;; + esac + else + echo "You are not currently logged in." + echo "Log in to your first account:" + echo " claude auth login" + echo "" + echo "Then re-run: claude-switcher setup" + return 0 + fi + + echo "Now log in to your second account." + echo "Run this command, then come back:" + echo "" + echo " claude auth logout && claude auth login" + echo "" + printf "Press Enter when you're logged in to the second account..." + read -r + + local new_email="" + if [ -f "$CLAUDE_CONFIG" ]; then + new_email=$(jq -r '.oauthAccount.emailAddress // empty' "$CLAUDE_CONFIG" 2>/dev/null) + fi + + if [ -z "$new_email" ]; then + die "doesn't look like you're logged in. Run 'claude auth login' and try again." + fi + + if [ -n "$current_email" ] && [ "$new_email" = "$current_email" ]; then + die "you're still logged in as $current_email. Log in with a different account." + fi + + local new_uuid + new_uuid=$(jq -r '.oauthAccount.accountUuid // empty' "$CLAUDE_CONFIG" 2>/dev/null) + for profile_dir in "$PROFILES_DIR"/*/; do + [ -d "$profile_dir" ] || continue + local existing_uuid + existing_uuid=$(jq -r '.accountUuid // empty' "${profile_dir}/account-metadata.json" 2>/dev/null) + if [ "$new_uuid" = "$existing_uuid" ]; then + die "this account (UUID: $new_uuid) is already saved as profile '$(basename "$profile_dir")'. Log in with a different account." + fi + done + + echo "Logged in as: $new_email" + printf "Profile name for this account: " + read -r name2 + [ -z "$name2" ] && die "profile name cannot be empty." + cmd_save "$name2" + echo "" + + echo "Available profiles:" + cmd_list + echo "" + printf "Default profile to activate now: " + read -r default_profile + if [ -n "$default_profile" ] && profile_exists "$default_profile"; then + cmd_use "$default_profile" + else + echo "No valid profile selected. You can switch later with: claude-switcher use " + fi + + echo "" + echo "Setup complete. Switch anytime with: claude-switcher use " +} + +cmd_setup_plugin() { + local claude_settings="${CLAUDE_DIR}/settings.json" + local statusline_script="${CLAUDE_DIR}/statusline-command.sh" + + ensure_dirs + + echo "claude-switcher setup" + echo "=====================" + echo "" + + local has_statusline=false + if [ -f "$claude_settings" ]; then + if jq -e '.statusLine' "$claude_settings" >/dev/null 2>&1; then + has_statusline=true + fi + fi + + if [ "$has_statusline" = false ]; then + echo "No status line configured. Creating one..." + + cat > "$statusline_script" <<'STATUSLINE' +#!/bin/sh +input=$(cat) + +# claude-switcher: capture rate limits +printf '%s' "$input" | jq '{five_hour:.rate_limits.five_hour,seven_day:.rate_limits.seven_day}' > ~/.claude-switcher/rate-limits.json 2>/dev/null || true + +cwd=$(echo "$input" | jq -r '.cwd // empty') +model=$(echo "$input" | jq -r '.model.display_name // .model.id // "unknown"') +short_dir="${cwd#"${HOME}"}" +[ "$short_dir" != "$cwd" ] && short_dir="~${short_dir}" +echo "${short_dir} | ${model}" +STATUSLINE + chmod +x "$statusline_script" + + if [ ! -f "$claude_settings" ]; then + echo '{}' > "$claude_settings" + fi + local tmp + tmp=$(mktemp "${claude_settings}.XXXXXX") + jq '.statusLine = {"type": "command", "command": "bash ~/.claude/statusline-command.sh"}' "$claude_settings" > "$tmp" && mv "$tmp" "$claude_settings" + echo "Created status line: $statusline_script" + else + local statusline_cmd + statusline_cmd=$(jq -r '.statusLine.command // empty' "$claude_settings") + + local script_path="" + # Extract path from "bash /path/to/script" or "/path/to/script" + script_path=$(echo "$statusline_cmd" | sed -n 's/^bash[[:space:]]\{1,\}//p') + if [ -z "$script_path" ]; then + case "$statusline_cmd" in + /*) script_path="$statusline_cmd" ;; + esac + fi + # Expand ~ + case "$script_path" in + "~"*) script_path="${HOME}${script_path#\~}" ;; + esac + + if [ -z "$script_path" ] || [ ! -f "$script_path" ]; then + die "could not find status line script at '$script_path'. Check your statusLine config in $claude_settings" + fi + + if grep -q "$STATUSLINE_SNIPPET_MARKER" "$script_path" 2>/dev/null; then + echo "Rate limit capture already configured in $script_path" + else + echo "Injecting rate limit capture into $script_path..." + + local inject_after="" + if grep -q 'input=$(cat)' "$script_path"; then + inject_after='input=$(cat)' + elif grep -q '_stdin=$(cat)' "$script_path"; then + inject_after='_stdin=$(cat)' + fi + + if [ -z "$inject_after" ]; then + die "could not find stdin read line in $script_path. Add manually after the line that reads stdin." + fi + + local tmp + tmp=$(mktemp "${script_path}.XXXXXX") + awk -v marker="$STATUSLINE_SNIPPET_MARKER" -v target="$inject_after" ' + { + print + if ($0 == target) { + print "" + print marker + print "printf '\''%s'\'' \"$input\" | jq '\''{five_hour:.rate_limits.five_hour,seven_day:.rate_limits.seven_day}'\'' > ~/.claude-switcher/rate-limits.json 2>/dev/null || true" + } + }' "$script_path" > "$tmp" && mv "$tmp" "$script_path" + chmod +x "$script_path" + echo "Injected rate limit capture after '${inject_after}'" + fi + fi + + echo "" + echo "Rate limit data will be captured to: $RATE_LIMITS_FILE" + echo "The status line updates this file continuously." + echo "" + echo "Next steps:" + echo " 1. Save your profiles (if not done already)" + echo " 2. Configure auto-switch:" + local script_dir + script_dir=$(dirname "$0") + echo " ${script_dir}/claude-switcher.sh auto-config enable" + echo " ${script_dir}/claude-switcher.sh auto-config primary work" + echo " ${script_dir}/claude-switcher.sh auto-config fallback personal" + echo " ${script_dir}/claude-switcher.sh auto-config threshold 97" +} + +cmd_uninstall() { + echo "claude-switcher uninstall" + echo "=========================" + echo "" + + printf "Remove saved profiles? [y/N] " + read -r confirm + case "$confirm" in + [yY]|[yY][eE][sS]) + rm -rf "${SWITCHER_DIR:?}" + echo "Removed all profiles and config from $SWITCHER_DIR" + ;; + *) + echo "Profiles kept at $SWITCHER_DIR" + ;; + esac + + local settings_file="${CLAUDE_DIR}/settings.json" + if [ -f "$settings_file" ]; then + local plugin_path + plugin_path=$(cd "$(dirname "$(dirname "$0")")" 2>/dev/null && pwd) || plugin_path="" + if [ -n "$plugin_path" ] && jq -e ".plugins | index(\"$plugin_path\")" "$settings_file" >/dev/null 2>&1; then + local tmp + tmp=$(mktemp "${settings_file}.XXXXXX") + jq --arg p "$plugin_path" '.plugins = (.plugins | map(select(. != $p)))' "$settings_file" > "$tmp" && mv "$tmp" "$settings_file" + echo "Deregistered Claude Code plugin" + fi + fi + + echo "Uninstall complete. Remove the plugin from your Claude Code settings to fully unregister." +} diff --git a/plugins/claude-switcher/scripts/lib/status.sh b/plugins/claude-switcher/scripts/lib/status.sh new file mode 100644 index 0000000..cc0d537 --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/status.sh @@ -0,0 +1,78 @@ +# shellcheck shell=bash +# Status command for claude-switcher +# Sourced by the main entry point -- not executed directly + +cmd_status() { + local active + active=$(get_config_value "active_profile") + + if [ -z "$active" ]; then + echo "No active profile tracked by claude-switcher." + else + echo "Active profile: ${active}" + cmd_show "$active" + fi + + init_auto_switch_config + local auto_enabled + auto_enabled=$(get_auto_config_value "enabled") + echo "" + echo "Auto-switch: ${auto_enabled:-disabled}" + if [ "$auto_enabled" = "true" ]; then + local primary + primary=$(get_auto_config_value "primary_profile") + echo " Primary: ${primary:-(not set)}" + local fallbacks + fallbacks=$(jq -r '.fallback_profiles | join(", ")' "$AUTO_SWITCH_CONFIG" 2>/dev/null) + echo " Fallbacks: ${fallbacks:-(none)}" + + local threshold + threshold=$(get_auto_config_value "preemptive_switch_percent") + echo " Threshold: ${threshold:-97}%" + echo " Rate limits: $(format_rate_limits)" + + if [ -f "$AUTO_SWITCH_STATE" ]; then + local on_fb + on_fb=$(get_auto_switch_state "on_fallback") + if [ "$on_fb" = "true" ]; then + local orig reason next + orig=$(get_auto_switch_state "original_profile") + reason=$(get_auto_switch_state "reason") + next=$(get_auto_switch_state "next_reset") + echo " ** ON FALLBACK (was: $orig, reason: $reason) **" + echo " Next reset: ${next:-(unknown)}" + fi + fi + fi + + echo "" + echo "Live auth status:" + if command -v claude >/dev/null 2>&1; then + local live_status + live_status=$(claude auth status --json 2>/dev/null || echo '{}') + local live_email live_org live_sub live_logged_in + live_logged_in=$(echo "$live_status" | jq -r '.loggedIn // false') + if [ "$live_logged_in" = "true" ]; then + live_email=$(echo "$live_status" | jq -r '.email // "unknown"') + live_org=$(echo "$live_status" | jq -r '.orgName // empty') + live_sub=$(echo "$live_status" | jq -r '.subscriptionType // "unknown"') + echo " Logged in: yes" + echo " Email: $live_email" + [ -n "$live_org" ] && echo " Organization: $live_org" + echo " Subscription: $live_sub" + + if [ -n "$active" ] && profile_exists "$active"; then + local profile_email + profile_email=$(jq -r '.emailAddress // empty' "${PROFILES_DIR}/${active}/account-metadata.json") + if [ "$live_email" != "$profile_email" ]; then + echo "" + echo "warning: live auth email ($live_email) doesn't match active profile email ($profile_email)" + fi + fi + else + echo " Logged in: no" + fi + else + echo " (claude CLI not found)" + fi +} diff --git a/plugins/claude-switcher/scripts/lib/vars.sh b/plugins/claude-switcher/scripts/lib/vars.sh new file mode 100644 index 0000000..26fab10 --- /dev/null +++ b/plugins/claude-switcher/scripts/lib/vars.sh @@ -0,0 +1,17 @@ +# shellcheck shell=bash +# Shared variables for claude-switcher +# Sourced by the main entry point -- not executed directly +# shellcheck disable=SC2034 + +VERSION="2.0.0" + +CLAUDE_DIR="${HOME}/.claude" +CLAUDE_CREDS="${CLAUDE_DIR}/.credentials.json" +CLAUDE_CONFIG="${HOME}/.claude.json" +SWITCHER_DIR="${HOME}/.claude-switcher" +PROFILES_DIR="${SWITCHER_DIR}/profiles" +CONFIG_FILE="${SWITCHER_DIR}/config.json" +BACKUP_DIR="${SWITCHER_DIR}/.last-state" +AUTO_SWITCH_CONFIG="${SWITCHER_DIR}/auto-switch.json" +AUTO_SWITCH_STATE="${SWITCHER_DIR}/auto-switch-state.json" +RATE_LIMITS_FILE="${SWITCHER_DIR}/rate-limits.json" diff --git a/plugins/claude-switcher/scripts/on-post-tool-use.sh b/plugins/claude-switcher/scripts/on-post-tool-use.sh new file mode 100755 index 0000000..d34076a --- /dev/null +++ b/plugins/claude-switcher/scripts/on-post-tool-use.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +# PostToolUse hook: reads real rate limit data and preemptively switches. +# Runs async to avoid slowing down tool execution. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI="${SCRIPT_DIR}/claude-switcher.sh" + +AUTO_SWITCH_CONFIG="${HOME}/.claude-switcher/auto-switch.json" +if [ ! -f "$AUTO_SWITCH_CONFIG" ]; then + exit 0 +fi + +enabled=$(jq -r '.enabled // false' "$AUTO_SWITCH_CONFIG" 2>/dev/null) +if [ "$enabled" != "true" ]; then + exit 0 +fi + +sh "$CLI" check-limits 2>/dev/null || true + +exit 0 diff --git a/plugins/claude-switcher/scripts/on-stop-failure.sh b/plugins/claude-switcher/scripts/on-stop-failure.sh new file mode 100755 index 0000000..b373f3a --- /dev/null +++ b/plugins/claude-switcher/scripts/on-stop-failure.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu + +# StopFailure hook: fires when a turn ends due to API error. +# Detects rate limit errors and auto-switches to fallback profile. + +SWITCHER_DIR="${HOME}/.claude-switcher" +AUTO_SWITCH_CONFIG="${SWITCHER_DIR}/auto-switch.json" +AUTO_SWITCH_STATE="${SWITCHER_DIR}/auto-switch-state.json" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI="${SCRIPT_DIR}/claude-switcher.sh" + +INPUT=$(cat) + +if [ ! -f "$AUTO_SWITCH_CONFIG" ]; then + exit 0 +fi + +enabled=$(jq -r '.enabled // false' "$AUTO_SWITCH_CONFIG" 2>/dev/null) +if [ "$enabled" != "true" ]; then + exit 0 +fi + +if [ -f "$AUTO_SWITCH_STATE" ]; then + on_fb=$(jq -r '.on_fallback // false' "$AUTO_SWITCH_STATE" 2>/dev/null) + if [ "$on_fb" = "true" ]; then + exit 0 + fi +fi + +transcript_path=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) +error_info=$(echo "$INPUT" | jq -r '.error // .error_type // .message // empty' 2>/dev/null) + +is_rate_limit=false + +if [ -n "$error_info" ]; then + if echo "$error_info" | grep -iqE "rate.?limit|429|overloaded|usage.?limit|too many|quota|throttl|capacity"; then + is_rate_limit=true + fi +fi + +if [ "$is_rate_limit" = "false" ] && [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then + if tail -20 "$transcript_path" 2>/dev/null | grep -iqE "rate.?limit|429|overloaded|usage.?limit|too many requests|quota exceeded|throttl|capacity"; then + is_rate_limit=true + fi +fi + +if [ "$is_rate_limit" = "false" ]; then + exit 0 +fi + +sh "$CLI" limit-hit 2>&1 || true + +cat </dev/null) +if [ -z "$active" ]; then + exit 0 +fi + +# Auto-switch-back check +auto_switch_msg="" + +if [ -f "$AUTO_SWITCH_CONFIG" ] && [ -f "$AUTO_SWITCH_STATE" ]; then + enabled=$(jq -r '.enabled // false' "$AUTO_SWITCH_CONFIG" 2>/dev/null) + on_fb=$(jq -r '.on_fallback // false' "$AUTO_SWITCH_STATE" 2>/dev/null) + + if [ "$enabled" = "true" ] && [ "$on_fb" = "true" ]; then + next_reset=$(jq -r '.next_reset // empty' "$AUTO_SWITCH_STATE" 2>/dev/null) + original=$(jq -r '.original_profile // empty' "$AUTO_SWITCH_STATE" 2>/dev/null) + + should_switch_back=false + + if [ -n "$next_reset" ]; then + now_epoch=$(date +%s) + reset_epoch=$(date -d "$next_reset" "+%s" 2>/dev/null || echo "") + + if [ -z "$reset_epoch" ]; then + case "$next_reset" in + [0-9]*) reset_epoch="$next_reset" ;; + esac + fi + + if [ -n "$reset_epoch" ] && [ "$now_epoch" -ge "$reset_epoch" ]; then + should_switch_back=true + fi + fi + + if [ "$should_switch_back" = "true" ] && [ -n "$original" ]; then + sh "$CLI" use "$original" >/dev/null 2>&1 || true + + cat > "$AUTO_SWITCH_STATE" </dev/null 2>&1 || true + + active="$original" + auto_switch_msg=" (auto-switched back from fallback -- limit reset)" + else + reason=$(jq -r '.reason // empty' "$AUTO_SWITCH_STATE" 2>/dev/null) + auto_switch_msg=" (on fallback due to ${reason}, primary: ${original}, resets: ${next_reset:-unknown})" + fi + fi +fi + +# Build profile info +meta_file="${PROFILES_DIR}/${active}/account-metadata.json" +creds_file="${PROFILES_DIR}/${active}/credentials.json" + +if [ ! -f "$meta_file" ]; then + exit 0 +fi + +email=$(jq -r '.emailAddress // "unknown"' "$meta_file" 2>/dev/null) +org=$(jq -r '.organizationName // empty' "$meta_file" 2>/dev/null) +sub=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$creds_file" 2>/dev/null) + +# Rate limit info +usage_msg="" +if [ -f "$AUTO_SWITCH_CONFIG" ] && [ -f "$RATE_LIMITS_FILE" ]; then + enabled_check=$(jq -r '.enabled // false' "$AUTO_SWITCH_CONFIG" 2>/dev/null) + if [ "$enabled_check" = "true" ]; then + five_hour_pct=$(jq -r '.five_hour.percent // 0' "$RATE_LIMITS_FILE" 2>/dev/null) + seven_day_pct=$(jq -r '.seven_day.percent // 0' "$RATE_LIMITS_FILE" 2>/dev/null) + threshold=$(jq -r '.preemptive_switch_percent // 97' "$AUTO_SWITCH_CONFIG" 2>/dev/null) + usage_msg=" [5h: ${five_hour_pct}%, 7d: ${seven_day_pct}%, switches at ${threshold}%]" + fi +fi + +cat <