/ tools / spec-verify / credentials.py
credentials.py
  1  """
  2  Credentials Checker
  3  
  4  Checks for local CLI credentials before running verification.
  5  Supports Gemini CLI and Codex CLI authentication.
  6  """
  7  
  8  import json
  9  import os
 10  from pathlib import Path
 11  from typing import Optional, Tuple
 12  
 13  # Credential file locations
 14  GEMINI_CREDS_PATH = Path.home() / ".gemini" / "oauth_creds.json"
 15  CODEX_CREDS_PATH = Path.home() / ".codex" / "auth.json"
 16  ANTHROPIC_CREDS_PATH = Path.home() / ".anthropic" / "auth.json"
 17  CLAUDE_CODE_CREDS_PATH = Path.home() / ".claude.json"
 18  CLAUDE_CODE_CREDS_PATH_V2 = Path.home() / ".claude" / ".credentials.json"
 19  
 20  # Alternative env var names (fallback)
 21  GEMINI_ENV_VARS = ["GOOGLE_API_KEY", "GEMINI_API_KEY"]
 22  OPENAI_ENV_VARS = ["OPENAI_API_KEY"]
 23  ANTHROPIC_ENV_VARS = ["ANTHROPIC_API_KEY"]
 24  
 25  
 26  def check_gemini_credentials() -> Tuple[bool, Optional[str]]:
 27      """
 28      Check if Gemini CLI credentials are available.
 29  
 30      Returns:
 31          Tuple of (has_credentials, error_message)
 32      """
 33      # Check OAuth creds file first (from `gemini auth`)
 34      if GEMINI_CREDS_PATH.exists():
 35          try:
 36              with open(GEMINI_CREDS_PATH, 'r') as f:
 37                  creds = json.load(f)
 38                  # Check if token exists and isn't empty
 39                  if creds.get('refresh_token') or creds.get('access_token'):
 40                      return True, None
 41          except (json.JSONDecodeError, IOError):
 42              pass
 43  
 44      # Fallback: check environment variables
 45      for env_var in GEMINI_ENV_VARS:
 46          if os.environ.get(env_var):
 47              return True, None
 48  
 49      return False, f"""Gemini credentials not found.
 50  
 51  Setup options:
 52    1. Run: gemini auth login
 53    2. Or set environment variable: export GOOGLE_API_KEY=<your-key>
 54  
 55  Credential locations checked:
 56    - {GEMINI_CREDS_PATH}
 57    - Environment: {', '.join(GEMINI_ENV_VARS)}"""
 58  
 59  
 60  def check_codex_credentials() -> Tuple[bool, Optional[str]]:
 61      """
 62      Check if Codex CLI credentials are available.
 63  
 64      Returns:
 65          Tuple of (has_credentials, error_message)
 66      """
 67      # Check auth.json from `codex auth`
 68      if CODEX_CREDS_PATH.exists():
 69          try:
 70              with open(CODEX_CREDS_PATH, 'r') as f:
 71                  creds = json.load(f)
 72                  # Check if we have valid auth data
 73                  # Codex stores key as OPENAI_API_KEY or api_key
 74                  if (creds.get('OPENAI_API_KEY') or creds.get('api_key') or
 75                      creds.get('access_token') or creds.get('refresh_token') or
 76                      creds.get('tokens')):
 77                      return True, None
 78          except (json.JSONDecodeError, IOError):
 79              pass
 80  
 81      # Fallback: check environment variables
 82      for env_var in OPENAI_ENV_VARS:
 83          if os.environ.get(env_var):
 84              return True, None
 85  
 86      return False, f"""Codex/OpenAI credentials not found.
 87  
 88  Setup options:
 89    1. Run: codex auth login
 90    2. Or set environment variable: export OPENAI_API_KEY=<your-key>
 91  
 92  Credential locations checked:
 93    - {CODEX_CREDS_PATH}
 94    - Environment: {', '.join(OPENAI_ENV_VARS)}"""
 95  
 96  
 97  def check_anthropic_credentials() -> Tuple[bool, Optional[str]]:
 98      """
 99      Check if Anthropic/Claude access is available.
100  
101      Uses Claude CLI which leverages existing OAuth session.
102  
103      Returns:
104          Tuple of (has_credentials, error_message)
105      """
106      # First check if Claude CLI is available (preferred - uses existing session)
107      try:
108          from providers.anthropic import check_claude_cli
109          has_cli, _ = check_claude_cli()
110          if has_cli:
111              return True, None
112      except ImportError:
113          pass
114  
115      # Fallback: Check environment variable
116      for env_var in ANTHROPIC_ENV_VARS:
117          if os.environ.get(env_var):
118              return True, None
119  
120      # Fallback: Check ~/.anthropic/auth.json
121      if ANTHROPIC_CREDS_PATH.exists():
122          try:
123              with open(ANTHROPIC_CREDS_PATH, 'r') as f:
124                  creds = json.load(f)
125                  if creds.get('api_key'):
126                      return True, None
127          except (json.JSONDecodeError, IOError):
128              pass
129  
130      return False, f"""Claude CLI not found or not authenticated.
131  
132  The spec-verify tool uses the Claude CLI to leverage your existing session.
133  
134  Setup:
135    1. Install Claude CLI: https://claude.ai/code
136    2. Authenticate: claude login
137  
138  Alternative (API key):
139    export ANTHROPIC_API_KEY=<your-key>"""
140  
141  
142  def check_credentials(provider: str) -> Tuple[bool, Optional[str]]:
143      """
144      Check credentials for specified provider.
145  
146      Args:
147          provider: 'gemini', 'openai', or 'anthropic'
148  
149      Returns:
150          Tuple of (has_credentials, error_message)
151      """
152      if provider == "gemini":
153          return check_gemini_credentials()
154      elif provider == "openai":
155          return check_codex_credentials()
156      elif provider == "anthropic":
157          return check_anthropic_credentials()
158      else:
159          return False, f"Unknown provider: {provider}"
160  
161  
162  def get_gemini_api_key() -> Optional[str]:
163      """
164      Get Gemini API key from local credentials.
165  
166      Returns:
167          API key string or None
168      """
169      # Try env vars first (they take precedence)
170      for env_var in GEMINI_ENV_VARS:
171          key = os.environ.get(env_var)
172          if key:
173              return key
174  
175      # Try OAuth - for OAuth we return None as the client uses tokens
176      # The google-generativeai library handles OAuth automatically
177      if GEMINI_CREDS_PATH.exists():
178          return "OAUTH_CREDS"  # Signal to use OAuth flow
179  
180      return None
181  
182  
183  def get_openai_api_key() -> Optional[str]:
184      """
185      Get OpenAI API key from local credentials.
186  
187      Returns:
188          API key string, "OAUTH_TOKENS" for token-based auth, or None
189      """
190      # Try env vars first
191      for env_var in OPENAI_ENV_VARS:
192          key = os.environ.get(env_var)
193          if key:
194              return key
195  
196      # Try Codex auth.json
197      if CODEX_CREDS_PATH.exists():
198          try:
199              with open(CODEX_CREDS_PATH, 'r') as f:
200                  creds = json.load(f)
201                  # Codex stores key as OPENAI_API_KEY or api_key
202                  if creds.get('OPENAI_API_KEY'):
203                      return creds['OPENAI_API_KEY']
204                  if creds.get('api_key'):
205                      return creds['api_key']
206                  # Codex may use OAuth tokens instead
207                  tokens = creds.get('tokens', {})
208                  if tokens.get('access_token'):
209                      return "OAUTH_TOKENS"
210          except (json.JSONDecodeError, IOError):
211              pass
212  
213      return None
214  
215  
216  def get_codex_access_token() -> Optional[str]:
217      """
218      Get Codex OAuth access token for API calls.
219  
220      Returns:
221          Access token string or None
222      """
223      if not CODEX_CREDS_PATH.exists():
224          return None
225  
226      try:
227          with open(CODEX_CREDS_PATH, 'r') as f:
228              creds = json.load(f)
229              tokens = creds.get('tokens', {})
230              return tokens.get('access_token')
231      except (json.JSONDecodeError, IOError):
232          return None
233  
234  
235  def get_anthropic_api_key() -> Optional[str]:
236      """
237      Get Anthropic API key from local credentials.
238  
239      Returns:
240          API key string or None
241      """
242      # Try env vars first
243      for env_var in ANTHROPIC_ENV_VARS:
244          key = os.environ.get(env_var)
245          if key:
246              return key
247  
248      # Try ~/.anthropic/auth.json
249      if ANTHROPIC_CREDS_PATH.exists():
250          try:
251              with open(ANTHROPIC_CREDS_PATH, 'r') as f:
252                  creds = json.load(f)
253                  if creds.get('api_key'):
254                      return creds['api_key']
255          except (json.JSONDecodeError, IOError):
256              pass
257  
258      # Try ~/.claude.json
259      if CLAUDE_CODE_CREDS_PATH.exists():
260          try:
261              with open(CLAUDE_CODE_CREDS_PATH, 'r') as f:
262                  creds = json.load(f)
263                  if creds.get('apiKey'):
264                      return creds['apiKey']
265                  if creds.get('api_key'):
266                      return creds['api_key']
267          except (json.JSONDecodeError, IOError):
268              pass
269  
270      return None
271  
272  
273  def preflight_check_gemini() -> Tuple[bool, Optional[str]]:
274      """
275      Make a test API call to verify Gemini access works.
276  
277      This ensures:
278      - Credentials are valid (not expired)
279      - Account has API access
280      - No interactive prompts will occur
281  
282      Returns:
283          Tuple of (success, error_message)
284      """
285      try:
286          import google.generativeai as genai
287      except ImportError:
288          return False, "google-generativeai package not installed. Install with: pip install google-generativeai"
289  
290      api_key = get_gemini_api_key()
291  
292      if api_key == "OAUTH_CREDS":
293          # Setup OAuth credentials
294          try:
295              from google.oauth2.credentials import Credentials
296              from google.auth.transport.requests import Request
297  
298              with open(GEMINI_CREDS_PATH, 'r') as f:
299                  oauth_creds = json.load(f)
300  
301              creds = Credentials(
302                  token=oauth_creds.get('access_token'),
303                  refresh_token=oauth_creds.get('refresh_token'),
304                  token_uri="https://oauth2.googleapis.com/token",
305                  client_id=oauth_creds.get('client_id'),
306                  client_secret=oauth_creds.get('client_secret'),
307              )
308  
309              # Refresh if needed
310              if creds.expired and creds.refresh_token:
311                  creds.refresh(Request())
312  
313              genai.configure(credentials=creds)
314          except Exception as e:
315              return False, f"OAuth setup failed: {e}"
316      elif api_key:
317          genai.configure(api_key=api_key)
318      else:
319          return False, "No API key found"
320  
321      # Make a minimal test request
322      try:
323          model = genai.GenerativeModel('gemini-2.0-flash')
324          response = model.generate_content(
325              "Reply with exactly: OK",
326              generation_config=genai.types.GenerationConfig(
327                  max_output_tokens=10,
328                  temperature=0,
329              ),
330          )
331  
332          # Check we got a response
333          if response and response.text:
334              return True, None
335          else:
336              return False, "Empty response from API"
337  
338      except Exception as e:
339          error_msg = str(e)
340  
341          # Parse common errors
342          if "API_KEY_INVALID" in error_msg or "invalid" in error_msg.lower():
343              return False, f"Invalid API key or expired OAuth token. Re-run: gemini auth login"
344          elif "PERMISSION_DENIED" in error_msg or "SCOPE_INSUFFICIENT" in error_msg:
345              return False, f"""Permission denied or insufficient OAuth scopes.
346  
347  The Gemini CLI OAuth tokens don't have the right scopes for the Python SDK.
348  You need a Gemini API key instead.
349  
350  Get one from: https://aistudio.google.com/apikey
351  Then set: export GOOGLE_API_KEY=<your-key>"""
352          elif "QUOTA" in error_msg or "rate" in error_msg.lower():
353              return False, f"Rate limit or quota exceeded. Wait and retry."
354          elif "requires" in error_msg.lower() and "consent" in error_msg.lower():
355              return False, f"Interactive consent required. Re-run: gemini auth login"
356          else:
357              return False, f"API test failed: {error_msg}"
358  
359  
360  def preflight_check_openai() -> Tuple[bool, Optional[str]]:
361      """
362      Make a test API call to verify OpenAI access works.
363  
364      This ensures:
365      - API key is valid
366      - Account has API access and quota
367      - No interactive prompts will occur
368  
369      Returns:
370          Tuple of (success, error_message)
371      """
372      try:
373          from openai import OpenAI
374      except ImportError:
375          return False, "openai package not installed. Install with: pip install openai"
376  
377      api_key = get_openai_api_key()
378  
379      if not api_key:
380          return False, "No API key found"
381  
382      # Handle OAuth token-based auth from Codex CLI
383      if api_key == "OAUTH_TOKENS":
384          access_token = get_codex_access_token()
385          if not access_token:
386              return False, "OAuth tokens found but access_token is missing. Re-run: codex auth login"
387          api_key = access_token
388  
389      # Make a minimal test request
390      try:
391          client = OpenAI(api_key=api_key)
392          response = client.chat.completions.create(
393              model="gpt-4",  # Use same model as config for preflight
394              messages=[{"role": "user", "content": "Reply with exactly: OK"}],
395              max_tokens=10,
396              temperature=0,
397          )
398  
399          # Check we got a response
400          if response and response.choices and response.choices[0].message.content:
401              return True, None
402          else:
403              return False, "Empty response from API"
404  
405      except Exception as e:
406          error_msg = str(e)
407  
408          # Parse common errors
409          if "invalid_api_key" in error_msg.lower() or "incorrect api key" in error_msg.lower():
410              return False, f"Invalid API key or token. Re-run: codex auth login"
411          elif "permission" in error_msg.lower():
412              return False, f"Permission denied. Check API access for your account."
413          elif "insufficient_quota" in error_msg.lower():
414              return False, f"""OpenAI quota exceeded.
415  
416  Your account has no remaining quota. Options:
417  1. Add billing: https://platform.openai.com/account/billing
418  2. Use a different API key with active quota
419  3. Set: export OPENAI_API_KEY=<key-with-quota>"""
420          elif "quota" in error_msg.lower() or "rate" in error_msg.lower():
421              return False, f"Rate limit exceeded. Wait a moment and retry."
422          elif "billing" in error_msg.lower():
423              return False, f"Billing issue. Check your OpenAI account has active billing."
424          else:
425              return False, f"API test failed: {error_msg}"
426  
427  
428  def preflight_check_anthropic() -> Tuple[bool, Optional[str]]:
429      """
430      Make a test call to verify Claude/Anthropic access works.
431  
432      Uses Claude CLI for preflight to leverage existing OAuth session.
433  
434      Returns:
435          Tuple of (success, error_message)
436      """
437      # Try Claude CLI first (uses existing session)
438      try:
439          from providers.anthropic import preflight_claude_cli
440          return preflight_claude_cli()
441      except ImportError:
442          pass
443  
444      # Fallback to API key approach
445      try:
446          from anthropic import Anthropic
447      except ImportError:
448          return False, "Neither Claude CLI nor anthropic package available."
449  
450      api_key = get_anthropic_api_key()
451  
452      if not api_key:
453          return False, "No API key found and Claude CLI not available."
454  
455      # Make a minimal test request
456      try:
457          client = Anthropic(api_key=api_key)
458          response = client.messages.create(
459              model="claude-sonnet-4-20250514",
460              max_tokens=10,
461              messages=[{"role": "user", "content": "Reply with exactly: OK"}],
462          )
463  
464          if response and response.content and response.content[0].text:
465              return True, None
466          else:
467              return False, "Empty response from API"
468  
469      except Exception as e:
470          error_msg = str(e)
471  
472          if "invalid_api_key" in error_msg.lower() or "authentication" in error_msg.lower():
473              return False, f"Invalid API key. Check your ANTHROPIC_API_KEY."
474          elif "rate" in error_msg.lower():
475              return False, f"Rate limit exceeded. Wait a moment and retry."
476          else:
477              return False, f"API test failed: {error_msg}"
478  
479  
480  def preflight_check(provider: str) -> Tuple[bool, Optional[str]]:
481      """
482      Run preflight check for the specified provider.
483  
484      Makes a test API call to verify credentials work and
485      no interactive prompts will be required.
486  
487      Args:
488          provider: 'gemini', 'openai', or 'anthropic'
489  
490      Returns:
491          Tuple of (success, error_message)
492      """
493      if provider == "gemini":
494          return preflight_check_gemini()
495      elif provider == "openai":
496          return preflight_check_openai()
497      elif provider == "anthropic":
498          return preflight_check_anthropic()
499      else:
500          return False, f"Unknown provider: {provider}"