😽

複数GitHubリポジトリの Claude Code のシークレットを一括更新するPythonスクリプト

に公開

はじめに

Claude CodeやGitHub Actionsを複数のリポジトリで使っている場合、認証情報(シークレット)の更新が大変ですよね。10個のリポジトリがあれば、10回同じ作業を繰り返す必要があります。

この記事では、Pythonスクリプト1つで全リポジトリのシークレットを一括更新する方法を紹介します。

解決したい課題

  • 各リポジトリのSettings → Secretsを手動で更新するのが面倒
  • コピペミスや更新漏れが発生しやすい
  • リポジトリが増えるほど作業時間が増える
  • 認証トークンの有効期限切れで定期的な更新が必要

実装アイデア

GitHub APIを使って、以下の流れで自動化します:

  1. 更新したいシークレット情報をJSONファイルに記載
  2. Pythonスクリプトで対象リポジトリを指定
  3. GitHub APIで各リポジトリのシークレットを一括更新

必要なファイル構成

project/
├── update_claude_credentials.py  # メインスクリプト
├── requirements.txt              # 依存ライブラリ
├── claude_credentials.json       # 認証情報(.gitignoreに追加!)
└── .gitignore                   # 認証情報をコミットしないように

実装方法

1. 必要なライブラリをインストール

requirements.txt:

PyGithub>=2.0.0
python-dotenv>=1.0.0
click>=8.0.0
pip install -r requirements.txt

2. 認証情報ファイルを作成

claude_credentials.json:

{
  "claudeAiOauth": {
    "accessToken": "xxxxxxxxxxxxxxxxxxxx",
    "refreshToken": "xxxxxxxxxxxxxxxxxxx",
    "expiresAt": 1749360577082,
    "scopes": ["user:inference", "user:profile"],
    "isMax": true
  }
}

3. メインスクリプトの実装

update_claude_credentials.py:

#!/usr/bin/env python3
import json
import sys
import os
from datetime import datetime
from typing import List, Dict, Any

try:
    import click
    from github import Github
    from github.Repository import Repository
except ImportError:
    print("Error: Required packages not installed.")
    print("Please run: pip install PyGithub click python-dotenv")
    sys.exit(1)

@click.command()
@click.argument('credentials_file', type=click.Path(exists=True))
@click.option('--token', envvar='GITHUB_TOKEN', required=True,
              help='GitHub Personal Access Token (can be set via GITHUB_TOKEN env var)')
@click.option('--repos', multiple=True,
              help='Specific repositories to update (format: owner/repo)')
@click.option('--all', is_flag=True,
              help='Update all repositories you have admin access to')
@click.option('--dry-run', is_flag=True,
              help='Show what would be updated without making changes')
