skill-seekers 2.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. skill_seekers/__init__.py +22 -0
  2. skill_seekers/cli/__init__.py +39 -0
  3. skill_seekers/cli/adaptors/__init__.py +120 -0
  4. skill_seekers/cli/adaptors/base.py +221 -0
  5. skill_seekers/cli/adaptors/claude.py +485 -0
  6. skill_seekers/cli/adaptors/gemini.py +453 -0
  7. skill_seekers/cli/adaptors/markdown.py +269 -0
  8. skill_seekers/cli/adaptors/openai.py +503 -0
  9. skill_seekers/cli/ai_enhancer.py +310 -0
  10. skill_seekers/cli/api_reference_builder.py +373 -0
  11. skill_seekers/cli/architectural_pattern_detector.py +525 -0
  12. skill_seekers/cli/code_analyzer.py +1462 -0
  13. skill_seekers/cli/codebase_scraper.py +1225 -0
  14. skill_seekers/cli/config_command.py +563 -0
  15. skill_seekers/cli/config_enhancer.py +431 -0
  16. skill_seekers/cli/config_extractor.py +871 -0
  17. skill_seekers/cli/config_manager.py +452 -0
  18. skill_seekers/cli/config_validator.py +394 -0
  19. skill_seekers/cli/conflict_detector.py +528 -0
  20. skill_seekers/cli/constants.py +72 -0
  21. skill_seekers/cli/dependency_analyzer.py +757 -0
  22. skill_seekers/cli/doc_scraper.py +2332 -0
  23. skill_seekers/cli/enhance_skill.py +488 -0
  24. skill_seekers/cli/enhance_skill_local.py +1096 -0
  25. skill_seekers/cli/enhance_status.py +194 -0
  26. skill_seekers/cli/estimate_pages.py +433 -0
  27. skill_seekers/cli/generate_router.py +1209 -0
  28. skill_seekers/cli/github_fetcher.py +534 -0
  29. skill_seekers/cli/github_scraper.py +1466 -0
  30. skill_seekers/cli/guide_enhancer.py +723 -0
  31. skill_seekers/cli/how_to_guide_builder.py +1267 -0
  32. skill_seekers/cli/install_agent.py +461 -0
  33. skill_seekers/cli/install_skill.py +178 -0
  34. skill_seekers/cli/language_detector.py +614 -0
  35. skill_seekers/cli/llms_txt_detector.py +60 -0
  36. skill_seekers/cli/llms_txt_downloader.py +104 -0
  37. skill_seekers/cli/llms_txt_parser.py +150 -0
  38. skill_seekers/cli/main.py +558 -0
  39. skill_seekers/cli/markdown_cleaner.py +132 -0
  40. skill_seekers/cli/merge_sources.py +806 -0
  41. skill_seekers/cli/package_multi.py +77 -0
  42. skill_seekers/cli/package_skill.py +241 -0
  43. skill_seekers/cli/pattern_recognizer.py +1825 -0
  44. skill_seekers/cli/pdf_extractor_poc.py +1166 -0
  45. skill_seekers/cli/pdf_scraper.py +617 -0
  46. skill_seekers/cli/quality_checker.py +519 -0
  47. skill_seekers/cli/rate_limit_handler.py +438 -0
  48. skill_seekers/cli/resume_command.py +160 -0
  49. skill_seekers/cli/run_tests.py +230 -0
  50. skill_seekers/cli/setup_wizard.py +93 -0
  51. skill_seekers/cli/split_config.py +390 -0
  52. skill_seekers/cli/swift_patterns.py +560 -0
  53. skill_seekers/cli/test_example_extractor.py +1081 -0
  54. skill_seekers/cli/test_unified_simple.py +179 -0
  55. skill_seekers/cli/unified_codebase_analyzer.py +572 -0
  56. skill_seekers/cli/unified_scraper.py +932 -0
  57. skill_seekers/cli/unified_skill_builder.py +1605 -0
  58. skill_seekers/cli/upload_skill.py +162 -0
  59. skill_seekers/cli/utils.py +432 -0
  60. skill_seekers/mcp/__init__.py +33 -0
  61. skill_seekers/mcp/agent_detector.py +316 -0
  62. skill_seekers/mcp/git_repo.py +273 -0
  63. skill_seekers/mcp/server.py +231 -0
  64. skill_seekers/mcp/server_fastmcp.py +1249 -0
  65. skill_seekers/mcp/server_legacy.py +2302 -0
  66. skill_seekers/mcp/source_manager.py +285 -0
  67. skill_seekers/mcp/tools/__init__.py +115 -0
  68. skill_seekers/mcp/tools/config_tools.py +251 -0
  69. skill_seekers/mcp/tools/packaging_tools.py +826 -0
  70. skill_seekers/mcp/tools/scraping_tools.py +842 -0
  71. skill_seekers/mcp/tools/source_tools.py +828 -0
  72. skill_seekers/mcp/tools/splitting_tools.py +212 -0
  73. skill_seekers/py.typed +0 -0
  74. skill_seekers-2.7.3.dist-info/METADATA +2027 -0
  75. skill_seekers-2.7.3.dist-info/RECORD +79 -0
  76. skill_seekers-2.7.3.dist-info/WHEEL +5 -0
  77. skill_seekers-2.7.3.dist-info/entry_points.txt +19 -0
  78. skill_seekers-2.7.3.dist-info/licenses/LICENSE +21 -0
  79. skill_seekers-2.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,452 @@
