feat: Phase 4 - Implement MarkdownAdaptor for generic export
- Add MarkdownAdaptor for universal markdown export - Pure markdown format (no platform-specific features) - ZIP packaging with README.md, references/, DOCUMENTATION.md - No upload capability (manual use only) - No AI enhancement support - Combines all references into single DOCUMENTATION.md - Add 12 unit tests (all passing) Test Results: - 12 MarkdownAdaptor tests passing - 45 total adaptor tests passing (4 skipped) Phase 4 Complete ✅ Related to #179
This commit is contained in:
268
src/skill_seekers/cli/adaptors/markdown.py
Normal file
268
src/skill_seekers/cli/adaptors/markdown.py
Normal file
@@ -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()
|
||||
228
tests/test_adaptors/test_markdown_adaptor.py
Normal file
228
tests/test_adaptors/test_markdown_adaptor.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user