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,162 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Automatic Skill Uploader
4
+ Uploads a skill package to LLM platforms (Claude, Gemini, OpenAI, etc.)
5
+
6
+ Usage:
7
+ # Claude (default)
8
+ export ANTHROPIC_API_KEY=sk-ant-...
9
+ skill-seekers upload output/react.zip
10
+
11
+ # Gemini
12
+ export GOOGLE_API_KEY=AIzaSy...
13
+ skill-seekers upload output/react-gemini.tar.gz --target gemini
14
+
15
+ # OpenAI
16
+ export OPENAI_API_KEY=sk-proj-...
17
+ skill-seekers upload output/react-openai.zip --target openai
18
+ """
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ # Import utilities
26
+ try:
27
+ from utils import print_upload_instructions
28
+ except ImportError:
29
+ sys.path.insert(0, str(Path(__file__).parent))
30
+ from utils import print_upload_instructions
31
+
32
+
33
+ def upload_skill_api(package_path, target="claude", api_key=None):
34
+ """
35
+ Upload skill package to LLM platform
36
+
37
+ Args:
38
+ package_path: Path to skill package file
39
+ target: Target platform ('claude', 'gemini', 'openai')
40
+ api_key: Optional API key (otherwise read from environment)
41
+
42
+ Returns:
43
+ tuple: (success, message)
44
+ """
45
+ try:
46
+ from skill_seekers.cli.adaptors import get_adaptor
47
+ except ImportError:
48
+ return False, "Adaptor system not available. Reinstall skill-seekers."
49
+
50
+ # Get platform-specific adaptor
51
+ try:
52
+ adaptor = get_adaptor(target)
53
+ except ValueError as e:
54
+ return False, str(e)
55
+
56
+ # Get API key
57
+ if not api_key:
58
+ api_key = os.environ.get(adaptor.get_env_var_name(), "").strip()
59
+
60
+ if not api_key:
61
+ return False, f"{adaptor.get_env_var_name()} not set. Export your API key first."
62
+
63
+ # Validate API key format
64
+ if not adaptor.validate_api_key(api_key):
65
+ return False, f"Invalid API key format for {adaptor.PLATFORM_NAME}"
66
+
67
+ package_path = Path(package_path)
68
+
69
+ # Basic file validation
70
+ if not package_path.exists():
71
+ return False, f"File not found: {package_path}"
72
+
73
+ skill_name = package_path.stem
74
+
75
+ print(f"📤 Uploading skill: {skill_name}")
76
+ print(f" Target: {adaptor.PLATFORM_NAME}")
77
+ print(f" Source: {package_path}")
78
+ print(f" Size: {package_path.stat().st_size:,} bytes")
79
+ print()
80
+
81
+ # Upload using adaptor
82
+ print(f"⏳ Uploading to {adaptor.PLATFORM_NAME}...")
83
+
84
+ try:
85
+ result = adaptor.upload(package_path, api_key)
86
+
87
+ if result["success"]:
88
+ print()
89
+ print(f"✅ {result['message']}")
90
+ print()
91
+ if result["url"]:
92
+ print("Your skill is now available at:")
93
+ print(f" {result['url']}")
94
+ if result["skill_id"]:
95
+ print(f" Skill ID: {result['skill_id']}")
96
+ print()
97
+ return True, "Upload successful"
98
+ else:
99
+ return False, result["message"]
100
+
101
+ except Exception as e:
102
+ return False, f"Unexpected error: {str(e)}"
103
+
104
+
105
+ def main():
106
+ parser = argparse.ArgumentParser(
107
+ description="Upload a skill package to LLM platforms",
108
+ formatter_class=argparse.RawDescriptionHelpFormatter,
109
+ epilog="""
110
+ Setup:
111
+ Claude:
112
+ export ANTHROPIC_API_KEY=sk-ant-...
113
+
114
+ Gemini:
115
+ export GOOGLE_API_KEY=AIzaSy...
116
+
117
+ OpenAI:
118
+ export OPENAI_API_KEY=sk-proj-...
119
+
120
+ Examples:
121
+ # Upload to Claude (default)
122
+ skill-seekers upload output/react.zip
123
+
124
+ # Upload to Gemini
125
+ skill-seekers upload output/react-gemini.tar.gz --target gemini
126
+
127
+ # Upload to OpenAI
128
+ skill-seekers upload output/react-openai.zip --target openai
129
+
130
+ # Upload with explicit API key
131
+ skill-seekers upload output/react.zip --api-key sk-ant-...
132
+ """,
133
+ )
134
+
135
+ parser.add_argument("package_file", help="Path to skill package file (e.g., output/react.zip)")
136
+
137
+ parser.add_argument(
138
+ "--target",
139
+ choices=["claude", "gemini", "openai"],
140
+ default="claude",
141
+ help="Target LLM platform (default: claude)",
142
+ )
143
+
144
+ parser.add_argument("--api-key", help="Platform API key (or set environment variable)")
145
+
146
+ args = parser.parse_args()
147
+
148
+ # Upload skill
149
+ success, message = upload_skill_api(args.package_file, args.target, args.api_key)
150
+
151
+ if success:
152
+ sys.exit(0)
153
+ else:
154
+ print(f"\n❌ Upload failed: {message}")
155
+ print()
156
+ print("📝 Manual upload instructions:")
157
+ print_upload_instructions(args.package_file)
158
+ sys.exit(1)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Utility functions for Skill Seeker CLI tools
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ import platform
9
+ import subprocess
10
+ import time
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import TypeVar
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def open_folder(folder_path: str | Path) -> bool:
21
+ """
22
+ Open a folder in the system file browser
23
+
24
+ Args:
25
+ folder_path: Path to folder to open
26
+
27
+ Returns:
28
+ bool: True if successful, False otherwise
29
+ """
30
+ folder_path = Path(folder_path).resolve()
31
+
32
+ if not folder_path.exists():
33
+ print(f"⚠️ Folder not found: {folder_path}")
34
+ return False
35
+
36
+ system = platform.system()
37
+
38
+ try:
39
+ if system == "Linux":
40
+ # Try xdg-open first (standard)
41
+ subprocess.run(["xdg-open", str(folder_path)], check=True)
42
+ elif system == "Darwin": # macOS
43
+ subprocess.run(["open", str(folder_path)], check=True)
44
+ elif system == "Windows":
45
+ subprocess.run(["explorer", str(folder_path)], check=True)
46
+ else:
47
+ print(f"⚠️ Unknown operating system: {system}")
48
+ return False
49
+
50
+ return True
51
+
52
+ except subprocess.CalledProcessError:
53
+ print("⚠️ Could not open folder automatically")
54
+ return False
55
+ except FileNotFoundError:
56
+ print("⚠️ File browser not found on system")
57
+ return False
58
+
59
+
60
+ def has_api_key() -> bool:
61
+ """
62
+ Check if ANTHROPIC_API_KEY is set in environment
63
+
64
+ Returns:
65
+ bool: True if API key is set, False otherwise
66
+ """
67
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
68
+ return len(api_key) > 0
69
+
70
+
71
+ def get_api_key() -> str | None:
72
+ """
73
+ Get ANTHROPIC_API_KEY from environment
74
+
75
+ Returns:
76
+ str: API key or None if not set
77
+ """
78
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
79
+ return api_key if api_key else None
80
+
81
+
82
+ def get_upload_url() -> str:
83
+ """
84
+ Get the Claude skills upload URL
85
+
86
+ Returns:
87
+ str: Claude skills upload URL
88
+ """
89
+ return "https://claude.ai/skills"
90
+
91
+
92
+ def print_upload_instructions(zip_path: str | Path) -> None:
93
+ """
94
+ Print clear upload instructions for manual upload
95
+
96
+ Args:
97
+ zip_path: Path to the .zip file to upload
98
+ """
99
+ zip_path = Path(zip_path)
100
+
101
+ print()
102
+ print("╔══════════════════════════════════════════════════════════╗")
103
+ print("║ NEXT STEP ║")
104
+ print("╚══════════════════════════════════════════════════════════╝")
105
+ print()
106
+ print(f"📤 Upload to Claude: {get_upload_url()}")
107
+ print()
108
+ print(f"1. Go to {get_upload_url()}")
109
+ print('2. Click "Upload Skill"')
110
+ print(f"3. Select: {zip_path}")
111
+ print("4. Done! ✅")
112
+ print()
113
+
114
+
115
+ def format_file_size(size_bytes: int) -> str:
116
+ """
117
+ Format file size in human-readable format
118
+
119
+ Args:
120
+ size_bytes: Size in bytes
121
+
122
+ Returns:
123
+ str: Formatted size (e.g., "45.3 KB")
124
+ """
125
+ if size_bytes < 1024:
126
+ return f"{size_bytes} bytes"
127
+ elif size_bytes < 1024 * 1024:
128
+ return f"{size_bytes / 1024:.1f} KB"
129
+ else:
130
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
131
+
132
+
133
+ def validate_skill_directory(skill_dir: str | Path) -> tuple[bool, str | None]:
134
+ """
135
+ Validate that a directory is a valid skill directory
136
+
137
+ Args:
138
+ skill_dir: Path to skill directory
139
+
140
+ Returns:
141
+ tuple: (is_valid, error_message)
142
+ """
143
+ skill_path = Path(skill_dir)
144
+
145
+ if not skill_path.exists():
146
+ return False, f"Directory not found: {skill_dir}"
147
+
148
+ if not skill_path.is_dir():
149
+ return False, f"Not a directory: {skill_dir}"
150
+
151
+ skill_md = skill_path / "SKILL.md"
152
+ if not skill_md.exists():
153
+ return False, f"SKILL.md not found in {skill_dir}"
154
+
155
+ return True, None
156
+
157
+
158
+ def validate_zip_file(zip_path: str | Path) -> tuple[bool, str | None]:
159
+ """
160
+ Validate that a file is a valid skill .zip file
161
+
162
+ Args:
163
+ zip_path: Path to .zip file
164
+
165
+ Returns:
166
+ tuple: (is_valid, error_message)
167
+ """
168
+ zip_path = Path(zip_path)
169
+
170
+ if not zip_path.exists():
171
+ return False, f"File not found: {zip_path}"
172
+
173
+ if not zip_path.is_file():
174
+ return False, f"Not a file: {zip_path}"
175
+
176
+ if zip_path.suffix != ".zip":
177
+ return False, f"Not a .zip file: {zip_path}"
178
+
179
+ return True, None
180
+
181
+
182
+ def read_reference_files(
183
+ skill_dir: str | Path, max_chars: int = 100000, preview_limit: int = 40000
184
+ ) -> dict[str, dict]:
185
+ """Read reference files from a skill directory with enriched metadata.
186
+
187
+ This function reads markdown files from the references/ subdirectory
188
+ of a skill, applying both per-file and total content limits.
189
+ Returns enriched metadata including source type, confidence, and path.
190
+
191
+ Args:
192
+ skill_dir (str or Path): Path to skill directory
193
+ max_chars (int): Maximum total characters to read (default: 100000)
194
+ preview_limit (int): Maximum characters per file (default: 40000)
195
+
196
+ Returns:
197
+ dict: Dictionary mapping filename to metadata dict with keys:
198
+ - 'content': File content
199
+ - 'source': Source type (documentation/github/pdf/api/codebase_analysis)
200
+ - 'confidence': Confidence level (high/medium/low)
201
+ - 'path': Relative path from references directory
202
+ - 'repo_id': Repository identifier for multi-source (e.g., 'encode_httpx'), None for single-source
203
+
204
+ Example:
205
+ >>> refs = read_reference_files('output/react/', max_chars=50000)
206
+ >>> refs['documentation/api.md']['source']
207
+ 'documentation'
208
+ >>> refs['documentation/api.md']['confidence']
209
+ 'high'
210
+ """
211
+ from pathlib import Path
212
+
213
+ skill_path = Path(skill_dir)
214
+ references_dir = skill_path / "references"
215
+ references: dict[str, dict] = {}
216
+
217
+ if not references_dir.exists():
218
+ print(f"⚠ No references directory found at {references_dir}")
219
+ return references
220
+
221
+ def _determine_source_metadata(relative_path: Path) -> tuple[str, str, str | None]:
222
+ """Determine source type, confidence level, and repo_id from path.
223
+
224
+ For multi-source support, extracts repo_id from paths like:
225
+ - codebase_analysis/encode_httpx/ARCHITECTURE.md -> repo_id='encode_httpx'
226
+ - github/README.md -> repo_id=None (single source)
227
+
228
+ Returns:
229
+ tuple: (source_type, confidence_level, repo_id)
230
+ """
231
+ path_str = str(relative_path)
232
+ repo_id = None # Default: no repo identity
233
+
234
+ # Documentation sources (official docs)
235
+ if path_str.startswith("documentation/"):
236
+ return "documentation", "high", None
237
+
238
+ # GitHub sources
239
+ elif path_str.startswith("github/"):
240
+ # README and releases are medium confidence
241
+ if "README" in path_str or "releases" in path_str:
242
+ return "github", "medium", None
243
+ # Issues are low confidence (user reports)
244
+ elif "issues" in path_str:
245
+ return "github", "low", None
246
+ else:
247
+ return "github", "medium", None
248
+
249
+ # PDF sources (books, manuals)
250
+ elif path_str.startswith("pdf/"):
251
+ return "pdf", "high", None
252
+
253
+ # Merged API (synthesized from multiple sources)
254
+ elif path_str.startswith("api/"):
255
+ return "api", "high", None
256
+
257
+ # Codebase analysis (C3.x automated analysis)
258
+ elif path_str.startswith("codebase_analysis/"):
259
+ # Extract repo_id from path: codebase_analysis/{repo_id}/...
260
+ parts = Path(path_str).parts
261
+ if len(parts) >= 2:
262
+ repo_id = parts[1] # e.g., 'encode_httpx', 'encode_httpcore'
263
+
264
+ # ARCHITECTURE.md is high confidence (comprehensive)
265
+ if "ARCHITECTURE" in path_str:
266
+ return "codebase_analysis", "high", repo_id
267
+ # Patterns and examples are medium (heuristic-based)
268
+ elif "patterns" in path_str or "examples" in path_str:
269
+ return "codebase_analysis", "medium", repo_id
270
+ # Configuration is high (direct extraction)
271
+ elif "configuration" in path_str:
272
+ return "codebase_analysis", "high", repo_id
273
+ else:
274
+ return "codebase_analysis", "medium", repo_id
275
+
276
+ # Conflicts report (discrepancy detection)
277
+ elif "conflicts" in path_str:
278
+ return "conflicts", "medium", None
279
+
280
+ # Fallback
281
+ else:
282
+ return "unknown", "medium", None
283
+
284
+ total_chars = 0
285
+ # Search recursively for all .md files (including subdirectories like github/README.md)
286
+ for ref_file in sorted(references_dir.rglob("*.md")):
287
+ # Note: We now include index.md files as they contain important content
288
+ # (patterns, examples, configuration analysis)
289
+
290
+ content = ref_file.read_text(encoding="utf-8")
291
+
292
+ # Limit size per file
293
+ truncated = False
294
+ if len(content) > preview_limit:
295
+ content = content[:preview_limit] + "\n\n[Content truncated...]"
296
+ truncated = True
297
+
298
+ # Use relative path from references_dir as key for nested files
299
+ relative_path = ref_file.relative_to(references_dir)
300
+ source_type, confidence, repo_id = _determine_source_metadata(relative_path)
301
+
302
+ # Build enriched metadata (with repo_id for multi-source support)
303
+ references[str(relative_path)] = {
304
+ "content": content,
305
+ "source": source_type,
306
+ "confidence": confidence,
307
+ "path": str(relative_path),
308
+ "truncated": truncated,
309
+ "size": len(content),
310
+ "repo_id": repo_id, # None for single-source, repo identifier for multi-source
311
+ }
312
+
313
+ total_chars += len(content)
314
+
315
+ # Stop if we've read enough
316
+ if total_chars > max_chars:
317
+ print(f" ℹ Limiting input to {max_chars:,} characters")
318
+ break
319
+
320
+ return references
321
+
322
+
323
+ def retry_with_backoff(
324
+ operation: Callable[[], T],
325
+ max_attempts: int = 3,
326
+ base_delay: float = 1.0,
327
+ operation_name: str = "operation",
328
+ ) -> T:
329
+ """Retry an operation with exponential backoff.
330
+
331
+ Useful for network operations that may fail due to transient errors.
332
+ Waits progressively longer between retries (exponential backoff).
333
+
334
+ Args:
335
+ operation: Function to retry (takes no arguments, returns result)
336
+ max_attempts: Maximum number of attempts (default: 3)
337
+ base_delay: Base delay in seconds, doubles each retry (default: 1.0)
338
+ operation_name: Name for logging purposes (default: "operation")
339
+
340
+ Returns:
341
+ Result of successful operation
342
+
343
+ Raises:
344
+ Exception: Last exception if all retries fail
345
+
346
+ Example:
347
+ >>> def fetch_page():
348
+ ... response = requests.get(url, timeout=30)
349
+ ... response.raise_for_status()
350
+ ... return response.text
351
+ >>> content = retry_with_backoff(fetch_page, max_attempts=3, operation_name=f"fetch {url}")
352
+ """
353
+ last_exception: Exception | None = None
354
+
355
+ for attempt in range(1, max_attempts + 1):
356
+ try:
357
+ return operation()
358
+ except Exception as e:
359
+ last_exception = e
360
+ if attempt < max_attempts:
361
+ delay = base_delay * (2 ** (attempt - 1))
362
+ logger.warning(
363
+ "%s failed (attempt %d/%d), retrying in %.1fs: %s",
364
+ operation_name,
365
+ attempt,
366
+ max_attempts,
367
+ delay,
368
+ e,
369
+ )
370
+ time.sleep(delay)
371
+ else:
372
+ logger.error("%s failed after %d attempts: %s", operation_name, max_attempts, e)
373
+
374
+ # This should always have a value, but mypy doesn't know that
375
+ if last_exception is not None:
376
+ raise last_exception
377
+ raise RuntimeError(f"{operation_name} failed with no exception captured")
378
+
379
+
380
+ async def retry_with_backoff_async(
381
+ operation: Callable[[], T],
382
+ max_attempts: int = 3,
383
+ base_delay: float = 1.0,
384
+ operation_name: str = "operation",
385
+ ) -> T:
386
+ """Async version of retry_with_backoff for async operations.
387
+
388
+ Args:
389
+ operation: Async function to retry (takes no arguments, returns awaitable)
390
+ max_attempts: Maximum number of attempts (default: 3)
391
+ base_delay: Base delay in seconds, doubles each retry (default: 1.0)
392
+ operation_name: Name for logging purposes (default: "operation")
393
+
394
+ Returns:
395
+ Result of successful operation
396
+
397
+ Raises:
398
+ Exception: Last exception if all retries fail
399
+
400
+ Example:
401
+ >>> async def fetch_page():
402
+ ... response = await client.get(url, timeout=30.0)
403
+ ... response.raise_for_status()
404
+ ... return response.text
405
+ >>> content = await retry_with_backoff_async(fetch_page, operation_name=f"fetch {url}")
406
+ """
407
+ import asyncio
408
+
409
+ last_exception: Exception | None = None
410
+
411
+ for attempt in range(1, max_attempts + 1):
412
+ try:
413
+ return await operation()
414
+ except Exception as e:
415
+ last_exception = e
416
+ if attempt < max_attempts:
417
+ delay = base_delay * (2 ** (attempt - 1))
418
+ logger.warning(
419
+ "%s failed (attempt %d/%d), retrying in %.1fs: %s",
420
+ operation_name,
421
+ attempt,
422
+ max_attempts,
423
+ delay,
424
+ e,
425
+ )
426
+ await asyncio.sleep(delay)
427
+ else:
428
+ logger.error("%s failed after %d attempts: %s", operation_name, max_attempts, e)
429
+
430
+ if last_exception is not None:
431
+ raise last_exception
432
+ raise RuntimeError(f"{operation_name} failed with no exception captured")
@@ -0,0 +1,33 @@
1
+ """Skill Seekers MCP (Model Context Protocol) server package.
2
+
3
+ This package provides MCP server integration for Claude Code, allowing
4
+ natural language interaction with Skill Seekers tools.
5
+
6
+ Main modules:
7
+ - server_fastmcp: FastMCP-based server with 17 tools (MCP 2025 spec)
8
+ - agent_detector: AI coding agent detection and configuration
9
+
10
+ Available MCP Tools:
11
+ - list_configs: List all available preset configurations
12
+ - generate_config: Generate a new config file for any docs site
13
+ - validate_config: Validate a config file structure
14
+ - estimate_pages: Estimate page count before scraping
15
+ - scrape_docs: Scrape and build a skill
16
+ - package_skill: Package skill into .zip file (with auto-upload)
17
+ - upload_skill: Upload .zip to Claude
18
+ - split_config: Split large documentation configs
19
+ - generate_router: Generate router/hub skills
20
+
21
+ Agent Detection:
22
+ - Supports 5 AI coding agents: Claude Code, Cursor, Windsurf, VS Code + Cline, IntelliJ IDEA
23
+ - Auto-detects installed agents on Linux, macOS, and Windows
24
+ - Generates correct MCP config for each agent (stdio vs HTTP)
25
+
26
+ Usage:
27
+ The MCP server is typically run by Claude Code via configuration
28
+ in ~/.config/claude-code/mcp.json
29
+ """
30
+
31
+ __version__ = "2.7.2"
32
+
33
+ __all__ = ["agent_detector"]