Claude Code のアカウント切り替えにUIがない — Keychain に保管して往復する

夜間に走らせている記事生成バッチが、朝見たら落ちていた。

You're out of extra usage · resets Aug 31 at 1pm (Asia/Tokyo)

利用枠を使い切っていた。3日後まで待てない。別のアカウントに切り替えたい。

ところが VS Code の Claude Code 拡張には、アカウントを切り替えるUIがない。 調べて出てくるのは「認証情報を削除して再ログインする」という手順だけだった。往復のたびにブラウザでログインし直すことになる。

認証情報がどこにあるかを調べて、削除するのではなく保管して書き戻すようにしたら、再ログインは各アカウント1回だけで済むようになった。以降は1コマンドで往復できる。

なお、これは自分が持っている複数のアカウントを使い分ける話で、個人用と会社用を行き来するようなケースを想定している。

注意: この記事のスクリプトは認証情報そのものを操作する。手順を誤ると再ログインが必要になる可能性があるため、内容を理解したうえで自己責任で試してほしい。非公開の実装に依存しているので、Claude Code 側の変更で動かなくなることもある。

よく紹介されている手順と、その不満

検索して出てくるのはこの手順だ。

  1. security delete-generic-password -s "Claude Code-credentials" で認証情報を削除
  2. VS Code で Developer: Reload Window
  3. 認証画面が出るので、別のアカウントでブラウザからログイン

これは確かに動く。動くが、戻るときも同じ手順になる。切り替えるたびにブラウザが立ち上がり、アカウントを選び、承認する。1回2〜3分。日に何度も往復するなら無視できない。

そもそも「削除」しかしていないのが引っかかった。元の認証情報を捨てているから、戻るのに再ログインが要る。 捨てずに取っておけばいいのではないか。

認証情報はどこにあるか

macOS の Keychain に入っていた。

security find-generic-password -s "Claude Code-credentials"
"acct"<blob>="takuo"
"svce"<blob>="Claude Code-credentials"

サービス名が Claude Code-credentials、アカウント名がログインユーザー名。値は -w を付けると取れる。510バイトほどのJSONだった。

構造だけ書くとこうなっている(値は当然伏せる)。

claudeAiOauth.accessToken            <108文字>
claudeAiOauth.refreshToken           <108文字>
claudeAiOauth.expiresAt              1787909074125
claudeAiOauth.refreshTokenExpiresAt  <13文字>
claudeAiOauth.subscriptionType       max
claudeAiOauth.rateLimitTier          <22文字>

リフレッシュトークンが入っているのが重要だった。アクセストークンの期限が切れていても、これがあれば自動で更新される。つまり保管しておけば、期限を気にせず後から書き戻せる

方針:捨てずに、別の名前で Keychain に置く

やることは単純だった。

  • 保存: Claude Code-credentials の値を読んで、Claude Code-credentials::<名前> という別サービス名で書く
  • 切り替え: Claude Code-credentials::<名前> の値を読んで、Claude Code-credentials に書き戻す

security コマンドだけで完結する。

# 保存
SECRET="$(security find-generic-password -s "Claude Code-credentials" -a "$USER" -w)"
security add-generic-password -U -s "Claude Code-credentials::main" -a "$USER" -w "$SECRET"

# 書き戻す
SECRET="$(security find-generic-password -s "Claude Code-credentials::main" -a "$USER" -w)"
security add-generic-password -U -s "Claude Code-credentials" -a "$USER" -w "$SECRET"

-U は「既にあれば上書き」の意味。これがないと2回目で失敗する。

保管先も Keychain の中なので、平文のファイルは一切作らない。 ここは譲らないようにした。トークンをホームディレクトリに .json で置く実装も書けるが、認証情報をわざわざ保護の弱い場所に移す理由がない。

これをスクリプトにまとめて、こう使えるようにした。

claude-account list          # 保存済みと現在の状態
claude-account save <名前>   # いまのアカウントを保存
claude-account use <名前>    # 切り替え
claude-account logout        # ログアウト
現在: ログイン中  max / トークン有効(〜08/28 18:24)