1
+ """
2
+ Configuration Manager for Skill Seekers
3
+
4
+ Handles multi-profile GitHub tokens, API keys, and application settings.
5
+ Provides secure storage with file permissions and auto-detection capabilities.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import stat
11
+ import sys
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ class ConfigManager:
18
+ """Manages Skill Seekers configuration with multi-token support."""
19
+
20
+ # Default paths
21
+ CONFIG_DIR = Path.home() / ".config" / "skill-seekers"
22
+ CONFIG_FILE = CONFIG_DIR / "config.json"
23
+ WELCOME_FLAG = CONFIG_DIR / ".welcomed"
24
+ PROGRESS_DIR = Path.home() / ".local" / "share" / "skill-seekers" / "progress"
25
+
26
+ # Default configuration
27
+ DEFAULT_CONFIG = {
28
+ "version": "1.0",
29
+ "github": {"default_profile": None, "profiles": {}},
30
+ "rate_limit": {
31
+ "default_timeout_minutes": 30,
32
+ "auto_switch_profiles": True,
33
+ "show_countdown": True,
34
+ },
35
+ "resume": {"auto_save_interval_seconds": 60, "keep_progress_days": 7},
36
+ "api_keys": {"anthropic": None, "google": None, "openai": None},
37
+ "first_run": {"completed": False, "version": "2.7.0"},
38
+ }
39
+
40
+ def __init__(self):
41
+ """Initialize configuration manager."""
42
+ self.config_dir = self.CONFIG_DIR
43
+ self.config_file = self.CONFIG_FILE
44
+ self.progress_dir = self.PROGRESS_DIR
45
+ self._ensure_directories()
46
+ self.config = self._load_config()
47
+
48
+ def _ensure_directories(self):
49
+ """Ensure configuration and progress directories exist with secure permissions."""
50
+ for directory in [self.config_dir, self.progress_dir]:
51
+ directory.mkdir(parents=True, exist_ok=True)
52
+ # Set directory permissions to 700 (rwx------)
53
+ directory.chmod(stat.S_IRWXU)
54
+
55
+ def _load_config(self) -> dict[str, Any]:
56
+ """Load configuration from file or create default."""
57
+ if not self.config_file.exists():
58
+ return self.DEFAULT_CONFIG.copy()
59
+
60
+ try:
61
+ with open(self.config_file) as f:
62
+ config = json.load(f)
63
+
64
+ # Merge with defaults for any missing keys
65
+ config = self._merge_with_defaults(config)
66
+ return config
67
+ except (OSError, json.JSONDecodeError) as e:
68
+ print(f"āš ļø Warning: Could not load config file: {e}")
69
+ print(" Using default configuration.")
70
+ return self.DEFAULT_CONFIG.copy()
71
+
72
+ def _merge_with_defaults(self, config: dict[str, Any]) -> dict[str, Any]:
73
+ """Merge loaded config with defaults to ensure all keys exist."""
74
+
75
+ def deep_merge(default: dict, custom: dict) -> dict:
76
+ result = default.copy()
77
+ for key, value in custom.items():
78
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
79
+ result[key] = deep_merge(result[key], value)
80
+ else:
81
+ result[key] = value
82
+ return result
83
+
84
+ return deep_merge(self.DEFAULT_CONFIG, config)
85
+
86
+ def save_config(self):
87
+ """Save configuration to file with secure permissions."""
88
+ try:
89
+ with open(self.config_file, "w") as f:
90
+ json.dump(self.config, f, indent=2)
91
+
92
+ # Set file permissions to 600 (rw-------)
93
+ self.config_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
94
+
95
+ except OSError as e:
96
+ print(f"āŒ Error saving config: {e}")
97
+ sys.exit(1)
98
+
99
+ # GitHub Token Management
100
+
101
+ def add_github_profile(
102
+ self,
103
+ name: str,
104
+ token: str,
105
+ description: str = "",
106
+ rate_limit_strategy: str = "prompt",
107
+ timeout_minutes: int = 30,
108
+ set_as_default: bool = False,
109
+ ):
110
+ """Add a new GitHub profile."""
111
+ if not name:
112
+ raise ValueError("Profile name cannot be empty")
113
+
114
+ if not token.startswith("ghp_") and not token.startswith("github_pat_"):
115
+ print("āš ļø Warning: Token doesn't match GitHub format (ghp_* or github_pat_*)")
116
+
117
+ profile = {
118
+ "token": token,
119
+ "description": description,
120
+ "rate_limit_strategy": rate_limit_strategy,
121
+ "timeout_minutes": timeout_minutes,
122
+ "added_at": datetime.now().isoformat(),
123
+ }
124
+
125
+ self.config["github"]["profiles"][name] = profile
126
+
127
+ if set_as_default or not self.config["github"]["default_profile"]:
128
+ self.config["github"]["default_profile"] = name
129
+
130
+ self.save_config()
131
+ print(f"āœ… Added GitHub profile: {name}")
132
+ if set_as_default:
133
+ print("āœ… Set as default profile")
134
+
135
+ def remove_github_profile(self, name: str):
136
+ """Remove a GitHub profile."""
137
+ if name not in self.config["github"]["profiles"]:
138
+ raise ValueError(f"Profile '{name}' not found")
139
+
140
+ del self.config["github"]["profiles"][name]
141
+
142
+ # Update default if we removed it
143
+ if self.config["github"]["default_profile"] == name:
144
+ remaining = list(self.config["github"]["profiles"].keys())
145
+ self.config["github"]["default_profile"] = remaining[0] if remaining else None
146
+
147
+ self.save_config()
148
+ print(f"āœ… Removed GitHub profile: {name}")
149
+
150
+ def list_github_profiles(self) -> list[dict[str, Any]]:
151
+ """List all GitHub profiles."""
152
+ profiles = []
153
+ default = self.config["github"]["default_profile"]
154
+
155
+ for name, data in self.config["github"]["profiles"].items():
156
+ profile_info = {
157
+ "name": name,
158
+ "description": data.get("description", ""),
159
+ "strategy": data.get("rate_limit_strategy", "prompt"),
160
+ "timeout": data.get("timeout_minutes", 30),
161
+ "is_default": name == default,
162
+ "added_at": data.get("added_at", "Unknown"),
163
+ }
164
+ profiles.append(profile_info)
165
+
166
+ return profiles
167
+
168
+ def get_github_token(
169
+ self, profile_name: str | None = None, _repo_url: str | None = None
170
+ ) -> str | None:
171
+ """
172
+ Get GitHub token with smart fallback chain.
173
+
174
+ Priority:
175
+ 1. Specified profile_name
176
+ 2. Environment variable GITHUB_TOKEN
177
+ 3. Default profile from config
178
+ 4. None (will use 60/hour unauthenticated)
179
+ """
180
+ # 1. Check specified profile
181
+ if profile_name:
182
+ profile = self.config["github"]["profiles"].get(profile_name)
183
+ if profile:
184
+ return profile["token"]
185
+ else:
186
+ print(f"āš ļø Warning: Profile '{profile_name}' not found")
187
+
188
+ # 2. Check environment variable
189
+ env_token = os.getenv("GITHUB_TOKEN")
190
+ if env_token:
191
+ return env_token
192
+
193
+ # 3. Check default profile
194
+ default_profile = self.config["github"]["default_profile"]
195
+ if default_profile:
196
+ profile = self.config["github"]["profiles"].get(default_profile)
197
+ if profile:
198
+ return profile["token"]
199
+
200
+ # 4. No token available
201
+ return None
202
+
203
+ def get_profile_for_token(self, token: str) -> str | None:
204
+ """Get profile name for a given token."""
205
+ for name, profile in self.config["github"]["profiles"].items():
206
+ if profile["token"] == token:
207
+ return name
208
+ return None
209
+
210
+ def get_next_profile(self, current_token: str) -> tuple | None:
211
+ """
212
+ Get next available profile for rate limit switching.
213
+
214
+ Returns: (profile_name, token) or None
215
+ """
216
+ profiles = list(self.config["github"]["profiles"].items())
217
+ if len(profiles) <= 1:
218
+ return None
219
+
220
+ # Find current profile index
221
+ current_idx = None
222
+ for idx, (_name, profile) in enumerate(profiles):
223
+ if profile["token"] == current_token:
224
+ current_idx = idx
225
+ break
226
+
227
+ if current_idx is None:
228
+ # Current token not in profiles, return first profile
229
+ name, profile = profiles[0]
230
+ return (name, profile["token"])
231
+
232
+ # Return next profile (circular)
233
+ next_idx = (current_idx + 1) % len(profiles)
234
+ name, profile = profiles[next_idx]
235
+ return (name, profile["token"])
236
+
237
+ def get_rate_limit_strategy(self, token: str | None = None) -> str:
238
+ """Get rate limit strategy for a token (or default)."""
239
+ if token:
240
+ profile_name = self.get_profile_for_token(token)
241
+ if profile_name:
242
+ profile = self.config["github"]["profiles"][profile_name]
243
+ return profile.get("rate_limit_strategy", "prompt")
244
+
245
+ # Default strategy
246
+ return "prompt"
247
+
248
+ def get_timeout_minutes(self, token: str | None = None) -> int:
249
+ """Get timeout minutes for a token (or default)."""
250
+ if token:
251
+ profile_name = self.get_profile_for_token(token)
252
+ if profile_name:
253
+ profile = self.config["github"]["profiles"][profile_name]
254
+ return profile.get("timeout_minutes", 30)
255
+
256
+ return self.config["rate_limit"]["default_timeout_minutes"]
257
+
258
+ # API Keys Management
259
+
260
+ def set_api_key(self, provider: str, key: str):
261
+ """Set API key for a provider (anthropic, google, openai)."""
262
+ if provider not in self.config["api_keys"]:
263
+ raise ValueError(f"Unknown provider: {provider}. Use: anthropic, google, openai")
264
+
265
+ self.config["api_keys"][provider] = key
266
+ self.save_config()
267
+ print(f"āœ… Set {provider.capitalize()} API key")
268
+
269
+ def get_api_key(self, provider: str) -> str | None:
270
+ """
271
+ Get API key with environment variable fallback.
272
+
273
+ Priority:
274
+ 1. Environment variable
275
+ 2. Config file
276
+ """
277
+ # Check environment first
278
+ env_map = {
279
+ "anthropic": "ANTHROPIC_API_KEY",
280
+ "google": "GOOGLE_API_KEY",
281
+ "openai": "OPENAI_API_KEY",
282
+ }
283
+
284
+ env_var = env_map.get(provider)
285
+ if env_var:
286
+ env_key = os.getenv(env_var)
287
+ if env_key:
288
+ return env_key
289
+
290
+ # Check config file
291
+ return self.config["api_keys"].get(provider)
292
+
293
+ # Progress Management
294
+
295
+ def save_progress(self, job_id: str, progress_data: dict[str, Any]):
296
+ """Save progress for a job."""
297
+ progress_file = self.progress_dir / f"{job_id}.json"
298
+
299
+ progress_data["last_updated"] = datetime.now().isoformat()
300
+
301
+ with open(progress_file, "w") as f:
302
+ json.dump(progress_data, f, indent=2)
303
+
304
+ # Set file permissions to 600
305
+ progress_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
306
+
307
+ def load_progress(self, job_id: str) -> dict[str, Any] | None:
308
+ """Load progress for a job."""
309
+ progress_file = self.progress_dir / f"{job_id}.json"
310
+
311
+ if not progress_file.exists():
312
+ return None
313
+
314
+ try:
315
+ with open(progress_file) as f:
316
+ return json.load(f)
317
+ except (OSError, json.JSONDecodeError):
318
+ return None
319
+
320
+ def list_resumable_jobs(self) -> list[dict[str, Any]]:
321
+ """List all resumable jobs."""
322
+ jobs = []
323
+
324
+ for progress_file in self.progress_dir.glob("*.json"):
325
+ try:
326
+ with open(progress_file) as f:
327
+ data = json.load(f)
328
+
329
+ if data.get("can_resume", False):
330
+ jobs.append(
331
+ {
332
+ "job_id": data.get("job_id", progress_file.stem),
333
+ "started_at": data.get("started_at"),
334
+ "command": data.get("command"),
335
+ "progress": data.get("progress", {}),
336
+ "last_updated": data.get("last_updated"),
337
+ }
338
+ )
339
+ except (OSError, json.JSONDecodeError):
340
+ continue
341
+
342
+ # Sort by last updated (newest first)
343
+ jobs.sort(key=lambda x: x.get("last_updated", ""), reverse=True)
344
+ return jobs
345
+
346
+ def delete_progress(self, job_id: str):
347
+ """Delete progress file for a job."""
348
+ progress_file = self.progress_dir / f"{job_id}.json"
349
+ if progress_file.exists():
350
+ progress_file.unlink()
351
+
352
+ def cleanup_old_progress(self):
353
+ """Delete progress files older than configured days."""
354
+ keep_days = self.config["resume"]["keep_progress_days"]
355
+ cutoff_date = datetime.now() - timedelta(days=keep_days)
356
+
357
+ deleted_count = 0
358
+ for progress_file in self.progress_dir.glob("*.json"):
359
+ # Check file modification time
360
+ mtime = datetime.fromtimestamp(progress_file.stat().st_mtime)
361
+ if mtime < cutoff_date:
362
+ progress_file.unlink()
363
+ deleted_count += 1
364
+
365
+ if deleted_count > 0:
366
+ print(f"🧹 Cleaned up {deleted_count} old progress file(s)")
367
+
368
+ # First Run Experience
369
+
370
+ def is_first_run(self) -> bool:
371
+ """Check if this is the first run."""
372
+ return not self.config["first_run"]["completed"]
373
+
374
+ def mark_first_run_complete(self):
375
+ """Mark first run as completed."""
376
+ self.config["first_run"]["completed"] = True
377
+ self.save_config()
378
+
379
+ def should_show_welcome(self) -> bool:
380
+ """Check if we should show welcome message."""
381
+ return not self.WELCOME_FLAG.exists()
382
+
383
+ def mark_welcome_shown(self):
384
+ """Mark welcome message as shown."""
385
+ self.WELCOME_FLAG.touch()
386
+ self.WELCOME_FLAG.chmod(stat.S_IRUSR | stat.S_IWUSR)
387
+
388
+ # Display Helpers
389
+
390
+ def display_config_summary(self):
391
+ """Display current configuration summary."""
392
+ print("\nšŸ“‹ Skill Seekers Configuration\n")
393
+ print(f"Config file: {self.config_file}")
394
+ print(f"Progress dir: {self.progress_dir}\n")
395
+
396
+ # GitHub profiles
397
+ profiles = self.list_github_profiles()
398
+ print(f"GitHub Profiles: {len(profiles)}")
399
+ if profiles:
400
+ for p in profiles:
401
+ default_marker = " (default)" if p["is_default"] else ""
402
+ print(f" • {p['name']}{default_marker}")
403
+ if p["description"]:
404
+ print(f" {p['description']}")
405
+ print(f" Strategy: {p['strategy']}, Timeout: {p['timeout']}m")
406
+ else:
407
+ print(" (none configured)")
408
+
409
+ print()
410
+
411
+ # API Keys
412
+ print("API Keys:")
413
+ for provider in ["anthropic", "google", "openai"]:
414
+ key = self.get_api_key(provider)
415
+ status = "āœ… Set" if key else "āŒ Not set"
416
+ source = ""
417
+ if key:
418
+ if os.getenv(provider.upper() + "_API_KEY"):
419
+ source = " (from environment)"
420
+ else:
421
+ source = " (from config)"
422
+ print(f" • {provider.capitalize()}: {status}{source}")
423
+
424
+ print()
425
+
426
+ # Settings
427
+ print("Settings:")
428
+ print(f" • Rate limit timeout: {self.config['rate_limit']['default_timeout_minutes']}m")
429
+ print(f" • Auto-switch profiles: {self.config['rate_limit']['auto_switch_profiles']}")
430
+ print(f" • Keep progress for: {self.config['resume']['keep_progress_days']} days")
431
+
432
+ # Resumable jobs
433
+ jobs = self.list_resumable_jobs()
434
+ if jobs:
435
+ print(f"\nšŸ“¦ Resumable Jobs: {len(jobs)}")
436
+ for job in jobs[:5]: # Show max 5
437
+ print(f" • {job['job_id']}")
438
+ if job.get("progress"):
439
+ phase = job["progress"].get("phase", "unknown")
440
+ print(f" Phase: {phase}, Last: {job['last_updated']}")
441
+
442
+
443
+ # Global instance
444
+ _config_manager = None
445
+
446
+
447
+ def get_config_manager() -> ConfigManager:
448
+ """Get singleton config manager instance."""
449
+ global _config_manager
450
+ if _config_manager is None:
451
+ _config_manager = ConfigManager()
452
+ return _config_manager