From b7cd317efb9b7ab4d1238cb5c595c8c962a7f71f Mon Sep 17 00:00:00 2001 From: yusyus Date: Sun, 21 Dec 2025 20:17:59 +0300 Subject: [PATCH] feat(A1.7): Add install_skill MCP tool for one-command workflow automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements complete end-to-end skill installation in a single command: fetch_config → scrape_docs → enhance_skill_local → package_skill → upload_skill Changes: - MCP Tool: Added install_skill_tool() to server.py (~300 lines) - Input validation (config_name XOR config_path) - 5-phase orchestration with error handling - Dry-run mode for workflow preview - Mandatory AI enhancement (30-60 sec, 3/10→9/10 quality boost) - Auto-upload to Claude (if ANTHROPIC_API_KEY set) - CLI Integration: New install command - Created install_skill.py CLI wrapper (~150 lines) - Updated main.py with install subcommand - Added entry point to pyproject.toml - Testing: Comprehensive test suite - Created test_install_skill.py with 13 tests - Tests cover validation, dry-run, orchestration, error handling - All tests passing (13/13) - Documentation: Updated all user-facing docs - CLAUDE.md: Added MCP tool (10 tools total) and CLI examples - README.md: Added prominent one-command workflow section - FLEXIBLE_ROADMAP.md: Marked A1.7 as complete Features: - Zero friction: One command instead of 5 separate steps - Quality guaranteed: Mandatory enhancement ensures 9/10 quality - Complete automation: From config to uploaded skill - Intelligent: Auto-detects config type (name vs path) - Flexible: Dry-run, unlimited, no-upload modes - Well-tested: 13 unit tests with mocking Usage: skill-seekers install --config react skill-seekers install --config configs/custom.json --no-upload skill-seekers install --config django --unlimited skill-seekers install --config react --dry-run Closes #204 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 52 +++- FLEXIBLE_ROADMAP.md | 3 +- README.md | 67 +++++ pyproject.toml | 1 + src/skill_seekers/cli/install_skill.py | 153 ++++++++++ src/skill_seekers/cli/main.py | 47 +++ src/skill_seekers/mcp/server.py | 345 +++++++++++++++++++++ tests/test_install_skill.py | 402 +++++++++++++++++++++++++ 8 files changed, 1067 insertions(+), 3 deletions(-) create mode 100644 src/skill_seekers/cli/install_skill.py create mode 100644 tests/test_install_skill.py diff --git a/CLAUDE.md b/CLAUDE.md index 503f705..1cf556b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,14 +67,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 🔌 MCP Integration Available -**This repository includes a fully tested MCP server with 9 tools:** +**This repository includes a fully tested MCP server with 10 tools:** - `mcp__skill-seeker__list_configs` - List all available preset configurations - `mcp__skill-seeker__generate_config` - Generate a new config file for any docs site - `mcp__skill-seeker__validate_config` - Validate a config file structure - `mcp__skill-seeker__estimate_pages` - Estimate page count before scraping - `mcp__skill-seeker__scrape_docs` - Scrape and build a skill - `mcp__skill-seeker__package_skill` - Package skill into .zip file (with auto-upload) -- `mcp__skill-seeker__upload_skill` - Upload .zip to Claude (NEW) +- `mcp__skill-seeker__upload_skill` - Upload .zip to Claude +- `mcp__skill-seeker__install_skill` - **NEW!** Complete one-command workflow (fetch → scrape → enhance → package → upload) - `mcp__skill-seeker__split_config` - Split large documentation configs - `mcp__skill-seeker__generate_router` - Generate router/hub skills @@ -188,6 +189,53 @@ skill-seekers package output/godot/ # Result: godot.zip ready to upload to Claude ``` +### **NEW!** One-Command Install Workflow (v2.1.1) + +The fastest way to install a skill - complete automation from config to uploaded skill: + +```bash +# Install React skill from official configs (auto-uploads to Claude) +skill-seekers install --config react +# Time: 20-45 minutes total (scraping 20-40 min + enhancement 60 sec + upload 5 sec) + +# Install from local config file +skill-seekers install --config configs/custom.json + +# Install without uploading (package only) +skill-seekers install --config django --no-upload + +# Unlimited scraping (no page limits - WARNING: can take hours) +skill-seekers install --config godot --unlimited + +# Preview workflow without executing +skill-seekers install --config react --dry-run + +# Custom output directory +skill-seekers install --config vue --destination /tmp/skills +``` + +**What it does automatically:** +1. ✅ Fetches config from API (if config name provided) +2. ✅ Scrapes documentation +3. ✅ **AI Enhancement (MANDATORY)** - 30-60 sec, quality boost from 3/10 → 9/10 +4. ✅ Packages skill to .zip +5. ✅ Uploads to Claude (if ANTHROPIC_API_KEY set) + +**Why use this:** +- **Zero friction** - One command instead of 5 separate steps +- **Quality guaranteed** - Enhancement is mandatory, ensures professional output +- **Complete automation** - From config name to uploaded skill +- **Time savings** - Fully automated workflow + +**Phases executed:** +``` +📥 PHASE 1: Fetch Config (if config name provided) +📖 PHASE 2: Scrape Documentation +✨ PHASE 3: AI Enhancement (MANDATORY - no skip option) +📦 PHASE 4: Package Skill +☁️ PHASE 5: Upload to Claude (optional) +``` + ### Interactive Mode ```bash diff --git a/FLEXIBLE_ROADMAP.md b/FLEXIBLE_ROADMAP.md index 9dbd961..a63f7e3 100644 --- a/FLEXIBLE_ROADMAP.md +++ b/FLEXIBLE_ROADMAP.md @@ -58,12 +58,13 @@ Small tasks that build community features incrementally - **Approach:** Use GitHub Issues with labels (no custom code needed) - **Workflow:** Review → Validate → Test → Approve/Reject - **Time:** 1-2 hours (GitHub Issues) or 4-6 hours (custom dashboard) -- [ ] **Task A1.7:** Add MCP tool `install_skill` for one-command workflow (Issue #204) +- [x] **Task A1.7:** Add MCP tool `install_skill` for one-command workflow (Issue #204) ✅ **COMPLETE!** - **Purpose:** Complete one-command workflow: fetch → scrape → **enhance** → package → upload - **Features:** Single command install, smart config detection, automatic AI enhancement (LOCAL) - **Workflow:** fetch_config → scrape_docs → enhance_skill_local → package_skill → upload_skill - **Critical:** Always includes AI enhancement step (30-60 sec, 3/10→9/10 quality boost) - **Time:** 3-4 hours + - **Completed:** December 21, 2025 - 10 tools total, 13 tests passing, full automation working - [ ] **Task A1.8:** Add smart skill detection and auto-install (Issue #205) - **Purpose:** Auto-detect missing skills from user queries and offer to install them - **Features:** Topic extraction, skill gap analysis, API search, smart suggestions diff --git a/README.md b/README.md index 4923752..ebcef18 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,73 @@ python3 src/skill_seekers/cli/doc_scraper.py --config configs/react.json **Time:** ~25 minutes | **Quality:** Production-ready | **Cost:** Free +--- + +## 🚀 **NEW!** One-Command Install Workflow (v2.1.1) + +**The fastest way to go from config to uploaded skill - complete automation:** + +```bash +# Install React skill from official configs (auto-uploads to Claude) +skill-seekers install --config react + +# Install from local config file +skill-seekers install --config configs/custom.json + +# Install without uploading (package only) +skill-seekers install --config django --no-upload + +# Unlimited scraping (no page limits) +skill-seekers install --config godot --unlimited + +# Preview workflow without executing +skill-seekers install --config react --dry-run +``` + +**Time:** 20-45 minutes total | **Quality:** Production-ready (9/10) | **Cost:** Free + +### What it does automatically: + +1. ✅ **Fetches config** from API (if config name provided) +2. ✅ **Scrapes documentation** (respects rate limits, handles pagination) +3. ✅ **AI Enhancement (MANDATORY)** - 30-60 sec, quality boost from 3/10 → 9/10 +4. ✅ **Packages skill** to .zip file +5. ✅ **Uploads to Claude** (if ANTHROPIC_API_KEY set) + +### Why use this? + +- **Zero friction** - One command instead of 5 separate steps +- **Quality guaranteed** - Enhancement is mandatory, ensures professional output +- **Complete automation** - From config name to uploaded skill in Claude +- **Time savings** - Fully automated end-to-end workflow + +### Phases executed: + +``` +📥 PHASE 1: Fetch Config (if config name provided) +📖 PHASE 2: Scrape Documentation +✨ PHASE 3: AI Enhancement (MANDATORY - no skip option) +📦 PHASE 4: Package Skill +☁️ PHASE 5: Upload to Claude (optional, requires API key) +``` + +**Requirements:** +- ANTHROPIC_API_KEY environment variable (for auto-upload) +- Claude Code Max plan (for local AI enhancement) + +**Example:** +```bash +# Set API key once +export ANTHROPIC_API_KEY=sk-ant-your-key-here + +# Run one command - sit back and relax! +skill-seekers install --config react + +# Result: React skill uploaded to Claude in 20-45 minutes +``` + +--- + ## Usage Examples ### Documentation Scraping diff --git a/pyproject.toml b/pyproject.toml index e94e498..4b2b4ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ skill-seekers-enhance = "skill_seekers.cli.enhance_skill_local:main" skill-seekers-package = "skill_seekers.cli.package_skill:main" skill-seekers-upload = "skill_seekers.cli.upload_skill:main" skill-seekers-estimate = "skill_seekers.cli.estimate_pages:main" +skill-seekers-install = "skill_seekers.cli.install_skill:main" [tool.setuptools] packages = ["skill_seekers", "skill_seekers.cli", "skill_seekers.mcp", "skill_seekers.mcp.tools"] diff --git a/src/skill_seekers/cli/install_skill.py b/src/skill_seekers/cli/install_skill.py new file mode 100644 index 0000000..8298e5d --- /dev/null +++ b/src/skill_seekers/cli/install_skill.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +Complete Skill Installation Workflow +One-command installation: fetch → scrape → enhance → package → upload + +This CLI tool orchestrates the complete skill installation workflow by calling +the install_skill MCP tool. + +Usage: + skill-seekers install --config react + skill-seekers install --config configs/custom.json --no-upload + skill-seekers install --config django --unlimited + skill-seekers install --config react --dry-run + +Examples: + # Install React skill from official configs + skill-seekers install --config react + + # Install from local config file + skill-seekers install --config configs/custom.json + + # Install without uploading + skill-seekers install --config django --no-upload + + # Preview workflow without executing + skill-seekers install --config react --dry-run +""" + +import asyncio +import argparse +import sys +from pathlib import Path + +# Add parent directory to path to import MCP server +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Import the MCP tool function +from skill_seekers.mcp.server import install_skill_tool + + +def main(): + """Main entry point for CLI""" + parser = argparse.ArgumentParser( + description="Complete skill installation workflow (fetch → scrape → enhance → package → upload)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Install React skill from official API + skill-seekers install --config react + + # Install from local config file + skill-seekers install --config configs/custom.json + + # Install without uploading + skill-seekers install --config django --no-upload + + # Unlimited scraping (no page limits) + skill-seekers install --config godot --unlimited + + # Preview workflow (dry run) + skill-seekers install --config react --dry-run + +Important: + - Enhancement is MANDATORY (30-60 sec) for quality (3/10→9/10) + - Total time: 20-45 minutes (mostly scraping) + - Auto-uploads to Claude if ANTHROPIC_API_KEY is set + +Phases: + 1. Fetch config (if config name provided) + 2. Scrape documentation + 3. AI Enhancement (MANDATORY - no skip option) + 4. Package to .zip + 5. Upload to Claude (optional) +""" + ) + + parser.add_argument( + "--config", + required=True, + help="Config name (e.g., 'react') or path (e.g., 'configs/custom.json')" + ) + + parser.add_argument( + "--destination", + default="output", + help="Output directory for skill files (default: output/)" + ) + + parser.add_argument( + "--no-upload", + action="store_true", + help="Skip automatic upload to Claude" + ) + + parser.add_argument( + "--unlimited", + action="store_true", + help="Remove page limits during scraping (WARNING: Can take hours)" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview workflow without executing" + ) + + args = parser.parse_args() + + # Determine if config is a name or path + config_arg = args.config + if config_arg.endswith('.json') or '/' in config_arg or '\\' in config_arg: + # It's a path + config_path = config_arg + config_name = None + else: + # It's a name + config_name = config_arg + config_path = None + + # Build arguments for install_skill_tool + tool_args = { + "config_name": config_name, + "config_path": config_path, + "destination": args.destination, + "auto_upload": not args.no_upload, + "unlimited": args.unlimited, + "dry_run": args.dry_run + } + + # Run async tool + try: + result = asyncio.run(install_skill_tool(tool_args)) + + # Print output + for content in result: + print(content.text) + + # Return success/failure based on output + output_text = result[0].text + if "❌" in output_text and "WORKFLOW COMPLETE" not in output_text: + return 1 + return 0 + + except KeyboardInterrupt: + print("\n\n⚠️ Workflow interrupted by user") + return 130 # Standard exit code for SIGINT + except Exception as e: + print(f"\n\n❌ Unexpected error: {str(e)}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/skill_seekers/cli/main.py b/src/skill_seekers/cli/main.py index dcf677d..e3458ee 100644 --- a/src/skill_seekers/cli/main.py +++ b/src/skill_seekers/cli/main.py @@ -156,6 +156,38 @@ For more information: https://github.com/yusufkaraaslan/Skill_Seekers estimate_parser.add_argument("config", help="Config JSON file") estimate_parser.add_argument("--max-discovery", type=int, help="Max pages to discover") + # === install subcommand === + install_parser = subparsers.add_parser( + "install", + help="Complete workflow: fetch → scrape → enhance → package → upload", + description="One-command skill installation (AI enhancement MANDATORY)" + ) + install_parser.add_argument( + "--config", + required=True, + help="Config name (e.g., 'react') or path (e.g., 'configs/custom.json')" + ) + install_parser.add_argument( + "--destination", + default="output", + help="Output directory (default: output/)" + ) + install_parser.add_argument( + "--no-upload", + action="store_true", + help="Skip automatic upload to Claude" + ) + install_parser.add_argument( + "--unlimited", + action="store_true", + help="Remove page limits during scraping" + ) + install_parser.add_argument( + "--dry-run", + action="store_true", + help="Preview workflow without executing" + ) + return parser @@ -268,6 +300,21 @@ def main(argv: Optional[List[str]] = None) -> int: sys.argv.extend(["--max-discovery", str(args.max_discovery)]) return estimate_main() or 0 + elif args.command == "install": + from skill_seekers.cli.install_skill import main as install_main + sys.argv = ["install_skill.py"] + if args.config: + sys.argv.extend(["--config", args.config]) + if args.destination: + sys.argv.extend(["--destination", args.destination]) + if args.no_upload: + sys.argv.append("--no-upload") + if args.unlimited: + sys.argv.append("--unlimited") + if args.dry_run: + sys.argv.append("--dry-run") + return install_main() or 0 + else: print(f"Error: Unknown command '{args.command}'", file=sys.stderr) parser.print_help() diff --git a/src/skill_seekers/mcp/server.py b/src/skill_seekers/mcp/server.py index e1f619d..5e099fc 100644 --- a/src/skill_seekers/mcp/server.py +++ b/src/skill_seekers/mcp/server.py @@ -418,6 +418,44 @@ async def list_tools() -> list[Tool]: "required": [], }, ), + Tool( + name="install_skill", + description="Complete one-command workflow: fetch config → scrape docs → AI enhance (MANDATORY) → package → upload. Enhancement required for quality (3/10→9/10). Takes 20-45 min depending on config size. Automatically uploads to Claude if ANTHROPIC_API_KEY is set.", + inputSchema={ + "type": "object", + "properties": { + "config_name": { + "type": "string", + "description": "Config name from API (e.g., 'react', 'django'). Mutually exclusive with config_path. Tool will fetch this config from the official API before scraping.", + }, + "config_path": { + "type": "string", + "description": "Path to existing config JSON file (e.g., 'configs/custom.json'). Mutually exclusive with config_name. Use this if you already have a config file.", + }, + "destination": { + "type": "string", + "description": "Output directory for skill files (default: 'output')", + "default": "output", + }, + "auto_upload": { + "type": "boolean", + "description": "Auto-upload to Claude after packaging (requires ANTHROPIC_API_KEY). Default: true. Set to false to skip upload.", + "default": True, + }, + "unlimited": { + "type": "boolean", + "description": "Remove page limits during scraping (default: false). WARNING: Can take hours for large sites.", + "default": False, + }, + "dry_run": { + "type": "boolean", + "description": "Preview workflow without executing (default: false). Shows all phases that would run.", + "default": False, + }, + }, + "required": [], + }, + ), Tool( name="fetch_config", description="Fetch config from API, git URL, or registered source. Supports three modes: (1) Named source from registry, (2) Direct git URL, (3) API (default). List available configs or download a specific one by name.", @@ -605,6 +643,8 @@ async def call_tool(name: str, arguments: Any) -> list[TextContent]: return await list_config_sources_tool(arguments) elif name == "remove_config_source": return await remove_config_source_tool(arguments) + elif name == "install_skill": + return await install_skill_tool(arguments) else: return [TextContent(type="text", text=f"Unknown tool: {name}")] @@ -1462,6 +1502,311 @@ Next steps: return [TextContent(type="text", text=f"❌ Error: {str(e)}")] +async def install_skill_tool(args: dict) -> list[TextContent]: + """ + Complete skill installation workflow. + + Orchestrates the complete workflow: + 1. Fetch config (if config_name provided) + 2. Scrape documentation + 3. AI Enhancement (MANDATORY - no skip option) + 4. Package to .zip + 5. Upload to Claude (optional) + + Args: + config_name: Config to fetch from API (mutually exclusive with config_path) + config_path: Path to existing config (mutually exclusive with config_name) + destination: Output directory (default: "output") + auto_upload: Upload after packaging (default: True) + unlimited: Remove page limits (default: False) + dry_run: Preview only (default: False) + + Returns: + List of TextContent with workflow progress and results + """ + import json + import re + + # Extract and validate inputs + config_name = args.get("config_name") + config_path = args.get("config_path") + destination = args.get("destination", "output") + auto_upload = args.get("auto_upload", True) + unlimited = args.get("unlimited", False) + dry_run = args.get("dry_run", False) + + # Validation: Must provide exactly one of config_name or config_path + if not config_name and not config_path: + return [TextContent( + type="text", + text="❌ Error: Must provide either config_name or config_path\n\nExamples:\n install_skill(config_name='react')\n install_skill(config_path='configs/custom.json')" + )] + + if config_name and config_path: + return [TextContent( + type="text", + text="❌ Error: Cannot provide both config_name and config_path\n\nChoose one:\n - config_name: Fetch from API (e.g., 'react')\n - config_path: Use existing file (e.g., 'configs/custom.json')" + )] + + # Initialize output + output_lines = [] + output_lines.append("🚀 SKILL INSTALLATION WORKFLOW") + output_lines.append("=" * 70) + output_lines.append("") + + if dry_run: + output_lines.append("🔍 DRY RUN MODE - Preview only, no actions taken") + output_lines.append("") + + # Track workflow state + workflow_state = { + 'config_path': config_path, + 'skill_name': None, + 'skill_dir': None, + 'zip_path': None, + 'phases_completed': [] + } + + try: + # ===== PHASE 1: Fetch Config (if needed) ===== + if config_name: + output_lines.append("📥 PHASE 1/5: Fetch Config") + output_lines.append("-" * 70) + output_lines.append(f"Config: {config_name}") + output_lines.append(f"Destination: {destination}/") + output_lines.append("") + + if not dry_run: + # Call fetch_config_tool directly + fetch_result = await fetch_config_tool({ + "config_name": config_name, + "destination": destination + }) + + # Parse result to extract config path + fetch_output = fetch_result[0].text + output_lines.append(fetch_output) + output_lines.append("") + + # Extract config path from output + # Expected format: "✅ Config saved to: configs/react.json" + match = re.search(r"saved to:\s*(.+\.json)", fetch_output) + if match: + workflow_state['config_path'] = match.group(1).strip() + output_lines.append(f"✅ Config fetched: {workflow_state['config_path']}") + else: + return [TextContent(type="text", text="\n".join(output_lines) + "\n\n❌ Failed to fetch config")] + + workflow_state['phases_completed'].append('fetch_config') + else: + output_lines.append(" [DRY RUN] Would fetch config from API") + workflow_state['config_path'] = f"{destination}/{config_name}.json" + + output_lines.append("") + + # ===== PHASE 2: Scrape Documentation ===== + phase_num = "2/5" if config_name else "1/4" + output_lines.append(f"📄 PHASE {phase_num}: Scrape Documentation") + output_lines.append("-" * 70) + output_lines.append(f"Config: {workflow_state['config_path']}") + output_lines.append(f"Unlimited mode: {unlimited}") + output_lines.append("") + + if not dry_run: + # Load config to get skill name + try: + with open(workflow_state['config_path'], 'r') as f: + config = json.load(f) + workflow_state['skill_name'] = config.get('name', 'unknown') + except Exception as e: + return [TextContent(type="text", text="\n".join(output_lines) + f"\n\n❌ Failed to read config: {str(e)}")] + + # Call scrape_docs_tool (does NOT include enhancement) + output_lines.append("Scraping documentation (this may take 20-45 minutes)...") + output_lines.append("") + + scrape_result = await scrape_docs_tool({ + "config_path": workflow_state['config_path'], + "unlimited": unlimited, + "enhance_local": False, # Enhancement is separate phase + "skip_scrape": False, + "dry_run": False + }) + + scrape_output = scrape_result[0].text + output_lines.append(scrape_output) + output_lines.append("") + + # Check for success + if "❌" in scrape_output: + return [TextContent(type="text", text="\n".join(output_lines) + "\n\n❌ Scraping failed - see error above")] + + workflow_state['skill_dir'] = f"{destination}/{workflow_state['skill_name']}" + workflow_state['phases_completed'].append('scrape_docs') + else: + output_lines.append(" [DRY RUN] Would scrape documentation") + workflow_state['skill_name'] = "example" + workflow_state['skill_dir'] = f"{destination}/example" + + output_lines.append("") + + # ===== PHASE 3: AI Enhancement (MANDATORY) ===== + phase_num = "3/5" if config_name else "2/4" + output_lines.append(f"✨ PHASE {phase_num}: AI Enhancement (MANDATORY)") + output_lines.append("-" * 70) + output_lines.append("⚠️ Enhancement is REQUIRED for quality (3/10→9/10 boost)") + output_lines.append(f"Skill directory: {workflow_state['skill_dir']}") + output_lines.append("Mode: Headless (runs in background)") + output_lines.append("Estimated time: 30-60 seconds") + output_lines.append("") + + if not dry_run: + # Run enhance_skill_local in headless mode + # Build command directly + cmd = [ + sys.executable, + str(CLI_DIR / "enhance_skill_local.py"), + workflow_state['skill_dir'] + # Headless is default, no flag needed + ] + + timeout = 900 # 15 minutes max for enhancement + + output_lines.append("Running AI enhancement...") + + stdout, stderr, returncode = run_subprocess_with_streaming(cmd, timeout=timeout) + + if returncode != 0: + output_lines.append(f"\n❌ Enhancement failed (exit code {returncode}):") + output_lines.append(stderr if stderr else stdout) + return [TextContent(type="text", text="\n".join(output_lines))] + + output_lines.append(stdout) + workflow_state['phases_completed'].append('enhance_skill') + else: + output_lines.append(" [DRY RUN] Would enhance SKILL.md with Claude Code") + + output_lines.append("") + + # ===== PHASE 4: Package Skill ===== + phase_num = "4/5" if config_name else "3/4" + output_lines.append(f"📦 PHASE {phase_num}: Package Skill") + output_lines.append("-" * 70) + output_lines.append(f"Skill directory: {workflow_state['skill_dir']}") + output_lines.append("") + + if not dry_run: + # Call package_skill_tool (auto_upload=False, we handle upload separately) + package_result = await package_skill_tool({ + "skill_dir": workflow_state['skill_dir'], + "auto_upload": False # We handle upload in next phase + }) + + package_output = package_result[0].text + output_lines.append(package_output) + output_lines.append("") + + # Extract zip path from output + # Expected format: "Saved to: output/react.zip" + match = re.search(r"Saved to:\s*(.+\.zip)", package_output) + if match: + workflow_state['zip_path'] = match.group(1).strip() + else: + # Fallback: construct zip path + workflow_state['zip_path'] = f"{destination}/{workflow_state['skill_name']}.zip" + + workflow_state['phases_completed'].append('package_skill') + else: + output_lines.append(" [DRY RUN] Would package to .zip file") + workflow_state['zip_path'] = f"{destination}/{workflow_state['skill_name']}.zip" + + output_lines.append("") + + # ===== PHASE 5: Upload (Optional) ===== + if auto_upload: + phase_num = "5/5" if config_name else "4/4" + output_lines.append(f"📤 PHASE {phase_num}: Upload to Claude") + output_lines.append("-" * 70) + output_lines.append(f"Zip file: {workflow_state['zip_path']}") + output_lines.append("") + + # Check for API key + has_api_key = os.environ.get('ANTHROPIC_API_KEY', '').strip() + + if not dry_run: + if has_api_key: + # Call upload_skill_tool + upload_result = await upload_skill_tool({ + "skill_zip": workflow_state['zip_path'] + }) + + upload_output = upload_result[0].text + output_lines.append(upload_output) + + workflow_state['phases_completed'].append('upload_skill') + else: + output_lines.append("⚠️ ANTHROPIC_API_KEY not set - skipping upload") + output_lines.append("") + output_lines.append("To enable automatic upload:") + output_lines.append(" 1. Get API key from https://console.anthropic.com/") + output_lines.append(" 2. Set: export ANTHROPIC_API_KEY=sk-ant-...") + output_lines.append("") + output_lines.append("📤 Manual upload:") + output_lines.append(" 1. Go to https://claude.ai/skills") + output_lines.append(" 2. Click 'Upload Skill'") + output_lines.append(f" 3. Select: {workflow_state['zip_path']}") + else: + output_lines.append(" [DRY RUN] Would upload to Claude (if API key set)") + + output_lines.append("") + + # ===== WORKFLOW SUMMARY ===== + output_lines.append("=" * 70) + output_lines.append("✅ WORKFLOW COMPLETE") + output_lines.append("=" * 70) + output_lines.append("") + + if not dry_run: + output_lines.append("Phases completed:") + for phase in workflow_state['phases_completed']: + output_lines.append(f" ✓ {phase}") + output_lines.append("") + + output_lines.append("📁 Output:") + output_lines.append(f" Skill directory: {workflow_state['skill_dir']}") + if workflow_state['zip_path']: + output_lines.append(f" Skill package: {workflow_state['zip_path']}") + output_lines.append("") + + if auto_upload and has_api_key: + output_lines.append("🎉 Your skill is now available in Claude!") + output_lines.append(" Go to https://claude.ai/skills to use it") + elif auto_upload: + output_lines.append("📝 Manual upload required (see instructions above)") + else: + output_lines.append("📤 To upload:") + output_lines.append(" skill-seekers upload " + workflow_state['zip_path']) + else: + output_lines.append("This was a dry run. No actions were taken.") + output_lines.append("") + output_lines.append("To execute for real, remove the --dry-run flag:") + if config_name: + output_lines.append(f" install_skill(config_name='{config_name}')") + else: + output_lines.append(f" install_skill(config_path='{config_path}')") + + return [TextContent(type="text", text="\n".join(output_lines))] + + except Exception as e: + output_lines.append("") + output_lines.append(f"❌ Workflow failed: {str(e)}") + output_lines.append("") + output_lines.append("Phases completed before failure:") + for phase in workflow_state['phases_completed']: + output_lines.append(f" ✓ {phase}") + return [TextContent(type="text", text="\n".join(output_lines))] + + async def submit_config_tool(args: dict) -> list[TextContent]: """Submit a custom config to skill-seekers-configs repository via GitHub issue""" try: diff --git a/tests/test_install_skill.py b/tests/test_install_skill.py new file mode 100644 index 0000000..97b2286 --- /dev/null +++ b/tests/test_install_skill.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +""" +Tests for install_skill MCP tool and CLI + +Tests the complete workflow orchestration for A1.7: +- Input validation +- Dry-run mode +- Phase orchestration +- Error handling +- CLI integration +""" + +import asyncio +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from mcp.types import TextContent + +# Import the function to test +from skill_seekers.mcp.server import install_skill_tool + + +class TestInstallSkillValidation: + """Test input validation""" + + @pytest.mark.asyncio + async def test_validation_no_config(self): + """Test error when neither config_name nor config_path provided""" + result = await install_skill_tool({}) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "❌ Error: Must provide either config_name or config_path" in result[0].text + assert "Examples:" in result[0].text + + @pytest.mark.asyncio + async def test_validation_both_configs(self): + """Test error when both config_name and config_path provided""" + result = await install_skill_tool({ + "config_name": "react", + "config_path": "configs/react.json" + }) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "❌ Error: Cannot provide both config_name and config_path" in result[0].text + assert "Choose one:" in result[0].text + + +class TestInstallSkillDryRun: + """Test dry-run mode""" + + @pytest.mark.asyncio + async def test_dry_run_with_config_name(self): + """Test dry run with config name (includes fetch phase)""" + result = await install_skill_tool({ + "config_name": "react", + "dry_run": True + }) + + assert len(result) == 1 + output = result[0].text + + # Verify dry run mode is indicated + assert "🔍 DRY RUN MODE" in output + assert "Preview only, no actions taken" in output + + # Verify all 5 phases are shown + assert "PHASE 1/5: Fetch Config" in output + assert "PHASE 2/5: Scrape Documentation" in output + assert "PHASE 3/5: AI Enhancement (MANDATORY)" in output + assert "PHASE 4/5: Package Skill" in output + assert "PHASE 5/5: Upload to Claude" in output + + # Verify dry run indicators + assert "[DRY RUN]" in output + assert "This was a dry run. No actions were taken." in output + + @pytest.mark.asyncio + async def test_dry_run_with_config_path(self): + """Test dry run with config path (skips fetch phase)""" + result = await install_skill_tool({ + "config_path": "configs/react.json", + "dry_run": True + }) + + assert len(result) == 1 + output = result[0].text + + # Verify dry run mode + assert "🔍 DRY RUN MODE" in output + + # Verify only 4 phases (no fetch) + assert "PHASE 1/4: Scrape Documentation" in output + assert "PHASE 2/4: AI Enhancement (MANDATORY)" in output + assert "PHASE 3/4: Package Skill" in output + assert "PHASE 4/4: Upload to Claude" in output + + # Should not show fetch phase + assert "PHASE 1/5" not in output + assert "Fetch Config" not in output + + +class TestInstallSkillEnhancementMandatory: + """Test that enhancement is always included""" + + @pytest.mark.asyncio + async def test_enhancement_is_mandatory(self): + """Test that enhancement phase is always present and mandatory""" + result = await install_skill_tool({ + "config_name": "react", + "dry_run": True + }) + + output = result[0].text + + # Verify enhancement phase is present + assert "AI Enhancement (MANDATORY)" in output + assert "Enhancement is REQUIRED for quality (3/10→9/10 boost)" in output or \ + "REQUIRED for quality" in output + + # Verify it's not optional + assert "MANDATORY" in output + assert "no skip option" in output.lower() or "MANDATORY" in output + + +class TestInstallSkillPhaseOrchestration: + """Test phase orchestration and data flow""" + + @pytest.mark.asyncio + @patch('skill_seekers.mcp.server.fetch_config_tool') + @patch('skill_seekers.mcp.server.scrape_docs_tool') + @patch('skill_seekers.mcp.server.run_subprocess_with_streaming') + @patch('skill_seekers.mcp.server.package_skill_tool') + @patch('skill_seekers.mcp.server.upload_skill_tool') + @patch('builtins.open') + @patch('os.environ.get') + async def test_full_workflow_with_fetch( + self, + mock_env_get, + mock_open, + mock_upload, + mock_package, + mock_subprocess, + mock_scrape, + mock_fetch + ): + """Test complete workflow when config_name is provided""" + + # Mock fetch_config response + mock_fetch.return_value = [TextContent( + type="text", + text="✅ Config fetched successfully\n\nConfig saved to: configs/react.json" + )] + + # Mock config file read + import json + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = json.dumps({"name": "react"}) + mock_open.return_value = mock_file + + # Mock scrape_docs response + mock_scrape.return_value = [TextContent( + type="text", + text="✅ Scraping complete\n\nSkill built at: output/react/" + )] + + # Mock enhancement subprocess + mock_subprocess.return_value = ("✅ Enhancement complete", "", 0) + + # Mock package response + mock_package.return_value = [TextContent( + type="text", + text="✅ Package complete\n\nSaved to: output/react.zip" + )] + + # Mock upload response + mock_upload.return_value = [TextContent( + type="text", + text="✅ Upload successful" + )] + + # Mock env (has API key) + mock_env_get.return_value = "sk-ant-test-key" + + # Run the workflow + result = await install_skill_tool({ + "config_name": "react", + "auto_upload": True + }) + + output = result[0].text + + # Verify all phases executed + assert "PHASE 1/5: Fetch Config" in output + assert "PHASE 2/5: Scrape Documentation" in output + assert "PHASE 3/5: AI Enhancement" in output + assert "PHASE 4/5: Package Skill" in output + assert "PHASE 5/5: Upload to Claude" in output + + # Verify workflow completion + assert "✅ WORKFLOW COMPLETE" in output + assert "fetch_config" in output + assert "scrape_docs" in output + assert "enhance_skill" in output + assert "package_skill" in output + assert "upload_skill" in output + + @pytest.mark.asyncio + @patch('skill_seekers.mcp.server.scrape_docs_tool') + @patch('skill_seekers.mcp.server.run_subprocess_with_streaming') + @patch('skill_seekers.mcp.server.package_skill_tool') + @patch('builtins.open') + @patch('os.environ.get') + async def test_workflow_with_existing_config( + self, + mock_env_get, + mock_open, + mock_package, + mock_subprocess, + mock_scrape + ): + """Test workflow when config_path is provided (skips fetch)""" + + # Mock config file read + import json + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = json.dumps({"name": "custom"}) + mock_open.return_value = mock_file + + # Mock scrape response + mock_scrape.return_value = [TextContent( + type="text", + text="✅ Scraping complete" + )] + + # Mock enhancement subprocess + mock_subprocess.return_value = ("✅ Enhancement complete", "", 0) + + # Mock package response + mock_package.return_value = [TextContent( + type="text", + text="✅ Package complete\n\nSaved to: output/custom.zip" + )] + + # Mock env (no API key - should skip upload) + mock_env_get.return_value = "" + + # Run the workflow + result = await install_skill_tool({ + "config_path": "configs/custom.json", + "auto_upload": True + }) + + output = result[0].text + + # Should only have 4 phases (no fetch) + assert "PHASE 1/4: Scrape Documentation" in output + assert "PHASE 2/4: AI Enhancement" in output + assert "PHASE 3/4: Package Skill" in output + assert "PHASE 4/4: Upload to Claude" in output + + # Should not have fetch phase + assert "Fetch Config" not in output + + # Should show manual upload instructions (no API key) + assert "⚠️ ANTHROPIC_API_KEY not set" in output + assert "Manual upload:" in output + + +class TestInstallSkillErrorHandling: + """Test error handling at each phase""" + + @pytest.mark.asyncio + @patch('skill_seekers.mcp.server.fetch_config_tool') + async def test_fetch_phase_failure(self, mock_fetch): + """Test handling of fetch phase failure""" + + # Mock fetch failure + mock_fetch.return_value = [TextContent( + type="text", + text="❌ Failed to fetch config: Network error" + )] + + result = await install_skill_tool({ + "config_name": "react" + }) + + output = result[0].text + + # Verify error is shown + assert "❌ Failed to fetch config" in output + + @pytest.mark.asyncio + @patch('skill_seekers.mcp.server.scrape_docs_tool') + @patch('builtins.open') + async def test_scrape_phase_failure(self, mock_open, mock_scrape): + """Test handling of scrape phase failure""" + + # Mock config read + import json + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = json.dumps({"name": "test"}) + mock_open.return_value = mock_file + + # Mock scrape failure + mock_scrape.return_value = [TextContent( + type="text", + text="❌ Scraping failed: Connection timeout" + )] + + result = await install_skill_tool({ + "config_path": "configs/test.json" + }) + + output = result[0].text + + # Verify error is shown and workflow stops + assert "❌ Scraping failed" in output + assert "WORKFLOW COMPLETE" not in output + + @pytest.mark.asyncio + @patch('skill_seekers.mcp.server.scrape_docs_tool') + @patch('skill_seekers.mcp.server.run_subprocess_with_streaming') + @patch('builtins.open') + async def test_enhancement_phase_failure(self, mock_open, mock_subprocess, mock_scrape): + """Test handling of enhancement phase failure""" + + # Mock config read + import json + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = json.dumps({"name": "test"}) + mock_open.return_value = mock_file + + # Mock scrape success + mock_scrape.return_value = [TextContent( + type="text", + text="✅ Scraping complete" + )] + + # Mock enhancement failure + mock_subprocess.return_value = ("", "Enhancement error: Claude not found", 1) + + result = await install_skill_tool({ + "config_path": "configs/test.json" + }) + + output = result[0].text + + # Verify error is shown + assert "❌ Enhancement failed" in output + assert "exit code 1" in output + + +class TestInstallSkillOptions: + """Test various option combinations""" + + @pytest.mark.asyncio + async def test_no_upload_option(self): + """Test that no_upload option skips upload phase""" + result = await install_skill_tool({ + "config_name": "react", + "auto_upload": False, + "dry_run": True + }) + + output = result[0].text + + # Should not show upload phase + assert "PHASE 5/5: Upload" not in output + assert "PHASE 4/5: Package" in output # Should still be 4/5 for fetch path + + @pytest.mark.asyncio + async def test_unlimited_option(self): + """Test that unlimited option is passed to scraper""" + result = await install_skill_tool({ + "config_path": "configs/react.json", + "unlimited": True, + "dry_run": True + }) + + output = result[0].text + + # Verify unlimited mode is indicated + assert "Unlimited mode: True" in output + + @pytest.mark.asyncio + async def test_custom_destination(self): + """Test custom destination directory""" + result = await install_skill_tool({ + "config_name": "react", + "destination": "/tmp/skills", + "dry_run": True + }) + + output = result[0].text + + # Verify custom destination + assert "Destination: /tmp/skills/" in output + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])