保存済みアカウント:
* main  max / トークン有効(〜08/28 18:24)
  sub   max / トークン有効(〜08/28 23:31)

* = 現在ログイン中のもの

* が現在ログイン中のもの。保管した値と現在の値を比較して印を付けているだけで、特別なことはしていない。プラン種別とトークンの期限も出しているので、どちらが使えるかの見当がつく。

初回だけ、2つ目のアカウントを「捕まえる」ために1回ログインが要る。save mainlogout → リロード → もう一方でログイン → save sub。ここを通れば以降は不要になる。

安全のために入れたこと

道具として使うものなので、事故らない作りにした。

保存していないアカウントを黙って壊さない。 uselogout の前に、いまログイン中の認証情報がどこかに保管済みかを確認する。保管されていなければ警告して、その場で名前を付けて保存できるようにした。

警告: いまログイン中のアカウントはまだ保存されていません。
      このまま切り替えると、再ログインしないと戻れなくなります。
      先に保存しますか? 名前を入力(空Enterで保存せず続行):

「切り替えたら戻れなくなった」が起きないようにするのが、このツールで一番大事な部分だと思っている。

security dump-keychain を使わない。 保存済みの名前を列挙するのに最初これを使ったが、項目によっては項目ごとにGUIの許可ダイアログが出る。バックグラウンドで走らせたときに止まるのは困る。名前の一覧だけ別ファイルに平文で持つことにした。名前は秘密ではないので、これで困らない。

確認を挟む。 logout は実際に認証情報を消すので [y/N] を出す。

動作環境

検証した環境は以下のとおり。

項目バージョン
macOS26.6.2 (25G83) / Apple M4 / arm64
Claude Code2.1.121
VS Code1.134.0
シェル/bin/bash 3.2.57(macOS 標準)で動作確認済み
依存なしsecurity sed date のみ。すべて macOS 標準)

追加インストールは何も要らない。 Homebrew の bash 5 系も不要
<<<${var:-} は bash 3.2 でも使える)。

最初は JSON を読む部分を python3 -c で書いていたが、やめた。
macOS は 12.3 以降 python3 を標準同梱していない(手元に入っていたのは Xcode Command Line Tools 由来だった)。
素の Mac で動かない道具にはしたくなかったので、sed で2つの値を抜き、
date -r でエポックミリ秒を整形するだけにした。jq も使っていない。

