""" Configuration Manager for Skill Seekers Handles multi-profile GitHub tokens, API keys, and application settings. Provides secure storage with file permissions and auto-detection capabilities. """ import json import os import stat import sys from datetime import datetime, timedelta from pathlib import Path from typing import Any def _get_config_dir() -> Path: """Return platform-appropriate config directory.""" if sys.platform == "win32": return Path(os.environ.get("APPDATA", Path.home())) / "skill-seekers" return Path.home() / ".config" / "skill-seekers" def _get_progress_dir() -> Path: """Return platform-appropriate progress/data directory.""" if sys.platform == "win32": return Path(os.environ.get("LOCALAPPDATA", Path.home())) / "skill-seekers" / "progress" return Path.home() / ".local" / "share" / "skill-seekers" / "progress" class ConfigManager: """Manages Skill Seekers configuration with multi-token support.""" # Default paths (computed at runtime for cross-platform support) CONFIG_DIR = _get_config_dir() CONFIG_FILE = CONFIG_DIR / "config.json" WELCOME_FLAG = CONFIG_DIR / ".welcomed" PROGRESS_DIR = _get_progress_dir() # Default configuration DEFAULT_CONFIG = { "version": "1.0", "github": {"default_profile": None, "profiles": {}}, "rate_limit": { "default_timeout_minutes": 30, "auto_switch_profiles": True, "show_countdown": True, }, "resume": {"auto_save_interval_seconds": 60, "keep_progress_days": 7}, "api_keys": {"anthropic": None, "google": None, "openai": None}, "ai_enhancement": { "default_enhance_level": 1, # Default AI enhancement level (0-3) "local_batch_size": 20, # Patterns per Claude CLI call (default was 5) "local_parallel_workers": 3, # Concurrent Claude CLI calls }, "first_run": {"completed": False, "version": "2.7.0"}, } def __init__(self): """Initialize configuration manager.""" self.config_dir = _get_config_dir() self.config_file = self.config_dir / "config.json" self.progress_dir = _get_progress_dir() self._ensure_directories() # Check if config file exists before loading config_exists = self.config_file.exists() self.config = self._load_config() # Save config file if it was just created with defaults if not config_exists: self.save_config() def _ensure_directories(self): """Ensure configuration and progress directories exist with secure permissions.""" # Create main config and progress directories for directory in [self.config_dir, self.progress_dir]: directory.mkdir(parents=True, exist_ok=True) # Set directory permissions to 700 (rwx------) - Unix only if sys.platform != "win32": directory.chmod(stat.S_IRWXU) # Also create configs subdirectory for user custom configs configs_dir = self.config_dir / "configs" configs_dir.mkdir(exist_ok=True) if sys.platform != "win32": configs_dir.chmod(stat.S_IRWXU) def _load_config(self) -> dict[str, Any]: """Load configuration from file or create default.""" if not self.config_file.exists(): return self.DEFAULT_CONFIG.copy() try: with open(self.config_file) as f: config = json.load(f) # Merge with defaults for any missing keys config = self._merge_with_defaults(config) return config except (OSError, json.JSONDecodeError) as e: print(f"⚠️ Warning: Could not load config file: {e}") print(" Using default configuration.") return self.DEFAULT_CONFIG.copy() def _merge_with_defaults(self, config: dict[str, Any]) -> dict[str, Any]: """Merge loaded config with defaults to ensure all keys exist.""" def deep_merge(default: dict, custom: dict) -> dict: result = default.copy() for key, value in custom.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = deep_merge(result[key], value) else: result[key] = value return result return deep_merge(self.DEFAULT_CONFIG, config) def save_config(self): """Save configuration to file with secure permissions.""" try: with open(self.config_file, "w") as f: json.dump(self.config, f, indent=2) # Set file permissions to 600 (rw-------) - Unix only if sys.platform != "win32": self.config_file.chmod(stat.S_IRUSR | stat.S_IWUSR) except OSError as e: print(f"❌ Error saving config: {e}") sys.exit(1) # GitHub Token Management def add_github_profile( self, name: str, token: str, description: str = "", rate_limit_strategy: str = "prompt", timeout_minutes: int = 30, set_as_default: bool = False, ): """Add a new GitHub profile.""" if not name: raise ValueError("Profile name cannot be empty") if not token.startswith("ghp_") and not token.startswith("github_pat_"): print("⚠️ Warning: Token doesn't match GitHub format (ghp_* or github_pat_*)") profile = { "token": token, "description": description, "rate_limit_strategy": rate_limit_strategy, "timeout_minutes": timeout_minutes, "added_at": datetime.now().isoformat(), } self.config["github"]["profiles"][name] = profile if set_as_default or not self.config["github"]["default_profile"]: self.config["github"]["default_profile"] = name self.save_config() print(f"✅ Added GitHub profile: {name}") if set_as_default: print("✅ Set as default profile") def remove_github_profile(self, name: str): """Remove a GitHub profile.""" if name not in self.config["github"]["profiles"]: raise ValueError(f"Profile '{name}' not found") del self.config["github"]["profiles"][name] # Update default if we removed it if self.config["github"]["default_profile"] == name: remaining = list(self.config["github"]["profiles"].keys()) self.config["github"]["default_profile"] = remaining[0] if remaining else None self.save_config() print(f"✅ Removed GitHub profile: {name}") def list_github_profiles(self) -> list[dict[str, Any]]: """List all GitHub profiles.""" profiles = [] default = self.config["github"]["default_profile"] for name, data in self.config["github"]["profiles"].items(): profile_info = { "name": name, "description": data.get("description", ""), "strategy": data.get("rate_limit_strategy", "prompt"), "timeout": data.get("timeout_minutes", 30), "is_default": name == default, "added_at": data.get("added_at", "Unknown"), } profiles.append(profile_info) return profiles def get_github_token( self, profile_name: str | None = None, _repo_url: str | None = None ) -> str | None: """ Get GitHub token with smart fallback chain. Priority: 1. Specified profile_name 2. Environment variable GITHUB_TOKEN 3. Default profile from config 4. None (will use 60/hour unauthenticated) """ # 1. Check specified profile if profile_name: profile = self.config["github"]["profiles"].get(profile_name) if profile: return profile["token"] else: print(f"⚠️ Warning: Profile '{profile_name}' not found") # 2. Check environment variable env_token = os.getenv("GITHUB_TOKEN") if env_token: return env_token # 3. Check default profile default_profile = self.config["github"]["default_profile"] if default_profile: profile = self.config["github"]["profiles"].get(default_profile) if profile: return profile["token"] # 4. No token available return None def get_profile_for_token(self, token: str) -> str | None: """Get profile name for a given token.""" for name, profile in self.config["github"]["profiles"].items(): if profile["token"] == token: return name return None def get_next_profile(self, current_token: str) -> tuple | None: """ Get next available profile for rate limit switching. Returns: (profile_name, token) or None """ profiles = list(self.config["github"]["profiles"].items()) if len(profiles) <= 1: return None # Find current profile index current_idx = None for idx, (_name, profile) in enumerate(profiles): if profile["token"] == current_token: current_idx = idx break if current_idx is None: # Current token not in profiles, return first profile name, profile = profiles[0] return (name, profile["token"]) # Return next profile (circular) next_idx = (current_idx + 1) % len(profiles) name, profile = profiles[next_idx] return (name, profile["token"]) def get_rate_limit_strategy(self, token: str | None = None) -> str: """Get rate limit strategy for a token (or default).""" if token: profile_name = self.get_profile_for_token(token) if profile_name: profile = self.config["github"]["profiles"][profile_name] return profile.get("rate_limit_strategy", "prompt") # Default strategy return "prompt" def get_timeout_minutes(self, token: str | None = None) -> int: """Get timeout minutes for a token (or default).""" if token: profile_name = self.get_profile_for_token(token) if profile_name: profile = self.config["github"]["profiles"][profile_name] return profile.get("timeout_minutes", 30) return self.config["rate_limit"]["default_timeout_minutes"] # API Keys Management def set_api_key(self, provider: str, key: str): """Set API key for a provider (anthropic, google, openai).""" if provider not in self.config["api_keys"]: raise ValueError(f"Unknown provider: {provider}. Use: anthropic, google, openai") self.config["api_keys"][provider] = key self.save_config() print(f"✅ Set {provider.capitalize()} API key") def get_api_key(self, provider: str) -> str | None: """ Get API key with environment variable fallback. Priority: 1. Environment variable 2. Config file """ # Check environment first env_map = { "anthropic": "ANTHROPIC_API_KEY", "google": "GOOGLE_API_KEY", "openai": "OPENAI_API_KEY", } env_var = env_map.get(provider) if env_var: env_key = os.getenv(env_var) if env_key: return env_key # Check config file return self.config["api_keys"].get(provider) # Progress Management def save_progress(self, job_id: str, progress_data: dict[str, Any]): """Save progress for a job.""" progress_file = self.progress_dir / f"{job_id}.json" progress_data["last_updated"] = datetime.now().isoformat() with open(progress_file, "w") as f: json.dump(progress_data, f, indent=2) # Set file permissions to 600 - Unix only if sys.platform != "win32": progress_file.chmod(stat.S_IRUSR | stat.S_IWUSR) def load_progress(self, job_id: str) -> dict[str, Any] | None: """Load progress for a job.""" progress_file = self.progress_dir / f"{job_id}.json" if not progress_file.exists(): return None try: with open(progress_file) as f: return json.load(f) except (OSError, json.JSONDecodeError): return None def list_resumable_jobs(self) -> list[dict[str, Any]]: """List all resumable jobs.""" jobs = [] for progress_file in self.progress_dir.glob("*.json"): try: with open(progress_file) as f: data = json.load(f) if data.get("can_resume", False): jobs.append( { "job_id": data.get("job_id", progress_file.stem), "started_at": data.get("started_at"), "command": data.get("command"), "progress": data.get("progress", {}), "last_updated": data.get("last_updated"), } ) except (OSError, json.JSONDecodeError): continue # Sort by last updated (newest first) jobs.sort(key=lambda x: x.get("last_updated", ""), reverse=True) return jobs def delete_progress(self, job_id: str): """Delete progress file for a job.""" progress_file = self.progress_dir / f"{job_id}.json" if progress_file.exists(): progress_file.unlink() def cleanup_old_progress(self): """Delete progress files older than configured days.""" keep_days = self.config["resume"]["keep_progress_days"] cutoff_date = datetime.now() - timedelta(days=keep_days) deleted_count = 0 for progress_file in self.progress_dir.glob("*.json"): # Check file modification time mtime = datetime.fromtimestamp(progress_file.stat().st_mtime) if mtime < cutoff_date: progress_file.unlink() deleted_count += 1 if deleted_count > 0: print(f"🧹 Cleaned up {deleted_count} old progress file(s)") # AI Enhancement Settings def get_default_enhance_level(self) -> int: """Get default AI enhancement level (0-3).""" return self.config.get("ai_enhancement", {}).get("default_enhance_level", 1) def set_default_enhance_level(self, level: int): """Set default AI enhancement level (0-3).""" if level not in [0, 1, 2, 3]: raise ValueError("enhance_level must be 0, 1, 2, or 3") if "ai_enhancement" not in self.config: self.config["ai_enhancement"] = {} self.config["ai_enhancement"]["default_enhance_level"] = level self.save_config() def get_local_batch_size(self) -> int: """Get batch size for LOCAL mode AI enhancement.""" return self.config.get("ai_enhancement", {}).get("local_batch_size", 20) def set_local_batch_size(self, size: int): """Set batch size for LOCAL mode AI enhancement.""" if "ai_enhancement" not in self.config: self.config["ai_enhancement"] = {} self.config["ai_enhancement"]["local_batch_size"] = size self.save_config() def get_local_parallel_workers(self) -> int: """Get number of parallel workers for LOCAL mode AI enhancement.""" return self.config.get("ai_enhancement", {}).get("local_parallel_workers", 3) def set_local_parallel_workers(self, workers: int): """Set number of parallel workers for LOCAL mode AI enhancement.""" if "ai_enhancement" not in self.config: self.config["ai_enhancement"] = {} self.config["ai_enhancement"]["local_parallel_workers"] = workers self.save_config() # First Run Experience def is_first_run(self) -> bool: """Check if this is the first run.""" return not self.config["first_run"]["completed"] def mark_first_run_complete(self): """Mark first run as completed.""" self.config["first_run"]["completed"] = True self.save_config() def should_show_welcome(self) -> bool: """Check if we should show welcome message.""" return not (self.config_dir / ".welcomed").exists() def mark_welcome_shown(self): """Mark welcome message as shown.""" welcome_flag = self.config_dir / ".welcomed" welcome_flag.touch() if sys.platform != "win32": welcome_flag.chmod(stat.S_IRUSR | stat.S_IWUSR) # Display Helpers def display_config_summary(self): """Display current configuration summary.""" print("\n📋 Skill Seekers Configuration\n") print(f"Config file: {self.config_file}") print(f"Custom configs dir: {self.config_dir / 'configs'}") print(f"Progress dir: {self.progress_dir}\n") # GitHub profiles profiles = self.list_github_profiles() print(f"GitHub Profiles: {len(profiles)}") if profiles: for p in profiles: default_marker = " (default)" if p["is_default"] else "" print(f" • {p['name']}{default_marker}") if p["description"]: print(f" {p['description']}") print(f" Strategy: {p['strategy']}, Timeout: {p['timeout']}m") else: print(" (none configured)") print() # API Keys print("API Keys:") for provider in ["anthropic", "google", "openai"]: key = self.get_api_key(provider) status = "✅ Set" if key else "❌ Not set" source = "" if key: if os.getenv(provider.upper() + "_API_KEY"): source = " (from environment)" else: source = " (from config)" print(f" • {provider.capitalize()}: {status}{source}") print() # Settings print("Settings:") print(f" • Rate limit timeout: {self.config['rate_limit']['default_timeout_minutes']}m") print(f" • Auto-switch profiles: {self.config['rate_limit']['auto_switch_profiles']}") print(f" • Keep progress for: {self.config['resume']['keep_progress_days']} days") # AI Enhancement settings level_names = {0: "off", 1: "SKILL.md only", 2: "standard", 3: "full"} default_level = self.get_default_enhance_level() print("\nAI Enhancement:") print(f" • Default level: {default_level} ({level_names.get(default_level, 'unknown')})") print(f" • Batch size: {self.get_local_batch_size()} patterns per call") print(f" • Parallel workers: {self.get_local_parallel_workers()} concurrent calls") # Resumable jobs jobs = self.list_resumable_jobs() if jobs: print(f"\n📦 Resumable Jobs: {len(jobs)}") for job in jobs[:5]: # Show max 5 print(f" • {job['job_id']}") if job.get("progress"): phase = job["progress"].get("phase", "unknown") print(f" Phase: {phase}, Last: {job['last_updated']}") # Global instance _config_manager = None def get_config_manager() -> ConfigManager: """Get singleton config manager instance.""" global _config_manager if _config_manager is None: _config_manager = ConfigManager() return _config_manager