@click.option('--org', help='Organization name to filter repositories')
def update_secrets(credentials_file: str, token: str, repos: tuple, all: bool, 
                  dry_run: bool, org: str) -> None:
    """
    Update Claude credentials across multiple GitHub repositories.
    
    CREDENTIALS_FILE: Path to JSON file containing Claude OAuth credentials
    """
    # Initialize GitHub client
    try:
        g = Github(token)
        user = g.get_user()
        click.echo(f"Authenticated as: {user.login}")
    except Exception as e:
        click.echo(f"Error: Failed to authenticate with GitHub: {str(e)}", err=True)
        sys.exit(1)
    
    # Load credentials
    try:
        with open(credentials_file, 'r') as f:
            data = json.load(f)
    except json.JSONDecodeError as e:
        click.echo(f"Error: Invalid JSON in {credentials_file}: {str(e)}", err=True)
        sys.exit(1)
    except Exception as e:
        click.echo(f"Error: Failed to read {credentials_file}: {str(e)}", err=True)
        sys.exit(1)
    
    # Extract secrets based on format
    if 'claudeAiOauth' in data:
        # Claude OAuth format
        oauth_data = data['claudeAiOauth']
        secrets_to_update = {
            'CLAUDE_ACCESS_TOKEN': oauth_data.get('accessToken', ''),
            'CLAUDE_REFRESH_TOKEN': oauth_data.get('refreshToken', ''),
            'CLAUDE_EXPIRES_AT': str(oauth_data.get('expiresAt', ''))
        }
        
        # Display token expiration
        if oauth_data.get('expiresAt'):
            try:
                expires_at = datetime.fromtimestamp(oauth_data['expiresAt'] / 1000)
                click.echo(f"Token expires at: {expires_at}")
                
                # Warn if token is expired or expiring soon
                now = datetime.now()
                if expires_at < now:
                    click.echo("⚠️  Warning: Token has already expired!", err=True)
                elif (expires_at - now).days < 7:
                    click.echo(f"⚠️  Warning: Token expires in {(expires_at - now).days} days!")
            except:
                pass
                
        # Display scope information
        if oauth_data.get('scopes'):
            click.echo(f"Scopes: {', '.join(oauth_data['scopes'])}")
        if oauth_data.get('isMax'):
            click.echo("Max plan: Yes")
            
    elif 'apiKeys' in data:
        # Generic API keys format
        secrets_to_update = data['apiKeys']
    else:
        # Flat format (backward compatibility)
        secrets_to_update = {k: v for k, v in data.items() 
                           if k.startswith('CLAUDE_') or k in ['CLAUDE_ACCESS_TOKEN', 
                                                               'CLAUDE_REFRESH_TOKEN', 
                                                               'CLAUDE_EXPIRES_AT']}
    
    if not secrets_to_update:
        click.echo("Error: No secrets found to update", err=True)
        sys.exit(1)
    
    click.echo(f"\nSecrets to update: {', '.join(secrets_to_update.keys())}")
    
    # Determine target repositories
    target_repos: List[Repository] = []
    
    if all:
        # Get all repos with admin access
        if org:
            try:
                organization = g.get_organization(org)
                all_repos = organization.get_repos()
                click.echo(f"Checking repositories in organization: {org}")
            except Exception as e:
                click.echo(f"Error: Failed to access organization {org}: {str(e)}", err=True)
                sys.exit(1)
        else:
            all_repos = user.get_repos()
            
        target_repos = [repo for repo in all_repos if repo.permissions.admin]
        click.echo(f"Found {len(target_repos)} repositories with admin access")
        
    elif repos:
        # Get specific repositories
        for repo_name in repos:
            try:
                repo = g.get_repo(repo_name)
                if not repo.permissions.admin:
                    click.echo(f"Warning: No admin access to {repo_name}, skipping", err=True)
                    continue
                target_repos.append(repo)
            except Exception as e:
                click.echo(f"Error: Failed to access {repo_name}: {str(e)}", err=True)
                
        click.echo(f"Found {len(target_repos)} accessible repositories")
    else:
        click.echo("Error: Please specify repositories with --repos or use --all", err=True)
        sys.exit(1)
    
    if not target_repos:
        click.echo("No repositories to update", err=True)
        sys.exit(1)
    
    # Confirm before proceeding
    if not dry_run:
        click.echo(f"\nAbout to update {len(target_repos)} repositories.")
        if not click.confirm("Do you want to continue?"):
            click.echo("Aborted.")
            sys.exit(0)
    
    # Update repositories
    success_count = 0
    failed_repos = []
    
    for repo in target_repos:
        click.echo(f"\n{'[DRY RUN] ' if dry_run else ''}Updating: {repo.full_name}")
        
        if dry_run:
            for secret_name in secrets_to_update.keys():
                click.echo(f"  Would update: {secret_name}")
            success_count += 1
            continue
        
        # Actually update secrets
        repo_success = True
        for secret_name, secret_value in secrets_to_update.items():
            try:
                repo.create_secret(secret_name, str(secret_value))
                click.echo(f"  ✓ {secret_name}")
            except Exception as e:
                click.echo(f"  ✗ {secret_name}: {str(e)}", err=True)
                repo_success = False
        
        if repo_success:
            success_count += 1
        else:
            failed_repos.append(repo.full_name)
    
    # Summary
    click.echo("\n" + "="*50)
    click.echo(f"{'DRY RUN ' if dry_run else ''}Summary:")
    click.echo(f"  Total repositories: {len(target_repos)}")
    click.echo(f"  Successfully updated: {success_count}")
    click.echo(f"  Failed: {len(failed_repos)}")
    
    if failed_repos:
        click.echo(f"\nFailed repositories:")
        for repo_name in failed_repos:
            click.echo(f"  - {repo_name}")
    
    if dry_run:
        click.echo("\nThis was a dry run. No changes were made.")
    else:
        click.echo(f"\n✅ Done! Updated {success_count} repositories.")

if __name__ == '__main__':
    update_secrets()

4. .gitignoreの設定

.gitignore:

# 認証情報を絶対にコミットしない
claude_credentials.json
*.credentials.json
.env

使い方

GitHub Personal Access Tokenの準備

  1. GitHub Settings → Developer settings → Personal access tokens
  2. 「Generate new token」をクリック
  3. 必要な権限: repo(リポジトリへのフルアクセス)
  4. トークンを環境変数に設定:
    export GITHUB_TOKEN="your-github-token"
    

実行例

特定のリポジトリを更新

python update_claude_credentials.py --repos project1 project2 project3

管理者権限を持つ全リポジトリを更新

python update_claude_credentials.py --all

別の認証情報ファイルを使用

python update_claude_credentials.py --credentials-file prod_credentials.json --all

実行結果の例

Found 15 repositories with admin access
Token expires at: 2025-02-07 12:09:37

Updating secrets for: my-blog
  ✓ CLAUDE_ACCESS_TOKEN
  ✓ CLAUDE_REFRESH_TOKEN
  ✓ CLAUDE_EXPIRES_AT

Updating secrets for: api-server
  ✓ CLAUDE_ACCESS_TOKEN
  ✓ CLAUDE_REFRESH_TOKEN
  ✓ CLAUDE_EXPIRES_AT

...

Done!

応用例

1. 他のシークレットも一括更新

api_credentials.json:

{
  "apiKeys": {
    "OPENAI_API_KEY": "sk-...",
    "SLACK_WEBHOOK_URL": "https://hooks.slack.com/...",
    "DATABASE_URL": "postgresql://..."
  }
}

対応するスクリプトの修正:

# JSONファイルの形式に応じて処理を分岐
if 'claudeAiOauth' in data:
    # Claude認証情報の処理
    oauth_data = data['claudeAiOauth']
    secrets_to_update = {
        'CLAUDE_ACCESS_TOKEN': oauth_data['accessToken'],
        'CLAUDE_REFRESH_TOKEN': oauth_data['refreshToken'],
        'CLAUDE_EXPIRES_AT': str(oauth_data['expiresAt'])
    }
elif 'apiKeys' in data:
    # その他のAPI キーの処理
    secrets_to_update = data['apiKeys']
else:
    # 従来のフラット形式
    secrets_to_update = data

2. 組織のリポジトリも対象に

# 組織のリポジトリを取得
org = g.get_organization('my-organization')
target_repos = [repo for repo in org.get_repos() 
               if repo.permissions.admin]

3. ドライランモード追加

@click.option('--dry-run', is_flag=True, help='Show what would be updated')
def update_secrets(token, repos, all, credentials_file, dry_run):
    if dry_run:
        click.echo(f"Would update: {secret_name}")
    else:
        repo.create_secret(secret_name, secret_value)

メリット

  • 時間短縮: 10リポジトリでも1コマンドで完了
  • ミス防止: 手動コピペによるエラーがゼロに
  • 定期更新が簡単: cronやGitHub Actionsで自動化可能

セキュリティ注意事項

  1. 認証情報ファイルは絶対にGitにコミットしない
  2. GitHub Personal Access Tokenは最小権限で作成
  3. スクリプト実行後は認証情報ファイルを削除

Claude認証情報の取得方法

Claude Codeの認証情報は以下の手順で取得できます:

  1. Claude Codeアプリケーションを開く
  2. 開発者ツールを開く(F12 または Cmd+Option+I)
  3. Application/Storage → Local Storage → https://claude.ai
  4. claude_auth キーの値をコピー
  5. JSONファイルとして保存

まとめ

このシンプルなPythonスクリプトで、複数リポジトリの管理が劇的に楽になります。特にClaude Codeのような定期的にトークン更新が必要なサービスを使っている場合、このスクリプトは必須ツールになるでしょう。

認証情報の形式(claudeAiOauth)に注意して、ぜひ自分の環境に合わせてカスタマイズして使ってみてください!


参考リンク:

Discussion