Skip to content

Commit 2d10a3e

Browse files
Mike Morganclaude
andcommitted
feat: Auto-detect API keys from common locations
Implements Issue #255 - Auto-detect API keys from common locations This feature improves the onboarding experience by automatically finding API keys from common configuration locations. Features: - Searches environment variables (ANTHROPIC_API_KEY, OPENAI_API_KEY) - Scans shell configs (~/.bashrc, ~/.zshrc, ~/.bash_profile, etc.) - Checks .env files in current directory and home - Checks ~/.config/cortex/ and ~/.cortex/ directories - Supports both Anthropic and OpenAI key formats - Key masking for safe display - Deduplication of keys found in multiple locations - Validation of key format and length Search locations: - ~/.bashrc, ~/.bash_profile, ~/.zshrc, ~/.zprofile, ~/.profile - ~/.env, ./.env, ./.env.local - ~/.config/cortex/.env, ~/.config/cortex/config - ~/.cortex/.env, ~/.cortex/config Usage: from cortex.api_key_detector import auto_configure_api_key key = auto_configure_api_key() # Auto-sets env var if found 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent f18bc09 commit 2d10a3e

File tree

2 files changed

+856
-0
lines changed

2 files changed

+856
-0
lines changed

cortex/api_key_detector.py

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
"""Auto-detect API keys from common locations.
2+
3+
This module scans common configuration files and locations to find
4+
API keys for supported LLM providers, making onboarding easier.
5+
6+
Implements Issue #255: Auto-detect API keys from common locations
7+
"""
8+
9+
import os
10+
import re
11+
import logging
12+
from pathlib import Path
13+
from typing import Optional, Dict, List, Tuple
14+
from dataclasses import dataclass
15+
from enum import Enum
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class Provider(Enum):
21+
"""Supported LLM providers."""
22+
ANTHROPIC = "anthropic"
23+
OPENAI = "openai"
24+
25+
26+
@dataclass
27+
class DetectedKey:
28+
"""Represents a detected API key.
29+
30+
Attributes:
31+
provider: The LLM provider (anthropic, openai)
32+
key: The actual API key value
33+
source: Where the key was found
34+
env_var: The environment variable name for this key
35+
"""
36+
provider: Provider
37+
key: str
38+
source: str
39+
env_var: str
40+
41+
@property
42+
def masked_key(self) -> str:
43+
"""Return a masked version of the key for display."""
44+
if len(self.key) <= 12:
45+
return "*" * len(self.key)
46+
return f"{self.key[:8]}...{self.key[-4:]}"
47+
48+
49+
# Patterns to match API keys in files
50+
KEY_PATTERNS = {
51+
Provider.ANTHROPIC: [
52+
# Environment variable exports
53+
r'(?:export\s+)?ANTHROPIC_API_KEY\s*=\s*["\']?(sk-ant-[a-zA-Z0-9_-]+)["\']?',
54+
# Direct assignment
55+
r'ANTHROPIC_API_KEY\s*[:=]\s*["\']?(sk-ant-[a-zA-Z0-9_-]+)["\']?',
56+
],
57+
Provider.OPENAI: [
58+
# Environment variable exports
59+
r'(?:export\s+)?OPENAI_API_KEY\s*=\s*["\']?(sk-[a-zA-Z0-9_-]+)["\']?',
60+
# Direct assignment
61+
r'OPENAI_API_KEY\s*[:=]\s*["\']?(sk-[a-zA-Z0-9_-]+)["\']?',
62+
],
63+
}
64+
65+
# Environment variable names for each provider
66+
ENV_VAR_NAMES = {
67+
Provider.ANTHROPIC: "ANTHROPIC_API_KEY",
68+
Provider.OPENAI: "OPENAI_API_KEY",
69+
}
70+
71+
# Common locations to search for API keys
72+
SEARCH_LOCATIONS = [
73+
# Shell configuration files
74+
"~/.bashrc",
75+
"~/.bash_profile",
76+
"~/.zshrc",
77+
"~/.zprofile",
78+
"~/.profile",
79+
# Environment files
80+
"~/.env",
81+
"./.env",
82+
"./.env.local",
83+
# Config directories
84+
"~/.config/cortex/.env",
85+
"~/.config/cortex/config",
86+
"~/.cortex/.env",
87+
"~/.cortex/config",
88+
# Project-specific
89+
"./cortex.env",
90+
]
91+
92+
93+
class APIKeyDetector:
94+
"""Detects API keys from various sources."""
95+
96+
def __init__(self, additional_paths: Optional[List[str]] = None):
97+
"""Initialize the detector.
98+
99+
Args:
100+
additional_paths: Extra file paths to search
101+
"""
102+
self.search_paths = [Path(p).expanduser() for p in SEARCH_LOCATIONS]
103+
if additional_paths:
104+
self.search_paths.extend([Path(p).expanduser() for p in additional_paths])
105+
106+
def _extract_key_from_content(
107+
self,
108+
content: str,
109+
provider: Provider
110+
) -> Optional[str]:
111+
"""Extract API key from file content.
112+
113+
Args:
114+
content: File content to search
115+
provider: Provider to search for
116+
117+
Returns:
118+
API key if found, None otherwise
119+
"""
120+
for pattern in KEY_PATTERNS[provider]:
121+
match = re.search(pattern, content, re.MULTILINE)
122+
if match:
123+
return match.group(1)
124+
return None
125+
126+
def _search_file(self, filepath: Path) -> List[DetectedKey]:
127+
"""Search a single file for API keys.
128+
129+
Args:
130+
filepath: Path to the file to search
131+
132+
Returns:
133+
List of detected keys
134+
"""
135+
detected = []
136+
137+
if not filepath.exists() or not filepath.is_file():
138+
return detected
139+
140+
try:
141+
content = filepath.read_text(encoding='utf-8', errors='ignore')
142+
143+
for provider in Provider:
144+
key = self._extract_key_from_content(content, provider)
145+
if key:
146+
detected.append(DetectedKey(
147+
provider=provider,
148+
key=key,
149+
source=str(filepath),
150+
env_var=ENV_VAR_NAMES[provider]
151+
))
152+
logger.debug(f"Found {provider.value} key in {filepath}")
153+
154+
except PermissionError:
155+
logger.debug(f"Permission denied reading {filepath}")
156+
except Exception as e:
157+
logger.debug(f"Error reading {filepath}: {e}")
158+
159+
return detected
160+
161+
def detect_from_environment(self) -> List[DetectedKey]:
162+
"""Check environment variables for API keys.
163+
164+
Returns:
165+
List of detected keys from environment
166+
"""
167+
detected = []
168+
169+
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
170+
if anthropic_key and anthropic_key.startswith("sk-ant-"):
171+
detected.append(DetectedKey(
172+
provider=Provider.ANTHROPIC,
173+
key=anthropic_key,
174+
source="environment variable",
175+
env_var="ANTHROPIC_API_KEY"
176+
))
177+
178+
openai_key = os.environ.get("OPENAI_API_KEY")
179+
if openai_key and openai_key.startswith("sk-"):
180+
detected.append(DetectedKey(
181+
provider=Provider.OPENAI,
182+
key=openai_key,
183+
source="environment variable",
184+
env_var="OPENAI_API_KEY"
185+
))
186+
187+
return detected
188+
189+
def detect_from_files(self) -> List[DetectedKey]:
190+
"""Search all configured paths for API keys.
191+
192+
Returns:
193+
List of detected keys from files
194+
"""
195+
detected = []
196+
197+
for filepath in self.search_paths:
198+
found = self._search_file(filepath)
199+
detected.extend(found)
200+
201+
return detected
202+
203+
def detect_all(self) -> List[DetectedKey]:
204+
"""Detect API keys from all sources.
205+
206+
Checks environment variables first, then files.
207+
Returns unique keys (same key from multiple sources is deduplicated).
208+
209+
Returns:
210+
List of all detected keys
211+
"""
212+
all_keys = []
213+
seen_keys = set()
214+
215+
# Environment variables take priority
216+
for key in self.detect_from_environment():
217+
if key.key not in seen_keys:
218+
all_keys.append(key)
219+
seen_keys.add(key.key)
220+
221+
# Then check files
222+
for key in self.detect_from_files():
223+
if key.key not in seen_keys:
224+
all_keys.append(key)
225+
seen_keys.add(key.key)
226+
227+
return all_keys
228+
229+
def get_best_key(self, preferred_provider: Optional[Provider] = None) -> Optional[DetectedKey]:
230+
"""Get the best available API key.
231+
232+
Args:
233+
preferred_provider: Preferred provider if multiple keys available
234+
235+
Returns:
236+
Best detected key, or None if no keys found
237+
"""
238+
keys = self.detect_all()
239+
240+
if not keys:
241+
return None
242+
243+
# If preferred provider specified and available, use it
244+
if preferred_provider:
245+
for key in keys:
246+
if key.provider == preferred_provider:
247+
return key
248+
249+
# Default priority: Anthropic > OpenAI (Cortex is optimized for Claude)
250+
for provider in [Provider.ANTHROPIC, Provider.OPENAI]:
251+
for key in keys:
252+
if key.provider == provider:
253+
return key
254+
255+
return keys[0] if keys else None
256+
257+
258+
def auto_configure_api_key(
259+
preferred_provider: Optional[str] = None,
260+
set_env: bool = True
261+
) -> Optional[DetectedKey]:
262+
"""Auto-detect and optionally configure an API key.
263+
264+
This is the main entry point for API key auto-detection.
265+
It searches common locations and can set the environment variable.
266+
267+
Args:
268+
preferred_provider: Preferred provider ('anthropic' or 'openai')
269+
set_env: Whether to set the environment variable if key is found
270+
271+
Returns:
272+
DetectedKey if found, None otherwise
273+
274+
Example:
275+
key = auto_configure_api_key()
276+
if key:
277+
print(f"Found {key.provider.value} key from {key.source}")
278+
"""
279+
detector = APIKeyDetector()
280+
281+
provider = None
282+
if preferred_provider:
283+
try:
284+
provider = Provider(preferred_provider.lower())
285+
except ValueError:
286+
logger.warning(f"Unknown provider: {preferred_provider}")
287+
288+
key = detector.get_best_key(preferred_provider=provider)
289+
290+
if key and set_env:
291+
# Set the environment variable for the current process
292+
os.environ[key.env_var] = key.key
293+
logger.info(f"Auto-configured {key.env_var} from {key.source}")
294+
295+
return key
296+
297+
298+
def get_detection_summary() -> Dict[str, any]:
299+
"""Get a summary of API key detection results.
300+
301+
Returns:
302+
Dictionary with detection summary for display
303+
"""
304+
detector = APIKeyDetector()
305+
keys = detector.detect_all()
306+
307+
summary = {
308+
"found": len(keys) > 0,
309+
"count": len(keys),
310+
"keys": [],
311+
"searched_locations": [str(p) for p in detector.search_paths if p.exists()]
312+
}
313+
314+
for key in keys:
315+
summary["keys"].append({
316+
"provider": key.provider.value,
317+
"source": key.source,
318+
"masked_key": key.masked_key,
319+
"env_var": key.env_var
320+
})
321+
322+
return summary
323+
324+
325+
def validate_detected_key(key: DetectedKey) -> Tuple[bool, Optional[str]]:
326+
"""Validate a detected API key format.
327+
328+
Args:
329+
key: The detected key to validate
330+
331+
Returns:
332+
Tuple of (is_valid, error_message)
333+
"""
334+
if key.provider == Provider.ANTHROPIC:
335+
if not key.key.startswith("sk-ant-"):
336+
return False, "Anthropic key should start with 'sk-ant-'"
337+
if len(key.key) < 20:
338+
return False, "Anthropic key appears too short"
339+
340+
elif key.provider == Provider.OPENAI:
341+
if not key.key.startswith("sk-"):
342+
return False, "OpenAI key should start with 'sk-'"
343+
if len(key.key) < 20:
344+
return False, "OpenAI key appears too short"
345+
346+
return True, None

0 commit comments

Comments
 (0)