sub="$(printf '%s' "$json" | sed -n 's/.*"subscriptionType":"\([^"]*\)".*/\1/p')"
exp="$(printf '%s' "$json" | sed -n 's/.*"expiresAt":\([0-9]*\).*/\1/p')"
when="$(date -r "$((exp / 1000))" "+%m/%d %H:%M")"

相手は機械生成のJSONなので、これで十分だった。

Linux や Windows では動かない。Keychain は macOS 固有のもので、他OSでは
認証情報の保存先が違う。

スクリプト全文

~/.claude/bin/claude-account として保存して実行権限を付ける。

mkdir -p ~/.claude/bin
# 下のスクリプトを ~/.claude/bin/claude-account として保存してから
chmod +x ~/.claude/bin/claude-account
echo 'export PATH="$HOME/.claude/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
#!/bin/bash
# Claude Code のアカウントを切り替える(macOS / VS Code 拡張・CLI 共通)。
#
# 認証情報は Keychain の generic-password に入っている:
#   サービス "Claude Code-credentials" / アカウント <ログインユーザー名>
# このスクリプトは、それを別のサービス名に複製して保管し、書き戻すことで切り替える。
# ★平文ファイルは一切作らない。保管先も Keychain の中。
#
# 使い方:
#   claude-account list              保存済みアカウントと現在の状態
#   claude-account save <名前>       いまログイン中のアカウントを <名前> で保存
#   claude-account use <名前>        <名前> に切り替える
#   claude-account logout            ログアウト(次回起動時に再ログインを求められる)
#
# 切り替え後は VS Code で Cmd+Shift+P → "Developer: Reload Window" が必要。
set -euo pipefail

SVC="Claude Code-credentials"
ACCT="$(id -un)"
PREFIX="Claude Code-credentials::"

die() { printf '%s\n' "$*" >&2; exit 1; }

# 現在ログイン中の認証情報を取り出す(無ければ空)。
active_secret() { security find-generic-password -s "$SVC" -a "$ACCT" -w 2>/dev/null || true; }
saved_secret()  { security find-generic-password -s "${PREFIX}$1" -a "$ACCT" -w 2>/dev/null || true; }

# Keychain へ書き込む。-U で既存を上書きする。
put() {
  # 注: security は値をコマンド引数で受け取るため、実行中の一瞬だけ ps に見える。
  #     単一ユーザーの Mac を前提とした割り切り。平文ファイルより安全側。
  security add-generic-password -U -s "$1" -a "$ACCT" -w "$2"
}

# 認証JSONから人が読める情報だけ取り出す(トークンは出さない)。
describe() {
  # 認証JSONから人が読める情報だけ取り出す(トークンは出さない)。
  # ★python や jq に依存させない。macOS は 12.3 以降 python3 を標準同梱していないため、
  #   素の Mac でも動くよう sed と date だけで済ませる。
  local json sub exp when state
  json="$(cat)"
  sub="$(printf '%s' "$json" | sed -n 's/.*"subscriptionType":"\([^"]*\)".*/\1/p')"
  exp="$(printf '%s' "$json" | sed -n 's/.*"expiresAt":\([0-9]*\).*/\1/p')"
  if [ -z "$sub" ] && [ -z "$exp" ]; then
    echo "(解析できません)"; return
  fi
  if [ -n "$exp" ]; then
    # ミリ秒 → 秒。date -r は BSD(macOS) の書式。
    when="$(date -r "$((exp / 1000))" "+%m/%d %H:%M" 2>/dev/null || echo "?")"
    if [ "$((exp / 1000))" -lt "$(date +%s)" ]; then state="期限切れ"; else state="有効"; fi
  else
    when="?"; state="不明"
  fi
  echo "${sub:-?} / トークン${state}(〜${when})"
}

# 保存した名前の一覧。★security dump-keychain は項目ごとに GUI の許可を求めて
# 止まることがあるので使わない。名前は秘密ではないので平文で持ってよい。
INDEX="$HOME/.claude/claude-accounts.list"
list_saved() { [ -f "$INDEX" ] && sort -u "$INDEX" || true; }
index_add() { touch "$INDEX"; grep -qxF "$1" "$INDEX" || printf '%s\n' "$1" >> "$INDEX"; }

cmd="${1:-list}"
case "$cmd" in
  list)
    cur="$(active_secret)"
    if [ -z "$cur" ]; then
      echo "現在: ログインしていません"
    else
      echo "現在: ログイン中  $(printf '%s' "$cur" | describe)"
    fi
    echo
    echo "保存済みアカウント:"
    found=0
    while IFS= read -r n; do
      if [ -z "$n" ]; then continue; fi
      found=1
      mark="  "
      if [ -n "$cur" ] && [ "$(saved_secret "$n")" = "$cur" ]; then mark="* "; fi
      echo "${mark}${n}  $(saved_secret "$n" | describe)"
    done <<< "$(list_saved)"
    if [ "$found" -eq 0 ]; then
      echo "  (まだありません。'claude-account save <名前>' で保存してください)"
    fi
    echo
    echo "* = 現在ログイン中のもの"
    ;;

  save)
    name="${2:-}"; [ -n "$name" ] || die "使い方: claude-account save <名前>"
    cur="$(active_secret)"
    [ -n "$cur" ] || die "ログインしていないため保存できません。先に Claude Code にログインしてください。"
    put "${PREFIX}${name}" "$cur"
    index_add "$name"
    echo "保存しました: ${name}  $(printf '%s' "$cur" | describe)"
    ;;

  use)
    name="${2:-}"; [ -n "$name" ] || die "使い方: claude-account use <名前>"
    target="$(saved_secret "$name")"
    [ -n "$target" ] || die "'${name}' は保存されていません。'claude-account list' で確認してください。"

    # ★保存していない認証情報を黙って壊さない。
    cur="$(active_secret)"
    if [ -n "$cur" ]; then
      known=0
      while IFS= read -r n; do
        if [ -z "$n" ]; then continue; fi
        if [ "$(saved_secret "$n")" = "$cur" ]; then known=1; fi
      done <<< "$(list_saved)"
      if [ "$known" -eq 0 ]; then
        echo "警告: いまログイン中のアカウントはまだ保存されていません。" >&2
        echo "      このまま切り替えると、再ログインしないと戻れなくなります。" >&2
        printf "      先に保存しますか? 名前を入力(空Enterで保存せず続行): " >&2
        read -r keep
        if [ -n "$keep" ]; then put "${PREFIX}${keep}" "$cur"; index_add "$keep"; echo "      保存しました: ${keep}" >&2; fi
      fi
    fi

    put "$SVC" "$target"
    echo "切り替えました: ${name}  $(printf '%s' "$target" | describe)"
    echo
    echo "★ VS Code で Cmd+Shift+P → 'Developer: Reload Window' を実行してください。"
    ;;

  logout)
    cur="$(active_secret)"
    [ -n "$cur" ] || { echo "すでにログインしていません。"; exit 0; }
    known=0
    while IFS= read -r n; do
      if [ -z "$n" ]; then continue; fi
      if [ "$(saved_secret "$n")" = "$cur" ]; then known=1; fi
    done <<< "$(list_saved)"
    if [ "$known" -eq 0 ]; then
      echo "警告: このアカウントは保存されていません。再ログインが必要になります。" >&2
    fi
    printf "ログアウトします。よろしいですか? [y/N]: "
    read -r ans
    [ "$ans" = "y" ] || { echo "中止しました。"; exit 0; }
    security delete-generic-password -s "$SVC" -a "$ACCT" >/dev/null
    echo "ログアウトしました。"
    echo "★ VS Code で Cmd+Shift+P → 'Developer: Reload Window' を実行してください。"
    ;;

  *) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' ;;
