Files
skill-seekers-reference/src/skill_seekers/cli/config_manager.py
YusufKaraaslanSpyke aa57164d34 feat: C3.9 documentation extraction, AI enhancement optimization, and C# support
Complete implementation of C3.9, granular AI enhancement control, performance optimizations, and bug fixes.

Features:
- C3.9 Project Documentation Extraction (markdown files)
- Granular AI enhancement control (--enhance-level 0-3)
- C# test extraction support
- 6-12x faster LOCAL mode with parallel execution
- Auto-enhancement UX improvements
- LOCAL mode fallback for all AI enhancements

Bug Fixes:
- C# language support
- Config type field compatibility
- LocalSkillEnhancer import

Documentation:
- Updated CHANGELOG.md
- Updated CLAUDE.md
- Removed client-specific files

Tests: All 1,257 tests passing
Critical linter errors: Fixed
2026-01-31 14:56:00 +03:00

517 lines
18 KiB
Python

"""
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
class ConfigManager:
"""Manages Skill Seekers configuration with multi-token support."""
# Default paths
CONFIG_DIR = Path.home() / ".config" / "skill-seekers"
CONFIG_FILE = CONFIG_DIR / "config.json"
WELCOME_FLAG = CONFIG_DIR / ".welcomed"
PROGRESS_DIR = Path.home() / ".local" / "share" / "skill-seekers" / "progress"
# 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 = self.CONFIG_DIR
self.config_file = self.CONFIG_FILE
self.progress_dir = self.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------)
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)
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-------)
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
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.WELCOME_FLAG.exists()
def mark_welcome_shown(self):
"""Mark welcome message as shown."""
self.WELCOME_FLAG.touch()
self.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