diff --git a/src/skill_seekers/cli/adaptors/markdown.py b/src/skill_seekers/cli/adaptors/markdown.py new file mode 100644 index 0000000..2d534ba --- /dev/null +++ b/src/skill_seekers/cli/adaptors/markdown.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Generic Markdown Adaptor + +Implements generic markdown export for universal LLM compatibility. +No platform-specific features, just clean markdown documentation. +""" + +import zipfile +from pathlib import Path +from typing import Dict, Any + +from .base import SkillAdaptor, SkillMetadata + + +class MarkdownAdaptor(SkillAdaptor): + """ + Generic Markdown platform adaptor. + + Handles: + - Pure markdown format (no platform-specific formatting) + - ZIP packaging with combined or individual files + - No upload capability (manual use) + - No AI enhancement (generic export only) + """ + + PLATFORM = "markdown" + PLATFORM_NAME = "Generic Markdown (Universal)" + DEFAULT_API_ENDPOINT = None # No upload endpoint + + def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str: + """ + Format SKILL.md as pure markdown. + + Clean, universal markdown that works with any LLM or documentation system. + + Args: + skill_dir: Path to skill directory + metadata: Skill metadata + + Returns: + Formatted markdown content + """ + # Read existing content (if any) + existing_content = self._read_existing_content(skill_dir) + + # If existing content is substantial, use it + if existing_content and len(existing_content) > 100: + content_body = existing_content + else: + # Generate clean markdown + content_body = f"""# {metadata.name.title()} Documentation + +{metadata.description} + +## Table of Contents + +{self._generate_toc(skill_dir)} + +## Quick Reference + +{self._extract_quick_reference(skill_dir)} + +## Documentation + +This documentation package contains comprehensive reference materials organized into categorized sections. + +### Available Sections + +{self._generate_toc(skill_dir)} + +## Usage + +Browse the reference files for detailed information on each topic. All files are in standard markdown format and can be viewed with any markdown reader or text editor. + +--- + +*Documentation generated by Skill Seekers* +""" + + # Return pure markdown (no frontmatter, no special formatting) + return content_body + + def package(self, skill_dir: Path, output_path: Path) -> Path: + """ + Package skill into ZIP file with markdown documentation. + + Creates universal structure: + - README.md (combined documentation) + - references/*.md (individual reference files) + - metadata.json (skill information) + + Args: + skill_dir: Path to skill directory + output_path: Output path/filename for ZIP + + Returns: + Path to created ZIP file + """ + skill_dir = Path(skill_dir) + + # Determine output filename + if output_path.is_dir() or str(output_path).endswith('/'): + output_path = Path(output_path) / f"{skill_dir.name}-markdown.zip" + elif not str(output_path).endswith('.zip'): + # Replace extension if needed + output_str = str(output_path).replace('.tar.gz', '.zip') + if not output_str.endswith('-markdown.zip'): + output_str = output_str.replace('.zip', '-markdown.zip') + if not output_str.endswith('.zip'): + output_str += '.zip' + output_path = Path(output_str) + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Create ZIP file + with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf: + # Add SKILL.md as README.md + skill_md = skill_dir / "SKILL.md" + if skill_md.exists(): + content = skill_md.read_text(encoding='utf-8') + zf.writestr("README.md", content) + + # Add individual reference files + refs_dir = skill_dir / "references" + if refs_dir.exists(): + for ref_file in refs_dir.rglob("*.md"): + if ref_file.is_file() and not ref_file.name.startswith('.'): + # Preserve directory structure under references/ + arcname = ref_file.relative_to(skill_dir) + zf.write(ref_file, str(arcname)) + + # Create combined documentation file + combined = self._create_combined_doc(skill_dir) + if combined: + zf.writestr("DOCUMENTATION.md", combined) + + # Add metadata file + import json + metadata = { + 'platform': 'markdown', + 'name': skill_dir.name, + 'version': '1.0.0', + 'created_with': 'skill-seekers', + 'format': 'universal_markdown', + 'usage': 'Use with any LLM or documentation system' + } + + zf.writestr("metadata.json", json.dumps(metadata, indent=2)) + + return output_path + + def upload(self, package_path: Path, api_key: str, **kwargs) -> Dict[str, Any]: + """ + Generic markdown export does not support upload. + + Users should manually use the exported markdown files. + + Args: + package_path: Path to package file + api_key: Not used + **kwargs: Not used + + Returns: + Result indicating no upload capability + """ + return { + 'success': False, + 'skill_id': None, + 'url': str(package_path.absolute()), + 'message': ( + 'Generic markdown export does not support automatic upload. ' + f'Your documentation is packaged at: {package_path.absolute()}' + ) + } + + def validate_api_key(self, api_key: str) -> bool: + """ + Markdown export doesn't use API keys. + + Args: + api_key: Not used + + Returns: + Always False (no API needed) + """ + return False + + def get_env_var_name(self) -> str: + """ + No API key needed for markdown export. + + Returns: + Empty string + """ + return "" + + def supports_enhancement(self) -> bool: + """ + Markdown export doesn't support AI enhancement. + + Returns: + False + """ + return False + + def enhance(self, skill_dir: Path, api_key: str) -> bool: + """ + Markdown export doesn't support enhancement. + + Args: + skill_dir: Not used + api_key: Not used + + Returns: + False + """ + print("❌ Generic markdown export does not support AI enhancement") + print(" Use --target claude, --target gemini, or --target openai for enhancement") + return False + + def _create_combined_doc(self, skill_dir: Path) -> str: + """ + Create a combined documentation file from all references. + + Args: + skill_dir: Path to skill directory + + Returns: + Combined markdown content + """ + skill_md = skill_dir / "SKILL.md" + refs_dir = skill_dir / "references" + + combined_parts = [] + + # Add main content + if skill_md.exists(): + content = skill_md.read_text(encoding='utf-8') + # Strip YAML frontmatter if present + if content.startswith('---'): + parts = content.split('---', 2) + if len(parts) >= 3: + content = parts[2].strip() + combined_parts.append(content) + + # Add separator + combined_parts.append("\n\n---\n\n") + + # Add all reference files + if refs_dir.exists(): + # Sort for consistent ordering + ref_files = sorted(refs_dir.glob("*.md")) + + for ref_file in ref_files: + if ref_file.name == "index.md": + continue # Skip index + + try: + ref_content = ref_file.read_text(encoding='utf-8') + combined_parts.append(f"# {ref_file.stem.replace('_', ' ').title()}\n\n") + combined_parts.append(ref_content) + combined_parts.append("\n\n---\n\n") + except Exception: + pass # Skip files that can't be read + + return "".join(combined_parts).strip() diff --git a/tests/test_adaptors/test_markdown_adaptor.py b/tests/test_adaptors/test_markdown_adaptor.py new file mode 100644 index 0000000..cec9207 --- /dev/null +++ b/tests/test_adaptors/test_markdown_adaptor.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Tests for Markdown adaptor +""" + +import unittest +from pathlib import Path +import tempfile +import zipfile + +from skill_seekers.cli.adaptors import get_adaptor +from skill_seekers.cli.adaptors.base import SkillMetadata + + +class TestMarkdownAdaptor(unittest.TestCase): + """Test Markdown adaptor functionality""" + + def setUp(self): + """Set up test adaptor""" + self.adaptor = get_adaptor('markdown') + + def test_platform_info(self): + """Test platform identifiers""" + self.assertEqual(self.adaptor.PLATFORM, 'markdown') + self.assertEqual(self.adaptor.PLATFORM_NAME, 'Generic Markdown (Universal)') + self.assertIsNone(self.adaptor.DEFAULT_API_ENDPOINT) + + def test_validate_api_key(self): + """Test that markdown export doesn't use API keys""" + # Any key should return False (no keys needed) + self.assertFalse(self.adaptor.validate_api_key('sk-ant-123')) + self.assertFalse(self.adaptor.validate_api_key('AIzaSyABC123')) + self.assertFalse(self.adaptor.validate_api_key('any-key')) + self.assertFalse(self.adaptor.validate_api_key('')) + + def test_get_env_var_name(self): + """Test environment variable name""" + self.assertEqual(self.adaptor.get_env_var_name(), '') + + def test_supports_enhancement(self): + """Test enhancement support""" + self.assertFalse(self.adaptor.supports_enhancement()) + + def test_enhance_returns_false(self): + """Test that enhance always returns False""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + refs_dir = skill_dir / "references" + refs_dir.mkdir() + (refs_dir / "test.md").write_text("Test content") + + success = self.adaptor.enhance(skill_dir, 'not-used') + self.assertFalse(success) + + def test_format_skill_md_no_frontmatter(self): + """Test that markdown format has no YAML frontmatter""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + + # Create minimal skill structure + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test content") + + metadata = SkillMetadata( + name="test-skill", + description="Test skill description" + ) + + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + # Should NOT start with YAML frontmatter + self.assertFalse(formatted.startswith('---')) + # Should contain the skill name and description + self.assertIn('test-skill', formatted.lower()) + self.assertIn('Test skill description', formatted) + + def test_package_creates_zip(self): + """Test that package creates ZIP file with correct structure""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + + # Create minimal skill structure + (skill_dir / "SKILL.md").write_text("# Test Skill Documentation") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "guide.md").write_text("# User Guide") + (skill_dir / "references" / "api.md").write_text("# API Reference") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + # Package skill + package_path = self.adaptor.package(skill_dir, output_dir) + + # Verify package was created + self.assertTrue(package_path.exists()) + self.assertTrue(str(package_path).endswith('.zip')) + self.assertIn('markdown', package_path.name) + + # Verify package contents + with zipfile.ZipFile(package_path, 'r') as zf: + names = zf.namelist() + + # Should have README.md (from SKILL.md) + self.assertIn('README.md', names) + + # Should have metadata.json + self.assertIn('metadata.json', names) + + # Should have DOCUMENTATION.md (combined) + self.assertIn('DOCUMENTATION.md', names) + + # Should have reference files + self.assertIn('references/guide.md', names) + self.assertIn('references/api.md', names) + + def test_package_readme_content(self): + """Test that README.md contains SKILL.md content""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + + skill_md_content = "# Test Skill\n\nThis is test documentation." + (skill_dir / "SKILL.md").write_text(skill_md_content) + (skill_dir / "references").mkdir() + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + # Verify README.md content + with zipfile.ZipFile(package_path, 'r') as zf: + readme_content = zf.read('README.md').decode('utf-8') + self.assertEqual(readme_content, skill_md_content) + + def test_package_combined_documentation(self): + """Test that DOCUMENTATION.md combines all references""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + + # Create SKILL.md + (skill_dir / "SKILL.md").write_text("# Main Skill") + + # Create references + refs_dir = skill_dir / "references" + refs_dir.mkdir() + (refs_dir / "guide.md").write_text("# Guide Content") + (refs_dir / "api.md").write_text("# API Content") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + # Verify DOCUMENTATION.md contains combined content + with zipfile.ZipFile(package_path, 'r') as zf: + doc_content = zf.read('DOCUMENTATION.md').decode('utf-8') + + # Should contain main skill content + self.assertIn('Main Skill', doc_content) + + # Should contain reference content + self.assertIn('Guide Content', doc_content) + self.assertIn('API Content', doc_content) + + # Should have separators + self.assertIn('---', doc_content) + + def test_package_metadata(self): + """Test that metadata.json is correct""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + + (skill_dir / "SKILL.md").write_text("# Test") + (skill_dir / "references").mkdir() + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + # Verify metadata + with zipfile.ZipFile(package_path, 'r') as zf: + import json + metadata_content = zf.read('metadata.json').decode('utf-8') + metadata = json.loads(metadata_content) + + self.assertEqual(metadata['platform'], 'markdown') + self.assertEqual(metadata['name'], 'test-skill') + self.assertEqual(metadata['format'], 'universal_markdown') + self.assertIn('created_with', metadata) + + def test_upload_not_supported(self): + """Test that upload returns appropriate message""" + with tempfile.NamedTemporaryFile(suffix='.zip') as tmp: + result = self.adaptor.upload(Path(tmp.name), 'not-used') + + self.assertFalse(result['success']) + self.assertIsNone(result['skill_id']) + self.assertIn('not support', result['message'].lower()) + # URL should point to local file + self.assertIn(tmp.name, result['url']) + + def test_package_output_filename(self): + """Test that package creates correct filename""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "my-framework" + skill_dir.mkdir() + + (skill_dir / "SKILL.md").write_text("# Test") + (skill_dir / "references").mkdir() + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + # Should include skill name and 'markdown' suffix + self.assertTrue(package_path.name.startswith('my-framework')) + self.assertIn('markdown', package_path.name) + self.assertTrue(package_path.name.endswith('.zip')) + + +if __name__ == '__main__': + unittest.main()