esac

読むときのポイントを3つだけ。

  • put() が Keychain への書き込み。-U が「あれば上書き」で、これが無いと2回目に失敗する
  • use の中ほどにある known の判定が、保存していないアカウントを壊さないための砦
  • list_saved()security dump-keychain ではなくインデックスファイルを読んでいるのは、
    GUI の許可ダイアログで止まらないようにするため

限界と注意(正直に書いておく)

security は値をコマンド引数で受け取る。 つまり実行の一瞬だけ ps に見える。単一ユーザーのMacを前提とした割り切りで、平文ファイルを置くよりは安全側、という判断をした。標準入力から読ませる方法があれば教えてほしい。

logout すると実行中のチャットセッションも切れる。 拡張機能が同じ認証情報を使っているので、リロードすると当然そうなる。区切りのいいタイミングでやる必要がある。会話履歴は残るので、再ログイン後に辿れる。

切り替えると夜間バッチも切り替わる。 私は launchd から Claude を headless で走らせているが、これらも同じ Keychain を見ている。切り替えれば全部が新しいアカウントで動く。これは利点でもあり、意図しないと事故にもなる。

Developer: Reload Window を忘れると効かない。 拡張機能が起動時に読んだ認証を掴んだままなので、コマンドだけ打って「切り替わらない」と悩むことになる。スクリプトの出力に毎回この一文を出すようにした。

そのうち不要になるはず。 アカウント切り替えUIは機能要望として上がっているようなので、公式に対応されたらこのツールは捨てる。それまでのつなぎだと思っている。

まとめ

  • Claude Code の認証は macOS の Keychain(サービス名 Claude Code-credentials)にある
  • よく紹介される手順は「削除して再ログイン」だが、削除せず別名で保管すれば往復できる
  • リフレッシュトークンごと保管するので、期限切れを気にしなくていい
  • 保管先も Keychain にすれば平文ファイルを作らずに済む
  • 道具にするなら「保存していないものを壊さない」を最優先で入れる

きっかけは夜間バッチが利用上限で落ちたことだったが、調べてみると認証の置き場所自体は素直で、30分ほどで形になった。「公式にUIがないから諦める」の一歩手前で、どこに何が保存されているかだけ調べてみると案外どうにかなる、という話でもある。

コメント

タイトルとURLをコピーしました