diff --git a/README.md b/README.md
index 835fcf0f..3c276793 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,8 @@ echo 'OPENAI_API_KEY=your-key-here' > .env
cortex --version
```
+> **💡 Zero-Config:** If you already have API keys from Claude CLI (`~/.config/anthropic/`) or OpenAI CLI (`~/.config/openai/`), Cortex will auto-detect them! Environment variables work immediately without prompting. See [Zero Config API Keys](docs/ZERO_CONFIG_API_KEYS.md).
+
### First Run
```bash
@@ -254,16 +256,27 @@ Found a vulnerability? Please report it responsibly:
## Troubleshooting
-"ANTHROPIC_API_KEY not set"
+"No API key found"
+
+Cortex auto-detects API keys from multiple locations. If none are found:
```bash
-# Verify .env file exists
-cat .env
-# Should show: ANTHROPIC_API_KEY=sk-ant-...
+# Option 1: Set environment variables (used immediately, no save needed)
+export ANTHROPIC_API_KEY=sk-ant-your-key
+cortex install nginx --dry-run
+
+# Option 2: Save directly to Cortex config
+echo 'ANTHROPIC_API_KEY=sk-ant-your-key' > ~/.cortex/.env
-# If missing, create it:
-echo 'ANTHROPIC_API_KEY=your-actual-key' > .env
+# Option 3: Use Ollama (free, local, no key needed)
+export CORTEX_PROVIDER=ollama
+python scripts/setup_ollama.py
+
+# Option 4: If you have Claude CLI installed, Cortex will find it automatically
+# Just run: cortex install nginx --dry-run
```
+
+See [Zero Config API Keys](docs/ZERO_CONFIG_API_KEYS.md) for details.
diff --git a/cortex/api_key_detector.py b/cortex/api_key_detector.py
new file mode 100644
index 00000000..7d0026cf
--- /dev/null
+++ b/cortex/api_key_detector.py
@@ -0,0 +1,546 @@
+"""
+API Key Auto-Detection Module
+
+Automatically detects API keys from common locations without requiring
+user to set environment variables. Searches in order:
+
+1. Environment variables: ANTHROPIC_API_KEY, OPENAI_API_KEY
+2. ~/.cortex/.env
+3. ~/.config/anthropic (Claude CLI location)
+4. ~/.config/openai
+5. .env in current directory
+
+Implements caching to avoid repeated file checks and supports manual entry
+with optional saving to ~/.cortex/.env.
+
+"""
+
+import json
+import os
+import re
+from pathlib import Path
+from typing import Optional
+
+from cortex.branding import console, cx_print
+
+# Constants
+CORTEX_DIR = ".cortex"
+CORTEX_ENV_FILE = ".env"
+CORTEX_CACHE_FILE = ".api_key_cache"
+
+# Supported API key prefixes
+KEY_PATTERNS = {
+ "sk-ant-": "anthropic",
+ "sk-": "openai",
+}
+
+# Environment variable mappings
+ENV_VAR_PROVIDERS = {
+ "ANTHROPIC_API_KEY": "anthropic",
+ "OPENAI_API_KEY": "openai",
+}
+
+# JSON field names to search for API keys
+API_KEY_FIELD_NAMES = ["api_key", "apiKey", "key"]
+
+# Provider display names for brand consistency
+PROVIDER_DISPLAY_NAMES = {"anthropic": "Claude (Anthropic)", "openai": "OpenAI", "ollama": "Ollama"}
+
+# Menu choice mappings for provider selection
+PROVIDER_MENU_CHOICES = {"anthropic": "1", "openai": "2", "ollama": "3"}
+
+
+class APIKeyDetector:
+ """Detects and caches API keys from multiple sources."""
+
+ def __init__(self, cache_dir: Path | None = None):
+ """
+ Initialize the API key detector.
+
+ Args:
+ cache_dir: Directory for caching key location info.
+ Defaults to ~/.cortex
+ """
+ self.cache_dir = cache_dir or (Path.home() / CORTEX_DIR)
+ self.cache_file = self.cache_dir / CORTEX_CACHE_FILE
+
+ def detect(self) -> tuple[bool, str | None, str | None, str | None]:
+ """
+ Auto-detect API key from common locations.
+
+ Returns:
+ Tuple of (found, key, provider, source)
+ - found: True if key was found
+ - key: The API key (or None)
+ - provider: "anthropic" or "openai" (or None)
+ - source: Where the key was found (or None)
+ """
+ # Check cached location first
+ result = self._check_cached_key()
+ if result:
+ return result
+
+ # Check in priority order
+ result = self._check_all_locations()
+ return result or (False, None, None, None)
+
+ def _check_cached_key(self) -> tuple[bool, str | None, str | None, str | None] | None:
+ """Check if we have a cached key that still works."""
+ cached = self._get_cached_key()
+ if not cached:
+ return None
+
+ provider, source = cached
+ return self._validate_cached_key(provider, source)
+
+ def _validate_cached_key(
+ self, provider: str, source: str
+ ) -> tuple[bool, str | None, str | None, str | None] | None:
+ """Validate that a cached key still works."""
+ env_var = self._get_env_var_name(provider)
+
+ if source == "environment":
+ value = os.environ.get(env_var)
+ return (True, value, provider, source) if value else None
+ else:
+ key = self._extract_key_from_file(Path(source), env_var)
+ if key:
+ os.environ[env_var] = key
+ return (True, key, provider, source)
+ return None
+
+ def _check_all_locations(self) -> tuple[bool, str | None, str | None, str | None] | None:
+ """Check all locations in priority order for API keys."""
+ locations = self._get_check_locations()
+
+ for source, env_vars in locations:
+ result = self._check_location(source, env_vars)
+ if result:
+ return result
+
+ return None
+
+ def _check_location(
+ self, source: str | Path, env_vars: list[str]
+ ) -> tuple[bool, str | None, str | None, str | None] | None:
+ """Check a specific location for API keys."""
+ for env_var in env_vars:
+ if source == "environment":
+ result = self._check_environment_variable(env_var)
+ elif isinstance(source, Path):
+ result = self._check_file_location(source, env_var)
+ else:
+ continue
+
+ if result:
+ return result
+
+ return None
+
+ def _check_environment_variable(
+ self, env_var: str
+ ) -> tuple[bool, str | None, str | None, str | None] | None:
+ """Check if an environment variable contains a valid API key."""
+ value = os.environ.get(env_var)
+ if value:
+ provider = self._get_provider_from_var(env_var)
+ self._cache_key_location(value, provider, "environment")
+ return (True, value, provider, "environment")
+ return None
+
+ def _check_file_location(
+ self, source: Path, env_var: str
+ ) -> tuple[bool, str | None, str | None, str | None] | None:
+ """Check if a file contains a valid API key."""
+ key = self._extract_key_from_file(source, env_var)
+ if key:
+ provider = self._get_provider_from_var(env_var)
+ self._cache_key_location(key, provider, str(source))
+ # Set in environment for this session
+ os.environ[env_var] = key
+ return (True, key, provider, str(source))
+ return None
+
+ def prompt_for_key(self) -> tuple[bool, str | None, str | None]:
+ """
+ Prompt user to select a provider and enter an API key if needed.
+
+ Returns:
+ Tuple of (entered, key, provider)
+ """
+ provider = self._get_provider_choice()
+ if not provider:
+ return (False, None, None)
+
+ if provider == "ollama":
+ return (True, "ollama-local", "ollama")
+
+ key = self._get_and_validate_key(provider)
+ if not key:
+ return (False, None, None)
+
+ self._ask_to_save_key(key, provider)
+ return (True, key, provider)
+
+ def _get_provider_choice(self) -> str | None:
+ """Get user's provider choice."""
+ cx_print("No API key found. Select a provider:", "warning")
+ console.print(" [bold]1.[/bold] Claude (Anthropic)")
+ console.print(" [bold]2.[/bold] OpenAI")
+ console.print(" [bold]3.[/bold] Ollama (local, no key needed)")
+
+ while True:
+ try:
+ choice = input("\nEnter choice [1/2/3]: ").strip()
+ except (EOFError, KeyboardInterrupt):
+ return None
+
+ if choice == "3":
+ cx_print("✓ Using Ollama (local mode)", "success")
+ return "ollama"
+
+ if choice == "1":
+ return "anthropic"
+ elif choice == "2":
+ return "openai"
+ else:
+ cx_print("Invalid choice. Please enter 1, 2, or 3.", "warning")
+
+ def _get_and_validate_key(self, provider: str) -> str | None:
+ """Get and validate API key from user."""
+ provider_name = "Anthropic" if provider == "anthropic" else "OpenAI"
+ prefix_hint = "sk-ant-" if provider == "anthropic" else "sk-"
+ cx_print(f"Enter your {provider_name} API key (starts with '{prefix_hint}'):", "info")
+
+ try:
+ key = input("> ").strip()
+ except (EOFError, KeyboardInterrupt):
+ return None
+
+ if not key:
+ cx_print("Cancelled.", "info")
+ return None
+
+ # Validate format
+ detected = self._get_provider_from_key(key)
+ if detected != provider:
+ cx_print(f"⚠️ Key doesn't match expected {provider_name} format", "warning")
+ return None
+
+ return key
+
+ def _ask_to_save_key(self, key: str, provider: str) -> None:
+ """Ask user if they want to save the key."""
+ print(f"\nSave to ~/{CORTEX_DIR}/{CORTEX_ENV_FILE}? [Y/n] ", end="")
+ try:
+ response = input().strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ response = "n"
+
+ if response != "n":
+ self._save_key_to_env(key, provider)
+ # Update cache to point to the default location
+ target_env = Path.home() / CORTEX_DIR / CORTEX_ENV_FILE
+ self._cache_key_location(key, provider, str(target_env))
+ cx_print(f"✓ Key saved to ~/{CORTEX_DIR}/{CORTEX_ENV_FILE}", "success")
+
+ def _maybe_save_found_key(self, key: str, provider: str, source: str) -> None:
+ """Offer to save a detected key to ~/.cortex/.env."""
+ # Skip prompting for environment variables - they're already properly configured
+ if source == "environment":
+ return
+
+ target_env = Path.home() / CORTEX_DIR / CORTEX_ENV_FILE
+ try:
+ source_path = Path(source).expanduser()
+ if source_path.exists() and source_path.resolve() == target_env.resolve():
+ return
+ except (OSError, ValueError):
+ # If source is not a valid path, continue prompting for file-based sources
+ pass
+
+ cx_print("API key found at following location", "info")
+ console.print(f"{source}")
+ print(f"\nSave to ~/{CORTEX_DIR}/{CORTEX_ENV_FILE}? [Y/n] ", end="")
+
+ try:
+ response = input().strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ response = "n"
+
+ if response != "n":
+ self._save_key_to_env(key, provider)
+ # Update cache to point to the new default location
+ self._cache_key_location(key, provider, str(target_env))
+ cx_print("✓ Key saved to ~/.cortex/.env", "success")
+
+ def _get_check_locations(self) -> list[tuple]:
+ """
+ Get locations to check in priority order.
+
+ Returns:
+ List of (source, env_vars) tuples
+ """
+ return [
+ ("environment", ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]),
+ (Path.home() / CORTEX_DIR / CORTEX_ENV_FILE, ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]),
+ (Path.home() / ".config" / "anthropic" / "credentials.json", ["ANTHROPIC_API_KEY"]),
+ (Path.home() / ".config" / "openai" / "credentials.json", ["OPENAI_API_KEY"]),
+ (Path.cwd() / ".env", ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]),
+ ]
+
+ def _extract_key_from_file(self, file_path: Path, env_var: str) -> str | None:
+ """
+ Extract API key from a file.
+
+ Supports both simple KEY=value format and JSON files.
+
+ Args:
+ file_path: Path to file to check
+ env_var: Environment variable name to look for
+
+ Returns:
+ The API key value or None
+ """
+ if not file_path.exists():
+ return None
+
+ try:
+ content = file_path.read_text().strip()
+
+ # Try extraction methods in order
+ key = self._extract_from_json(content, env_var)
+ if key:
+ return key
+
+ key = self._extract_from_env_format(content, env_var)
+ if key:
+ return key
+
+ return self._extract_raw_key(content)
+
+ except Exception:
+ # Silently ignore read errors
+ pass
+
+ return None
+
+ def _extract_from_json(self, content: str, env_var: str) -> str | None:
+ """Extract API key from JSON content."""
+ try:
+ data = json.loads(content)
+ # Look for key in common locations
+ for key_field in API_KEY_FIELD_NAMES + [env_var]:
+ if key_field in data:
+ return data[key_field]
+ except json.JSONDecodeError:
+ # Content is not valid JSON; indicate no key found from JSON and let callers try other formats.
+ return None
+
+ def _extract_from_env_format(self, content: str, env_var: str) -> str | None:
+ """Extract API key from KEY=value format."""
+ pattern = rf"^{env_var}\s*=\s*(.+?)(?:\s*#.*)?$"
+ match = re.search(pattern, content, re.MULTILINE)
+ if match:
+ return match.group(1).strip("\"'")
+ return None
+
+ def _extract_raw_key(self, content: str) -> str | None:
+ """Extract raw API key from content (handles single-line files)."""
+ for line in content.split("\n"):
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+
+ # Check if line is just the key
+ if self._is_valid_key(line):
+ return line
+
+ # Check if line is KEY=VALUE format
+ if "=" in line:
+ _, value = line.split("=", 1)
+ value = value.strip("\"'").strip()
+ if self._is_valid_key(value):
+ return value
+
+ return None
+
+ def _is_valid_key(self, value: str) -> bool:
+ """Check if a value matches known API key patterns.
+
+ More specific (longer) prefixes are checked first to avoid ambiguity
+ when prefixes overlap (e.g., "sk-ant-" vs "sk-").
+ """
+ for prefix in sorted(KEY_PATTERNS.keys(), key=len, reverse=True):
+ if value.startswith(prefix):
+ return True
+ return False
+
+ def _get_provider_from_var(self, env_var: str) -> str:
+ """Get provider name from environment variable name."""
+ return ENV_VAR_PROVIDERS.get(env_var, "unknown")
+
+ def _get_provider_from_key(self, key: str) -> str | None:
+ """Get provider name from API key format.
+
+ More specific (longer) prefixes are checked first to ensure that
+ overlapping prefixes resolve to the most specific provider.
+ """
+ for prefix, provider in sorted(
+ KEY_PATTERNS.items(), key=lambda item: len(item[0]), reverse=True
+ ):
+ if key.startswith(prefix):
+ return provider
+ return None
+
+ def _atomic_write(self, target_file: Path, content: str) -> None:
+ """
+ Atomically write content to a file to prevent corruption from concurrent access.
+
+ Args:
+ target_file: The file to write to
+ content: The content to write
+ """
+ temp_file = target_file.with_suffix(".tmp")
+ temp_file.parent.mkdir(parents=True, exist_ok=True)
+ temp_file.write_text(content)
+ temp_file.chmod(0o600)
+ temp_file.replace(target_file)
+
+ def _cache_key_location(self, key: str, provider: str, source: str):
+ """
+ Cache the location where a key was found.
+
+ Uses atomic write operations to prevent corruption from concurrent access.
+
+ Args:
+ key: The API key
+ provider: Provider name
+ source: Where the key was found
+ """
+ try:
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
+ cache_data = {
+ "provider": provider,
+ "source": source,
+ "key_hint": key[:10] + "..." if len(key) > 10 else key,
+ }
+
+ self._atomic_write(self.cache_file, json.dumps(cache_data, indent=2))
+
+ except Exception:
+ # Silently ignore cache errors
+ pass
+
+ def _get_cached_key(self) -> tuple[str, str] | None:
+ """
+ Retrieve cached key location info.
+
+ Returns:
+ Tuple of (provider, source) or None
+ """
+ if not self.cache_file.exists():
+ return None
+
+ try:
+ data = json.loads(self.cache_file.read_text())
+ # Check if the source still has a valid key
+ provider = data.get("provider")
+ source = data.get("source")
+
+ # Return cached info (caller will validate key still exists)
+ if provider and source:
+ return (provider, source)
+ except Exception:
+ return None
+
+ return None
+
+ def _get_env_var_name(self, provider: str) -> str:
+ """Get environment variable name from provider name."""
+ reverse_mapping = {v: k for k, v in ENV_VAR_PROVIDERS.items()}
+ return reverse_mapping.get(provider, "UNKNOWN_API_KEY")
+
+ def _read_env_file(self, env_file: Path) -> str:
+ """Read .env file content, return empty string if not exists."""
+ return env_file.read_text() if env_file.exists() else ""
+
+ def _write_env_file(self, env_file: Path, content: str) -> None:
+ """Write .env file with secure permissions."""
+ env_file.parent.mkdir(parents=True, exist_ok=True)
+ env_file.write_text(content)
+ env_file.chmod(0o600)
+
+ def _update_or_append_key(self, existing: str, var_name: str, key: str) -> str:
+ """Update existing key or append new one to file content."""
+ if f"{var_name}=" in existing:
+ # Replace existing
+ pattern = rf"^{var_name}=.*$"
+ return re.sub(pattern, f"{var_name}={key}", existing, flags=re.MULTILINE)
+ else:
+ # Append
+ if existing and not existing.endswith("\n"):
+ existing += "\n"
+ return existing + f"{var_name}={key}\n"
+
+ def _save_key_to_env(self, key: str, provider: str):
+ """
+ Save API key to ~/.cortex/.env.
+
+ Uses atomic write operations to prevent corruption from concurrent access.
+
+ Args:
+ key: The API key to save
+ provider: Provider name
+ """
+ try:
+ env_file = Path.home() / CORTEX_DIR / CORTEX_ENV_FILE
+ var_name = self._get_env_var_name(provider)
+ existing = self._read_env_file(env_file)
+ updated = self._update_or_append_key(existing, var_name, key)
+
+ self._atomic_write(env_file, updated)
+
+ except Exception as e:
+ # If save fails, print warning but don't crash
+ cx_print(f"Warning: Could not save key to ~/.cortex/.env: {e}", "warning")
+
+
+def auto_detect_api_key() -> tuple[bool, str | None, str | None, str | None]:
+ """
+ Convenience function to auto-detect API key.
+
+ Returns:
+ Tuple of (found, key, provider, source)
+ """
+ detector = APIKeyDetector()
+ return detector.detect()
+
+
+def setup_api_key() -> tuple[bool, str | None, str | None]:
+ """
+ Setup API key by auto-detecting or prompting user.
+
+ Returns:
+ Tuple of (success, key, provider)
+ """
+ detector = APIKeyDetector()
+
+ # Try auto-detection first
+ found, key, provider, source = detector.detect()
+ if found:
+ # Only show "Found" message for non-default locations
+ # ~/.cortex/.env is our canonical location, so no need to announce it
+ default_location = str(Path.home() / CORTEX_DIR / CORTEX_ENV_FILE)
+ if source != default_location:
+ display_name = PROVIDER_DISPLAY_NAMES.get(provider, provider.upper())
+ cx_print(f"🔑 Found {display_name} API key in {source}", "success")
+ detector._maybe_save_found_key(key, provider, source)
+ return (True, key, provider)
+
+ # Prompt for manual entry
+ entered, key, provider = detector.prompt_for_key()
+ if entered:
+ return (True, key, provider)
+
+ return (False, None, None)
diff --git a/cortex/cli.py b/cortex/cli.py
index 7d248002..550fc9c6 100644
--- a/cortex/cli.py
+++ b/cortex/cli.py
@@ -6,6 +6,7 @@
from datetime import datetime
from typing import Any
+from cortex.api_key_detector import auto_detect_api_key, setup_api_key
from cortex.ask import AskHandler
from cortex.branding import VERSION, console, cx_header, cx_print, show_banner
from cortex.coordinator import InstallationCoordinator, InstallationStep, StepStatus
@@ -43,23 +44,28 @@ def _debug(self, message: str):
console.print(f"[dim][DEBUG] {message}[/dim]")
def _get_api_key(self) -> str | None:
- # Check if using Ollama or Fake provider (no API key needed)
- provider = self._get_provider()
- if provider == "ollama":
- self._debug("Using Ollama (no API key required)")
- return "ollama-local" # Placeholder for Ollama
- if provider == "fake":
+ # 1. Check explicit provider override first (fake/ollama need no key)
+ explicit_provider = os.environ.get("CORTEX_PROVIDER", "").lower()
+ if explicit_provider == "fake":
self._debug("Using Fake provider for testing")
- return "fake-key" # Placeholder for Fake provider
-
- is_valid, detected_provider, error = validate_api_key()
- if not is_valid:
- self._print_error(error)
- cx_print("Run [bold]cortex wizard[/bold] to configure your API key.", "info")
- cx_print("Or use [bold]CORTEX_PROVIDER=ollama[/bold] for offline mode.", "info")
- return None
- api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")
- return api_key
+ return "fake-key"
+ if explicit_provider == "ollama":
+ self._debug("Using Ollama (no API key required)")
+ return "ollama-local"
+
+ # 2. Try auto-detection + prompt to save (setup_api_key handles both)
+ success, key, detected_provider = setup_api_key()
+ if success:
+ self._debug(f"Using {detected_provider} API key")
+ # Store detected provider so _get_provider can use it
+ self._detected_provider = detected_provider
+ return key
+
+ # Still no key
+ self._print_error("No API key found or provided")
+ cx_print("Run [bold]cortex wizard[/bold] to configure your API key.", "info")
+ cx_print("Or use [bold]CORTEX_PROVIDER=ollama[/bold] for offline mode.", "info")
+ return None
def _get_provider(self) -> str:
# Check environment variable for explicit provider choice
@@ -67,7 +73,14 @@ def _get_provider(self) -> str:
if explicit_provider in ["ollama", "openai", "claude", "fake"]:
return explicit_provider
- # Auto-detect based on available API keys
+ # Use provider from auto-detection (set by _get_api_key)
+ detected = getattr(self, "_detected_provider", None)
+ if detected == "anthropic":
+ return "claude"
+ elif detected == "openai":
+ return "openai"
+
+ # Check env vars (may have been set by auto-detect)
if os.environ.get("ANTHROPIC_API_KEY"):
return "claude"
elif os.environ.get("OPENAI_API_KEY"):
diff --git a/docs/ZERO_CONFIG_API_KEYS.md b/docs/ZERO_CONFIG_API_KEYS.md
new file mode 100644
index 00000000..47e74f7c
--- /dev/null
+++ b/docs/ZERO_CONFIG_API_KEYS.md
@@ -0,0 +1,283 @@
+# Zero Config API Keys
+
+**Module:** `cortex/api_key_detector.py`
+
+## Overview
+
+Cortex automatically finds API keys from common locations without requiring users to manually set environment variables. This "zero config" approach means you can start using Cortex immediately if you already have API keys saved from other tools like the Claude CLI or OpenAI CLI.
+
+```bash
+$ cortex install nginx
+
+🔑 Found ANTHROPIC API key in ~/.config/anthropic/credentials.json
+📦 Installing nginx...
+```
+
+## Features
+
+- **Auto-detection** from 5 common locations
+- **Caching** to avoid repeated file checks
+- **Smart save prompts** - only for file-based keys, not environment variables
+- **Provider selection** when no key is found
+- **Secure storage** with proper file permissions (600)
+
+## Detection Locations
+
+Cortex checks these locations in order of priority:
+
+| Priority | Location | Description |
+|----------|----------|-------------|
+| 1 | Environment variables | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` *(used immediately, no save prompt)* |
+| 2 | `~/.cortex/.env` | Cortex's own config directory |
+| 3 | `~/.config/anthropic/credentials.json` | Claude CLI location |
+| 4 | `~/.config/openai/credentials.json` | OpenAI CLI location |
+| 5 | `.env` in current directory | Project-local environment file |
+
+### File Format Support
+
+The detector supports multiple file formats:
+
+**JSON format** (Claude/OpenAI CLI style):
+```json
+{"api_key": "sk-ant-api03-..."}
+```
+
+**Environment format** (.env style):
+```bash
+ANTHROPIC_API_KEY=sk-ant-api03-...
+```
+
+**Raw key** (single line):
+```
+sk-ant-api03-...
+```
+
+## Usage
+
+### Automatic Detection
+
+Simply run any Cortex command. If a key is found outside the default location, you'll be prompted to save it:
+
+```bash
+$ cortex install nginx --dry-run
+
+🔑 Found ANTHROPIC API key in ~/.config/anthropic/credentials.json
+API key found at following location
+/home/user/.config/anthropic/credentials.json
+
+Save to ~/.cortex/.env? [Y/n] y
+✓ Key saved to ~/.cortex/.env
+```
+
+**Environment variables are used immediately without prompting**, as they are already properly configured:
+
+```bash
+$ export ANTHROPIC_API_KEY=sk-ant-api03-...
+$ cortex install nginx --dry-run
+
+📦 Planning installation...
+```
+
+On subsequent runs, Cortex uses the cached location silently:
+
+```bash
+$ cortex install nginx --dry-run
+
+📦 Planning installation...
+```
+
+### No Key Found - Provider Selection
+
+If no API key is found, Cortex prompts you to select a provider:
+
+```bash
+$ cortex install nginx
+
+⚠️ No API key found. Select a provider:
+ 1. Claude (Anthropic)
+ 2. OpenAI
+ 3. Ollama (local, no key needed)
+
+Enter choice [1/2/3]: 1
+Enter your Anthropic API key (starts with 'sk-ant-'):
+> sk-ant-api03-...
+
+Save to ~/.cortex/.env? [Y/n] y
+✓ Key saved to ~/.cortex/.env
+```
+
+### Ollama (No API Key Required)
+
+For local, free inference without an API key:
+
+```bash
+$ cortex install nginx
+
+⚠️ No API key found. Select a provider:
+ 1. Claude (Anthropic)
+ 2. OpenAI
+ 3. Ollama (local, no key needed)
+
+Enter choice [1/2/3]: 3
+✓ Using Ollama (local mode)
+```
+
+Or set the provider explicitly:
+
+```bash
+export CORTEX_PROVIDER=ollama
+cortex install nginx
+```
+
+## Configuration
+
+### Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `ANTHROPIC_API_KEY` | Anthropic/Claude API key |
+| `OPENAI_API_KEY` | OpenAI API key |
+| `CORTEX_PROVIDER` | Force provider: `claude`, `openai`, `ollama`, `fake` |
+
+### File Locations
+
+| File | Purpose |
+|------|---------|
+| `~/.cortex/.env` | Saved API keys (secure, 600 permissions) |
+| `~/.cortex/.api_key_cache` | Cached key location for fast lookup |
+
+### Cache Format
+
+The cache file stores metadata about the last detected key:
+
+```json
+{
+ "provider": "anthropic",
+ "source": "/home/user/.cortex/.env",
+ "key_hint": "sk-ant-api..."
+}
+```
+
+## Security
+
+### File Permissions
+
+All sensitive files are created with mode `600` (user read/write only):
+
+- `~/.cortex/.env` - API keys
+- `~/.cortex/.api_key_cache` - Cache metadata
+
+### Key Validation
+
+API keys are validated by prefix pattern:
+
+| Prefix | Provider |
+|--------|----------|
+| `sk-ant-` | Anthropic/Claude |
+| `sk-` | OpenAI |
+
+### No Key Logging
+
+API keys are never logged in full. Only hints like `sk-ant-api...` appear in cache files.
+
+## Troubleshooting
+
+### Key Not Being Detected
+
+1. **Check file permissions:**
+ ```bash
+ ls -la ~/.config/anthropic/credentials.json
+ # Should be readable by your user
+ ```
+
+2. **Verify file format:**
+ ```bash
+ cat ~/.config/anthropic/credentials.json
+ # Should be valid JSON: {"api_key": "sk-ant-..."}
+ ```
+
+3. **Clear cache and retry:**
+ ```bash
+ rm ~/.cortex/.api_key_cache
+ cortex install nginx --dry-run
+ ```
+
+### Wrong Provider Selected
+
+If Cortex selects the wrong provider:
+
+```bash
+# Force a specific provider
+export CORTEX_PROVIDER=claude
+cortex install nginx
+```
+
+### Save Prompt Keeps Appearing
+
+If prompted to save repeatedly:
+
+1. **Check if save succeeded:**
+ ```bash
+ cat ~/.cortex/.env
+ # Should contain ANTHROPIC_API_KEY=sk-ant-...
+ ```
+
+2. **Check cache points to correct location:**
+ ```bash
+ cat ~/.cortex/.api_key_cache
+ # "source" should be "/home/user/.cortex/.env"
+ ```
+
+3. **Check file permissions:**
+ ```bash
+ ls -la ~/.cortex/.env
+ # Should be -rw------- (600)
+ ```
+
+### API Key Invalid Errors
+
+If you get authentication errors:
+
+1. **Verify key is complete** (no truncation or line breaks):
+ ```bash
+ cat ~/.cortex/.env | wc -c
+ # Anthropic keys are ~100+ characters
+ ```
+
+2. **Test key directly:**
+ ```bash
+ curl https://api.anthropic.com/v1/messages \
+ -H "x-api-key: $(grep ANTHROPIC ~/.cortex/.env | cut -d= -f2)" \
+ -H "content-type: application/json" \
+ -d '{"model":"claude-3-haiku-20240307","max_tokens":10,"messages":[{"role":"user","content":"Hi"}]}'
+ ```
+
+## Integration with Other Cortex Features
+
+### First-Run Wizard
+
+The first-run wizard (`cortex setup`) uses the API key detector internally. See [FIRST_RUN_WIZARD.md](FIRST_RUN_WIZARD.md).
+
+### Environment Management
+
+API keys can also be managed via `cortex env`. See [ENV_MANAGEMENT.md](ENV_MANAGEMENT.md).
+
+### Configuration Export
+
+When exporting configuration with `cortex config export`, API keys are **not** included by default for security.
+
+## Testing
+
+Run the API key detector tests:
+
+```bash
+pytest tests/test_api_key_detector.py -v
+```
+
+Test coverage includes:
+- Detection from all 5 locations
+- Priority ordering
+- JSON and .env file parsing
+- Cache creation and retrieval
+- Save functionality
+- User prompts
diff --git a/tests/test_api_key_detector.py b/tests/test_api_key_detector.py
new file mode 100644
index 00000000..f67a17e6
--- /dev/null
+++ b/tests/test_api_key_detector.py
@@ -0,0 +1,374 @@
+"""
+Tests for API Key Auto-Detection Module
+
+Tests the APIKeyDetector class for auto-detecting API keys from
+various common locations.
+"""
+
+import json
+import os
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from cortex.api_key_detector import APIKeyDetector, auto_detect_api_key, setup_api_key
+
+
+class TestAPIKeyDetector:
+ """Test the APIKeyDetector class."""
+
+ @pytest.fixture
+ def detector(self):
+ """Create a detector with a temporary cache directory."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ detector = APIKeyDetector(cache_dir=Path(tmpdir))
+ yield detector
+
+ @pytest.fixture
+ def temp_home(self):
+ """Create a temporary home directory."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield Path(tmpdir)
+
+ def _setup_detector_with_home(self, temp_home, cortex_env_content=None):
+ """Helper to create detector with mocked home directory."""
+ if cortex_env_content:
+ cortex_dir = temp_home / ".cortex"
+ cortex_dir.mkdir()
+ (cortex_dir / ".env").write_text(cortex_env_content)
+
+ detector = APIKeyDetector(cache_dir=temp_home / ".cortex")
+ return detector
+
+ def _setup_config_file(self, temp_home, config_path_parts, content):
+ """Helper to create a config file with content."""
+ config_path = temp_home / Path(*config_path_parts)
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(content)
+
+ def _detect_with_mocked_home(self, detector, temp_home):
+ """Helper to run detect with mocked home directory."""
+ with patch("pathlib.Path.home", return_value=temp_home):
+ with patch.dict(os.environ, {}, clear=True):
+ return detector.detect()
+
+ def _assert_found_key(self, result, expected_key, expected_provider):
+ """Helper to assert successful key detection."""
+ found, key, provider, _ = result
+ assert found is True
+ assert key == expected_key
+ assert provider == expected_provider
+
+ def _assert_env_contains(self, temp_home, expected_lines, unexpected_lines=None):
+ """Helper to assert .env file content."""
+ content = (temp_home / ".cortex" / ".env").read_text()
+ for line in expected_lines:
+ assert line in content
+ if unexpected_lines:
+ for line in unexpected_lines:
+ assert line not in content
+
+ def test_detect_from_environment(self, detector):
+ """Test detection from environment variables."""
+ with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test123"}, clear=True):
+ found, key, provider, source = detector.detect()
+ assert found is True
+ assert key == "sk-ant-test123"
+ assert provider == "anthropic"
+ assert source == "environment"
+
+ def test_detect_openai_from_environment(self, detector):
+ """Test detection of OpenAI key from environment."""
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test123"}, clear=True):
+ found, key, provider, source = detector.detect()
+ assert found is True
+ assert key == "sk-test123"
+ assert provider == "openai"
+ assert source == "environment"
+
+ def test_detect_from_cortex_env_file(self, temp_home):
+ """Test detection from ~/.cortex/.env file."""
+ detector = self._setup_detector_with_home(
+ temp_home, "ANTHROPIC_API_KEY=sk-ant-fromfile123\n"
+ )
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ result = self._detect_with_mocked_home(detector, temp_home)
+ self._assert_found_key(result, "sk-ant-fromfile123", "anthropic")
+
+ def test_detect_from_anthropic_config(self, temp_home):
+ """Test detection from ~/.config/anthropic (Claude CLI location)."""
+ detector = self._setup_detector_with_home(temp_home)
+ self._setup_config_file(
+ temp_home,
+ (".config", "anthropic", "credentials.json"),
+ json.dumps({"key": "sk-ant-config123"}),
+ )
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ result = self._detect_with_mocked_home(detector, temp_home)
+ self._assert_found_key(result, "sk-ant-config123", "anthropic")
+
+ def test_detect_from_openai_config(self, temp_home):
+ """Test detection from ~/.config/openai."""
+ detector = self._setup_detector_with_home(temp_home)
+ self._setup_config_file(
+ temp_home,
+ (".config", "openai", "credentials.json"),
+ json.dumps({"key": "sk-openai123"}),
+ )
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ result = self._detect_with_mocked_home(detector, temp_home)
+ self._assert_found_key(result, "sk-openai123", "openai")
+
+ def test_detect_from_current_dir(self, detector):
+ """Test detection from .env in current directory."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ env_file = Path(tmpdir) / ".env"
+ env_file.write_text("ANTHROPIC_API_KEY=sk-ant-cwd123\n")
+
+ with patch("pathlib.Path.cwd", return_value=Path(tmpdir)):
+ # Also need to mock home so it doesn't find other keys
+ with patch("pathlib.Path.home", return_value=Path(tmpdir)):
+ test_detector = APIKeyDetector(cache_dir=Path(tmpdir) / ".cortex")
+ with patch.dict(os.environ, {}, clear=True):
+ found, key, provider, _ = test_detector.detect()
+ assert found is True
+ assert key == "sk-ant-cwd123"
+ assert provider == "anthropic"
+
+ def test_priority_order(self, temp_home):
+ """Test that detection respects priority order."""
+ # Create keys in multiple locations
+ detector = self._setup_detector_with_home(temp_home, "ANTHROPIC_API_KEY=sk-ant-cortex\n")
+ self._setup_config_file(
+ temp_home,
+ (".config", "anthropic", "credentials.json"),
+ json.dumps({"key": "sk-ant-config"}),
+ )
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ # Should find cortex/.env first (higher priority)
+ result = self._detect_with_mocked_home(detector, temp_home)
+ self._assert_found_key(result, "sk-ant-cortex", "anthropic")
+
+ def test_no_key_found(self, detector):
+ """Test when no key is found."""
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("pathlib.Path.home", return_value=Path("/nonexistent")):
+ found, key, provider, _ = detector.detect()
+ assert found is False
+ assert key is None
+ assert provider is None
+
+ def test_extract_key_from_env_file(self, detector):
+ """Test extracting key from .env format file."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
+ f.write("ANTHROPIC_API_KEY=sk-ant-test123\n")
+ f.write("OTHER_VAR=value\n")
+ f.flush()
+
+ try:
+ key = detector._extract_key_from_file(Path(f.name), "ANTHROPIC_API_KEY")
+ assert key == "sk-ant-test123"
+ finally:
+ os.unlink(f.name)
+
+ def test_extract_key_from_json(self, detector):
+ """Test extracting key from JSON file."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
+ json.dump({"key": "sk-ant-json123"}, f)
+ f.flush()
+
+ try:
+ key = detector._extract_key_from_file(Path(f.name), "ANTHROPIC_API_KEY")
+ assert key == "sk-ant-json123"
+ finally:
+ os.unlink(f.name)
+
+ def test_cache_key_location(self, detector):
+ """Test caching key location information."""
+ detector._cache_key_location("sk-ant-test", "anthropic", "test/location")
+
+ assert detector.cache_file.exists()
+ data = json.loads(detector.cache_file.read_text())
+ assert data["provider"] == "anthropic"
+ assert data["source"] == "test/location"
+
+ def test_cache_file_permissions(self, detector):
+ """Test that cache file has secure permissions."""
+ detector._cache_key_location("sk-ant-test", "anthropic", "test/location")
+
+ # Check file permissions (should be 600 = user read/write only)
+ mode = detector.cache_file.stat().st_mode
+ # Extract permission bits
+ perms = mode & 0o777
+ assert perms == 0o600
+
+ def _setup_detector_with_env_file(self, temp_home, existing_content=""):
+ """Helper to setup detector with existing .env file."""
+ cortex_dir = temp_home / ".cortex"
+ cortex_dir.mkdir(parents=True, exist_ok=True)
+ env_file = cortex_dir / ".env"
+ if existing_content:
+ env_file.write_text(existing_content)
+ return APIKeyDetector(cache_dir=cortex_dir)
+
+ def test_save_key_to_env_file(self, temp_home):
+ """Test saving key to ~/.cortex/.env."""
+ detector = self._setup_detector_with_env_file(temp_home)
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ detector._save_key_to_env("sk-ant-saved", "anthropic")
+ self._assert_env_contains(temp_home, ["ANTHROPIC_API_KEY=sk-ant-saved"])
+
+ def test_save_key_appends_to_existing(self, temp_home):
+ """Test that save appends to existing .env file."""
+ detector = self._setup_detector_with_env_file(temp_home, "OTHER_VAR=value\n")
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ detector._save_key_to_env("sk-ant-saved", "anthropic")
+ self._assert_env_contains(
+ temp_home, ["OTHER_VAR=value", "ANTHROPIC_API_KEY=sk-ant-saved"]
+ )
+
+ def test_save_key_replaces_existing(self, temp_home):
+ """Test that save replaces existing key in .env file."""
+ detector = self._setup_detector_with_env_file(temp_home, "ANTHROPIC_API_KEY=sk-ant-old\n")
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ detector._save_key_to_env("sk-ant-new", "anthropic")
+ self._assert_env_contains(
+ temp_home, ["ANTHROPIC_API_KEY=sk-ant-new"], unexpected_lines=["sk-ant-old"]
+ )
+
+ @patch("builtins.input", return_value="y")
+ def test_maybe_save_found_key_prompts_and_saves(self, mock_input, temp_home):
+ """Detected key from file should prompt to save and persist when accepted."""
+ detector = self._setup_detector_with_home(temp_home)
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ with patch.object(detector, "_save_key_to_env") as mock_save:
+ # Use a file source (not environment) to test prompting
+ detector._maybe_save_found_key(
+ "sk-ant-found", "anthropic", "~/.config/anthropic/credentials.json"
+ )
+ mock_save.assert_called_once_with("sk-ant-found", "anthropic")
+
+ @patch("builtins.input")
+ def test_maybe_save_found_key_skips_when_already_saved(self, mock_input, temp_home):
+ """Skip save prompt when key already lives in ~/.cortex/.env."""
+ detector = self._setup_detector_with_home(temp_home, "ANTHROPIC_API_KEY=sk-ant-existing\n")
+ source = str(temp_home / ".cortex" / ".env")
+
+ with patch("pathlib.Path.home", return_value=temp_home):
+ with patch.object(detector, "_save_key_to_env") as mock_save:
+ detector._maybe_save_found_key("sk-ant-existing", "anthropic", source)
+ mock_input.assert_not_called()
+ mock_save.assert_not_called()
+
+ def test_provider_detection_from_key_format(self, detector):
+ """Test provider detection based on key format."""
+ assert detector._get_provider_from_var("ANTHROPIC_API_KEY") == "anthropic"
+ assert detector._get_provider_from_var("OPENAI_API_KEY") == "openai"
+
+ @patch("cortex.api_key_detector.cx_print")
+ def test_prompt_for_key_user_input(self, mock_print, detector):
+ """Test prompting user for key input."""
+ # New flow: choice 1 (Claude), then key, then y to save
+ with patch("builtins.input", side_effect=["1", "sk-ant-manual", "y"]):
+ entered, key, provider = detector.prompt_for_key()
+ assert entered is True
+ assert key == "sk-ant-manual"
+ assert provider == "anthropic"
+
+ @patch("cortex.api_key_detector.cx_print")
+ def test_prompt_for_key_invalid_format(self, mock_print, detector):
+ """Test prompting with invalid key format."""
+ # Choice 1 (Claude), but enter an invalid key
+ with patch("builtins.input", side_effect=["1", "invalid-key"]):
+ entered, _, _ = detector.prompt_for_key()
+ assert entered is False
+
+ @patch("cortex.api_key_detector.cx_print")
+ def test_prompt_cancelled(self, mock_print, detector):
+ """Test when user cancels prompt."""
+ with patch("builtins.input", side_effect=KeyboardInterrupt()):
+ entered, _, _ = detector.prompt_for_key()
+ assert entered is False
+
+ @patch("cortex.api_key_detector.cx_print")
+ def test_prompt_empty_input(self, mock_print, detector):
+ """Test when user provides empty input."""
+ # Choice 1 (Claude), but enter empty key
+ with patch("builtins.input", side_effect=["1", ""]):
+ entered, _, _ = detector.prompt_for_key()
+ assert entered is False
+
+
+class TestConvenienceFunctions:
+ """Test convenience functions."""
+
+ def test_auto_detect_api_key(self):
+ """Test auto_detect_api_key function."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # Use fresh detector with isolated cache
+ def mock_detector_init(self, cache_dir=None):
+ """Mock APIKeyDetector.__init__ to use temporary cache directory."""
+ self.cache_dir = Path(tmpdir)
+ self.cache_file = Path(tmpdir) / ".api_key_cache"
+
+ with patch("cortex.api_key_detector.APIKeyDetector.__init__", mock_detector_init):
+ with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=True):
+ found, key, provider, _ = auto_detect_api_key()
+ assert found is True
+ assert key == "sk-ant-test"
+ assert provider == "anthropic"
+
+ @patch("cortex.api_key_detector.APIKeyDetector._maybe_save_found_key")
+ @patch("cortex.api_key_detector.APIKeyDetector.detect")
+ @patch("cortex.api_key_detector.APIKeyDetector.prompt_for_key")
+ def test_setup_api_key_auto_detect(self, mock_prompt, mock_detect, mock_save):
+ """Test setup_api_key with auto-detection."""
+ mock_detect.return_value = (True, "sk-ant-test", "anthropic", "env")
+
+ success, key, provider = setup_api_key()
+ assert success is True
+ assert key == "sk-ant-test"
+ assert provider == "anthropic"
+ mock_save.assert_called_once_with("sk-ant-test", "anthropic", "env")
+
+ @patch("cortex.api_key_detector.APIKeyDetector._maybe_save_found_key")
+ @patch("cortex.api_key_detector.APIKeyDetector.detect")
+ @patch("cortex.api_key_detector.APIKeyDetector.prompt_for_key")
+ def test_setup_api_key_fallback_to_prompt(self, mock_prompt, mock_detect, mock_save):
+ """Test setup_api_key falls back to prompt."""
+ mock_detect.return_value = (False, None, None, None)
+ mock_prompt.return_value = (True, "sk-ant-manual", "anthropic")
+
+ success, key, provider = setup_api_key()
+ assert success is True
+ assert key == "sk-ant-manual"
+ assert provider == "anthropic"
+ mock_save.assert_not_called()
+
+ @patch("cortex.api_key_detector.APIKeyDetector._maybe_save_found_key")
+ @patch("cortex.api_key_detector.APIKeyDetector.detect")
+ @patch("cortex.api_key_detector.APIKeyDetector.prompt_for_key")
+ def test_setup_api_key_failure(self, mock_prompt, mock_detect, mock_save):
+ """Test setup_api_key when both detection and prompt fail."""
+ mock_detect.return_value = (False, None, None, None)
+ mock_prompt.return_value = (False, None, None)
+
+ success, key, provider = setup_api_key()
+ assert success is False
+ assert key is None
+ assert provider is None
+ mock_save.assert_not_called()
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1f97bc1a..093a8e50 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,6 +1,8 @@
import os
import sys
+import tempfile
import unittest
+from pathlib import Path
from unittest.mock import Mock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -11,37 +13,57 @@
class TestCortexCLI(unittest.TestCase):
def setUp(self):
self.cli = CortexCLI()
+ # Use a temp dir for cache isolation
+ self._temp_dir = tempfile.TemporaryDirectory()
+ self._temp_home = Path(self._temp_dir.name)
+
+ def tearDown(self):
+ self._temp_dir.cleanup()
- @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-openai-key-123"}, clear=True)
def test_get_api_key_openai(self):
- api_key = self.cli._get_api_key()
- self.assertEqual(api_key, "sk-test-openai-key-123")
-
- @patch.dict(
- os.environ,
- {"ANTHROPIC_API_KEY": "sk-ant-test-claude-key-123", "OPENAI_API_KEY": ""},
- clear=True,
- )
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-openai-key-123"}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ api_key = self.cli._get_api_key()
+ self.assertEqual(api_key, "sk-test-openai-key-123")
+
def test_get_api_key_claude(self):
- api_key = self.cli._get_api_key()
- self.assertEqual(api_key, "sk-ant-test-claude-key-123")
+ with patch.dict(
+ os.environ,
+ {"ANTHROPIC_API_KEY": "sk-ant-test-claude-key-123", "OPENAI_API_KEY": ""},
+ clear=True,
+ ):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ api_key = self.cli._get_api_key()
+ self.assertEqual(api_key, "sk-ant-test-claude-key-123")
- @patch.dict(os.environ, {}, clear=True)
@patch("sys.stderr")
def test_get_api_key_not_found(self, mock_stderr):
- # When no API key is set, falls back to Ollama local mode
- api_key = self.cli._get_api_key()
- self.assertEqual(api_key, "ollama-local")
+ # When no API key is set and user selects Ollama, falls back to Ollama local mode
+ from cortex.api_key_detector import PROVIDER_MENU_CHOICES
+
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ with patch("builtins.input", return_value=PROVIDER_MENU_CHOICES["ollama"]):
+ api_key = self.cli._get_api_key()
+ self.assertEqual(api_key, "ollama-local")
- @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-openai-key-123"}, clear=True)
def test_get_provider_openai(self):
- provider = self.cli._get_provider()
- self.assertEqual(provider, "openai")
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-openai-key-123"}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ # Call _get_api_key first to populate _detected_provider
+ self.cli._get_api_key()
+ provider = self.cli._get_provider()
+ self.assertEqual(provider, "openai")
- @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test-claude-key-123"}, clear=True)
def test_get_provider_claude(self):
- provider = self.cli._get_provider()
- self.assertEqual(provider, "claude")
+ with patch.dict(
+ os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test-claude-key-123"}, clear=True
+ ):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ # Call _get_api_key first to populate _detected_provider
+ self.cli._get_api_key()
+ provider = self.cli._get_provider()
+ self.assertEqual(provider, "claude")
@patch("sys.stdout")
def test_print_status(self, mock_stdout):
diff --git a/tests/test_cli_extended.py b/tests/test_cli_extended.py
index ae1e6ca6..173d7a7d 100644
--- a/tests/test_cli_extended.py
+++ b/tests/test_cli_extended.py
@@ -6,7 +6,9 @@
import os
import sys
+import tempfile
import unittest
+from pathlib import Path
from unittest.mock import Mock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -19,32 +21,50 @@ class TestCortexCLIExtended(unittest.TestCase):
def setUp(self) -> None:
self.cli = CortexCLI()
+ # Use a temp dir for cache isolation
+ self._temp_dir = tempfile.TemporaryDirectory()
+ self._temp_home = Path(self._temp_dir.name)
+
+ def tearDown(self):
+ self._temp_dir.cleanup()
- @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-key"}, clear=True)
def test_get_api_key_openai(self) -> None:
- api_key = self.cli._get_api_key()
- self.assertEqual(api_key, "sk-test-key")
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-key"}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ api_key = self.cli._get_api_key()
+ self.assertEqual(api_key, "sk-test-key")
- @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test-claude-key"}, clear=True)
def test_get_api_key_claude(self) -> None:
- api_key = self.cli._get_api_key()
- self.assertEqual(api_key, "sk-ant-test-claude-key")
+ with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test-claude-key"}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ api_key = self.cli._get_api_key()
+ self.assertEqual(api_key, "sk-ant-test-claude-key")
- @patch.object(CortexCLI, "_get_provider", return_value="openai")
- @patch.dict(os.environ, {}, clear=True)
- def test_get_api_key_not_found(self, _mock_get_provider) -> None:
- api_key = self.cli._get_api_key()
- self.assertIsNone(api_key)
+ def test_get_api_key_not_found(self) -> None:
+ # When no API key is set and user selects Ollama, falls back to Ollama local mode
+ from cortex.api_key_detector import PROVIDER_MENU_CHOICES
+
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ with patch("builtins.input", return_value=PROVIDER_MENU_CHOICES["ollama"]):
+ api_key = self.cli._get_api_key()
+ self.assertEqual(api_key, "ollama-local")
- @patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True)
def test_get_provider_openai(self) -> None:
- provider = self.cli._get_provider()
- self.assertEqual(provider, "openai")
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ # Call _get_api_key first to populate _detected_provider
+ self.cli._get_api_key()
+ provider = self.cli._get_provider()
+ self.assertEqual(provider, "openai")
- @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}, clear=True)
def test_get_provider_claude(self) -> None:
- provider = self.cli._get_provider()
- self.assertEqual(provider, "claude")
+ with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}, clear=True):
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ # Call _get_api_key first to populate _detected_provider
+ self.cli._get_api_key()
+ provider = self.cli._get_provider()
+ self.assertEqual(provider, "claude")
def test_get_provider_override(self) -> None:
with patch.dict(
@@ -52,12 +72,15 @@ def test_get_provider_override(self) -> None:
{"CORTEX_PROVIDER": "claude", "OPENAI_API_KEY": "test-key"},
clear=True,
):
- provider = self.cli._get_provider()
- self.assertEqual(provider, "claude")
-
- del os.environ["CORTEX_PROVIDER"]
- provider = self.cli._get_provider()
- self.assertEqual(provider, "openai")
+ with patch("pathlib.Path.home", return_value=self._temp_home):
+ provider = self.cli._get_provider()
+ self.assertEqual(provider, "claude")
+
+ del os.environ["CORTEX_PROVIDER"]
+ # Call _get_api_key first to populate _detected_provider
+ self.cli._get_api_key()
+ provider = self.cli._get_provider()
+ self.assertEqual(provider, "openai")
@patch("cortex.cli.cx_print")
def test_print_status(self, mock_cx_print) -> None: