-
-
Save jwarkentin/225ffcfba14023315bdca24c0f7ee3f6 to your computer and use it in GitHub Desktop.
Manages Claude/Codex credential files to make it quick and easy to switch between accounts when a session limit is hit (only works on Linux since Mac uses keychain)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # cas - Claude/Codex Account Switch | |
| # Switches a tool's live auth file to an aliased credentials file. | |
| # | |
| # Claude aliases: ~/.claude/.credentials.<alias>.json | |
| # Codex aliases: ~/.codex/auth.<alias>.json | |
| # | |
| # The selected alias carries a ".cur" tag. If a tool replaces the symlink | |
| # during token refresh, cas uses that marker to recover the refreshed file. | |
| set -euo pipefail | |
| TOOLS=(claude codex) | |
| TOOL="" | |
| TOOL_DIR="" | |
| LINK_NAME="" | |
| FILE_PREFIX="" | |
| usage() { | |
| cat >&2 <<'EOF' | |
| Usage: | |
| cas <tool> <alias> Switch <tool> to <alias> | |
| cas <tool> Show the current account for <tool> | |
| cas Show current accounts | |
| cas -h, --help Show this help | |
| Tool names support unique partial matches: | |
| cas cl work # Claude | |
| cas co 2 # Codex | |
| When only ~/.claude or ~/.codex exists, a single argument is treated as the | |
| alias for that tool: | |
| cas work | |
| EOF | |
| } | |
| configure_tool() { | |
| TOOL="$1" | |
| case "$TOOL" in | |
| claude) | |
| TOOL_DIR="$HOME/.claude" | |
| LINK_NAME=".credentials.json" | |
| FILE_PREFIX=".credentials." | |
| ;; | |
| codex) | |
| TOOL_DIR="$HOME/.codex" | |
| LINK_NAME="auth.json" | |
| FILE_PREFIX="auth." | |
| ;; | |
| *) | |
| echo "Error: unsupported tool '$TOOL'." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| match_tool() { | |
| local requested="$1" tool match="" | |
| for tool in "${TOOLS[@]}"; do | |
| if [[ "$tool" == "$requested"* ]]; then | |
| if [ -n "$match" ]; then | |
| echo "Error: tool '$requested' is ambiguous ($match, $tool)." >&2 | |
| return 1 | |
| fi | |
| match="$tool" | |
| fi | |
| done | |
| if [ -z "$match" ]; then | |
| echo "Error: unknown tool '$requested'. Expected claude or codex." >&2 | |
| return 1 | |
| fi | |
| echo "$match" | |
| } | |
| existing_tools() { | |
| local tool | |
| for tool in "${TOOLS[@]}"; do | |
| case "$tool" in | |
| claude) [ -d "$HOME/.claude" ] && echo "$tool" ;; | |
| codex) [ -d "$HOME/.codex" ] && echo "$tool" ;; | |
| esac | |
| done | |
| } | |
| link_path() { | |
| echo "$TOOL_DIR/$LINK_NAME" | |
| } | |
| alias_path() { | |
| echo "$TOOL_DIR/${FILE_PREFIX}$1.json" | |
| } | |
| current_alias_path() { | |
| echo "$TOOL_DIR/${FILE_PREFIX}$1.cur.json" | |
| } | |
| # Echo the alias currently marked ".cur", or return 1 if none. | |
| find_cur() { | |
| local f base | |
| for f in "$TOOL_DIR"/"${FILE_PREFIX}"*.cur.json; do | |
| [ -e "$f" ] || continue | |
| base="${f##*/}" | |
| base="${base#"$FILE_PREFIX"}" | |
| echo "${base%.cur.json}" | |
| return 0 | |
| done | |
| return 1 | |
| } | |
| clear_marker() { | |
| local f | |
| for f in "$TOOL_DIR"/"${FILE_PREFIX}"*.cur.json; do | |
| [ -e "$f" ] || continue | |
| mv -f "$f" "${f%.cur.json}.json" | |
| done | |
| } | |
| # If the tool replaced its symlink with a real file, preserve the refreshed | |
| # credentials under the alias marked current. | |
| heal_if_clobbered() { | |
| local link cur | |
| link="$(link_path)" | |
| if [ -e "$link" ] && [ ! -L "$link" ]; then | |
| if cur="$(find_cur)"; then | |
| mv -f "$link" "$(current_alias_path "$cur")" | |
| echo "Recovered refreshed $TOOL credentials into ${FILE_PREFIX}${cur}.cur.json" | |
| else | |
| echo "Error: $link is a regular file and no $TOOL account is marked current." >&2 | |
| echo "Cannot determine which aliased file it belongs to; resolve manually." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| } | |
| show_current() { | |
| local link tgt alias cur | |
| link="$(link_path)" | |
| if [ -L "$link" ]; then | |
| tgt="$(basename "$(readlink "$link")")" | |
| alias="${tgt#"$FILE_PREFIX"}" | |
| alias="${alias%.cur.json}" | |
| alias="${alias%.json}" | |
| echo "$TOOL: $alias" | |
| elif [ -e "$link" ]; then | |
| if cur="$(find_cur)"; then | |
| echo "$TOOL: $cur ($link is a plain file after token refresh)" >&2 | |
| else | |
| echo "$TOOL: no current marker ($link is a plain file)" >&2 | |
| fi | |
| else | |
| echo "$TOOL: no account selected" >&2 | |
| fi | |
| } | |
| list_aliases() { | |
| local f base | |
| for f in "$TOOL_DIR"/"${FILE_PREFIX}"*.json; do | |
| [ -e "$f" ] || continue | |
| base="${f##*/}" | |
| base="${base#"$FILE_PREFIX"}" | |
| base="${base%.cur.json}" | |
| base="${base%.json}" | |
| [ "$f" = "$(link_path)" ] || echo " $base" | |
| done | sort -u >&2 | |
| } | |
| switch_account() { | |
| local alias="$1" plain current link | |
| plain="$(alias_path "$alias")" | |
| current="$(current_alias_path "$alias")" | |
| link="$(link_path)" | |
| if [ ! -f "$plain" ] && [ ! -f "$current" ]; then | |
| echo "Error: no $TOOL credentials file for alias '$alias'." >&2 | |
| echo "Available aliases:" >&2 | |
| list_aliases | |
| exit 1 | |
| fi | |
| heal_if_clobbered | |
| clear_marker | |
| mv -f "$plain" "$current" | |
| ln -sfn "${FILE_PREFIX}${alias}.cur.json" "$link" | |
| echo "Switched $TOOL to account '$alias' ($LINK_NAME -> ${FILE_PREFIX}${alias}.cur.json)" | |
| } | |
| if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then | |
| usage | |
| exit 0 | |
| fi | |
| if [ $# -gt 2 ]; then | |
| usage | |
| exit 1 | |
| fi | |
| mapfile -t existing < <(existing_tools) | |
| case $# in | |
| 0) | |
| if [ "${#existing[@]}" -eq 0 ]; then | |
| echo "No ~/.claude or ~/.codex directory exists." >&2 | |
| exit 1 | |
| fi | |
| for tool in "${existing[@]}"; do | |
| configure_tool "$tool" | |
| show_current | |
| done | |
| ;; | |
| 1) | |
| if [ "${#existing[@]}" -eq 1 ]; then | |
| configure_tool "${existing[0]}" | |
| switch_account "$1" | |
| else | |
| selected_tool="$(match_tool "$1")" || exit 1 | |
| configure_tool "$selected_tool" | |
| show_current | |
| fi | |
| ;; | |
| 2) | |
| selected_tool="$(match_tool "$1")" || exit 1 | |
| configure_tool "$selected_tool" | |
| switch_account "$2" | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment