diff --git a/.github/workflows/test-vector-dbs.yml b/.github/workflows/test-vector-dbs.yml index 797b0f9..8879a58 100644 --- a/.github/workflows/test-vector-dbs.yml +++ b/.github/workflows/test-vector-dbs.yml @@ -45,12 +45,13 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install pytest pip install -e . - name: Run adaptor tests run: | echo "๐Ÿงช Testing $ADAPTOR_NAME adaptor..." - python -m pytest "tests/test_${ADAPTOR_NAME}_adaptor.py" -v --tb=short + python -m pytest "tests/test_adaptors/test_${ADAPTOR_NAME}_adaptor.py" -v --tb=short - name: Test adaptor integration run: | @@ -105,41 +106,3 @@ jobs: echo "๐Ÿงช Testing MCP vector database tools..." python -m pytest tests/test_mcp_vector_dbs.py -v --tb=short - test-week2-integration: - name: Week 2 Features Integration Test - runs-on: ubuntu-latest - needs: [test-adaptors, test-mcp-tools] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - - - name: Run Week 2 validation script - run: | - echo "๐ŸŽฏ Running Week 2 feature validation..." - python test_week2_features.py - - - name: Create test summary - run: | - echo "## ๐Ÿงช Vector Database Testing Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Adaptor Tests" >> $GITHUB_STEP_SUMMARY - echo "โœ… Weaviate adaptor - All tests passed" >> $GITHUB_STEP_SUMMARY - echo "โœ… Chroma adaptor - All tests passed" >> $GITHUB_STEP_SUMMARY - echo "โœ… FAISS adaptor - All tests passed" >> $GITHUB_STEP_SUMMARY - echo "โœ… Qdrant adaptor - All tests passed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### MCP Tools" >> $GITHUB_STEP_SUMMARY - echo "โœ… 8/8 MCP vector DB tests passed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Week 2 Integration" >> $GITHUB_STEP_SUMMARY - echo "โœ… 6/6 feature tests passed" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a746d68..7e57e23 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install "ruff>=0.15" mypy + pip install "ruff==0.15.8" mypy pip install -e . - name: Run ruff linter diff --git a/.gitignore b/.gitignore index 8569091..173fc22 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,11 @@ wheels/ .installed.cfg *.egg +# Environment variables (secrets) +.env +.env.local +.env.*.local + # Virtual Environment venv/ ENV/ diff --git a/configs/claude-code-unified.json b/configs/claude-code-unified.json new file mode 100644 index 0000000..c792b1b --- /dev/null +++ b/configs/claude-code-unified.json @@ -0,0 +1,261 @@ +{ + "name": "claude-code-unified", + "description": "Claude Code CLI - unified knowledge base combining official documentation and leaked source code analysis. Use for understanding Claude Code internals, architecture, tools, commands, IDE integrations, MCP, plugins, skills, and development workflows.", + "merge_mode": "claude-enhanced", + "workflows": [ + "complex-merge", + "cli-tooling", + "architecture-comprehensive", + "api-documentation", + "security-focus", + "ai-assistant-internals" + ], + "workflow_vars": { + "depth": "comprehensive", + "merge_strategy": "priority", + "source_priority_order": "official_docs,code,community" + }, + "sources": [ + { + "type": "documentation", + "base_url": "https://code.claude.com/docs/en/", + "start_urls": [ + "https://code.claude.com/docs/en/overview", + "https://code.claude.com/docs/en/quickstart", + "https://code.claude.com/docs/en/common-workflows", + "https://code.claude.com/docs/en/claude-code-on-the-web", + "https://code.claude.com/docs/en/desktop", + "https://code.claude.com/docs/en/chrome", + "https://code.claude.com/docs/en/vs-code", + "https://code.claude.com/docs/en/jetbrains", + "https://code.claude.com/docs/en/github-actions", + "https://code.claude.com/docs/en/gitlab-ci-cd", + "https://code.claude.com/docs/en/slack", + "https://code.claude.com/docs/en/sub-agents", + "https://code.claude.com/docs/en/plugins", + "https://code.claude.com/docs/en/discover-plugins", + "https://code.claude.com/docs/en/skills", + "https://code.claude.com/docs/en/output-styles", + "https://code.claude.com/docs/en/hooks-guide", + "https://code.claude.com/docs/en/headless", + "https://code.claude.com/docs/en/mcp", + "https://code.claude.com/docs/en/third-party-integrations", + "https://code.claude.com/docs/en/amazon-bedrock", + "https://code.claude.com/docs/en/google-vertex-ai", + "https://code.claude.com/docs/en/microsoft-foundry", + "https://code.claude.com/docs/en/network-config", + "https://code.claude.com/docs/en/llm-gateway", + "https://code.claude.com/docs/en/devcontainer", + "https://code.claude.com/docs/en/sandboxing", + "https://code.claude.com/docs/en/setup", + "https://code.claude.com/docs/en/iam", + "https://code.claude.com/docs/en/security", + "https://code.claude.com/docs/en/data-usage", + "https://code.claude.com/docs/en/monitoring-usage", + "https://code.claude.com/docs/en/costs", + "https://code.claude.com/docs/en/analytics", + "https://code.claude.com/docs/en/plugin-marketplaces", + "https://code.claude.com/docs/en/settings", + "https://code.claude.com/docs/en/terminal-config", + "https://code.claude.com/docs/en/model-config", + "https://code.claude.com/docs/en/memory", + "https://code.claude.com/docs/en/statusline", + "https://code.claude.com/docs/en/cli-reference", + "https://code.claude.com/docs/en/interactive-mode", + "https://code.claude.com/docs/en/slash-commands", + "https://code.claude.com/docs/en/checkpointing", + "https://code.claude.com/docs/en/hooks", + "https://code.claude.com/docs/en/plugins-reference", + "https://code.claude.com/docs/en/troubleshooting", + "https://code.claude.com/docs/en/legal-and-compliance" + ], + "selectors": { + "main_content": "#content-area, #content-container, article, main", + "title": "h1", + "code_blocks": "pre code" + }, + "url_patterns": { + "include": [ + "/docs/en/" + ], + "exclude": [ + "/docs/fr/", + "/docs/de/", + "/docs/it/", + "/docs/ja/", + "/docs/es/", + "/docs/ko/", + "/docs/zh-CN/", + "/docs/zh-TW/", + "/docs/ru/", + "/docs/id/", + "/docs/pt/", + "/changelog", + "github.com" + ] + }, + "categories": { + "getting_started": [ + "overview", + "quickstart", + "common-workflows" + ], + "ide_integrations": [ + "vs-code", + "jetbrains", + "desktop", + "chrome", + "claude-code-on-the-web", + "slack" + ], + "ci_cd": [ + "github-actions", + "gitlab-ci-cd" + ], + "building": [ + "sub-agents", + "subagent", + "plugins", + "discover-plugins", + "skills", + "output-styles", + "hooks-guide", + "headless", + "programmatic" + ], + "mcp": [ + "mcp", + "model-context-protocol" + ], + "deployment": [ + "third-party-integrations", + "amazon-bedrock", + "google-vertex-ai", + "microsoft-foundry", + "network-config", + "llm-gateway", + "devcontainer", + "sandboxing" + ], + "administration": [ + "setup", + "iam", + "security", + "data-usage", + "monitoring-usage", + "costs", + "analytics", + "plugin-marketplaces" + ], + "configuration": [ + "settings", + "terminal-config", + "model-config", + "memory", + "statusline" + ], + "reference": [ + "cli-reference", + "interactive-mode", + "slash-commands", + "checkpointing", + "hooks", + "plugins-reference" + ], + "troubleshooting": [ + "troubleshooting" + ], + "legal": [ + "legal-and-compliance" + ] + }, + "rate_limit": 0.5, + "max_pages": 250 + }, + { + "type": "local", + "path": "/Users/yusufkaraaslan/Github/cli-template", + "name": "claude-code-source", + "description": "Leaked Claude Code CLI TypeScript source code (main.tsx, tools, commands, components, Ink renderer, bridge, coordinator, plugins, skills, MCP, vim, voice, remote, server, memdir, tasks, state, schemas, entrypoints)", + "weight": 0.6, + "enhance_level": 3, + "include_code": true, + "file_patterns": [ + "*.ts", + "*.tsx", + "*.md", + "*.json" + ], + "skip_patterns": [ + ".git/", + "node_modules/", + "__pycache__/", + "*.map", + "*.js", + "dist/", + "build/" + ], + "categories": { + "core": [ + "main.tsx", + "commands.ts", + "tools.ts", + "Tool.ts", + "QueryEngine.ts", + "context.ts", + "cost-tracker.ts" + ], + "commands": [ + "commands/" + ], + "tools": [ + "tools/" + ], + "ui": [ + "components/", + "hooks/", + "screens/", + "ink/" + ], + "integrations": [ + "bridge/", + "coordinator/", + "remote/", + "server/", + "services/" + ], + "features": [ + "plugins/", + "skills/", + "vim/", + "voice/", + "tasks/", + "memdir/", + "state/", + "buddy/" + ], + "infrastructure": [ + "entrypoints/", + "migrations/", + "schemas/", + "query/", + "upstreamproxy/", + "native-ts/", + "outputStyles/", + "utils/", + "types/", + "constants/" + ] + }, + "analysis_features": { + "detect_patterns": true, + "extract_tests": true, + "build_guides": true, + "extract_config": true, + "build_api_reference": true, + "analyze_dependencies": true, + "detect_architecture": true + } + } + ] +} diff --git a/configs/unity-addressables.json b/configs/unity-addressables.json new file mode 100644 index 0000000..9b4bce7 --- /dev/null +++ b/configs/unity-addressables.json @@ -0,0 +1,125 @@ +{ + "name": "unity-addressables", + "description": "Unity Addressables asset management system - Use when implementing asset loading, remote content delivery, asset bundles, memory management, or dynamic asset loading in Unity projects.", + "version": "1.0.0", + "merge_mode": "claude-enhanced", + "base_url": "https://docs.unity3d.com/Packages/com.unity.addressables@2.3/manual/index.html", + "sources": [ + { + "type": "documentation", + "base_url": "https://docs.unity3d.com/Packages/com.unity.addressables@2.3/manual/index.html", + "browser": true, + "extract_api": true, + "selectors": { + "main_content": ".content-wrap, .section, article, main", + "title": "h1", + "code_blocks": "pre code" + }, + "url_patterns": { + "include": [ + "com.unity.addressables" + ], + "exclude": [ + "/changelog/", + "/license/" + ] + }, + "categories": { + "getting_started": [ + "index", + "getting-started", + "installation", + "quickstart", + "AddressableAssetsGettingStarted" + ], + "concepts": [ + "AddressableAssetsDevelopmentCycle", + "AddressableAssetsOverview", + "asset-references", + "labels", + "groups", + "profiles" + ], + "loading": [ + "load-assets", + "LoadingAddressableAssets", + "MemoryManagement", + "AsyncOperationHandle", + "UnloadingAddressableAssets", + "synchronous-addressables" + ], + "api": [ + "api", + "reference", + "Addressables", + "AddressablesAPI" + ], + "building": [ + "Builds", + "BuildLayoutReport", + "ContentUpdateWorkflow", + "AddressableAssetSettings", + "build-scripting" + ], + "advanced": [ + "remote-content-distribution", + "content-catalogs", + "diagnostic-tools", + "CustomOperations", + "TransformInternalId", + "ccd" + ] + }, + "rate_limit": 0.5 + }, + { + "type": "github", + "repo": "Unity-Technologies/Addressables-Sample", + "include_code": true, + "language": "C#", + "enable_codebase_analysis": true, + "code_analysis_depth": "deep", + "fetch_issues": false, + "fetch_changelog": false, + "fetch_releases": false, + "file_patterns": [ + "**/*.cs" + ] + } + ], + "analysis_features": { + "pattern_detection": true, + "test_extraction": true, + "how_to_guides": true, + "config_extraction": true, + "architecture_overview": true, + "api_reference": true, + "dependency_graph": true + }, + + "enhancement": { + "enabled": true, + "level": 3, + "mode": "LOCAL" + }, + + "chunking": { + "enabled": true, + "chunk_size": 1000, + "chunk_overlap": 200 + }, + + "output_formats": [ + "claude", + "markdown" + ], + + "metadata": { + "version": "2.3", + "framework": "unity", + "language": "csharp", + "tags": ["unity", "addressables", "asset-management", "asset-bundles", "content-delivery", "csharp"], + "documentation_url": "https://docs.unity3d.com/Packages/com.unity.addressables@latest/", + "repository_url": "https://github.com/Unity-Technologies/Addressables-Sample" + } +} diff --git a/configs/unity-dotween.json b/configs/unity-dotween.json new file mode 100644 index 0000000..58a15c9 --- /dev/null +++ b/configs/unity-dotween.json @@ -0,0 +1,128 @@ +{ + "name": "dotween", + "description": "DOTween (HOTween v2) animation engine for Unity - Use when implementing tweening animations, sequences, easing, UI animations, or any programmatic animation in Unity C# projects.", + "version": "1.0.0", + "merge_mode": "claude-enhanced", + "base_url": "https://dotween.demigiant.com/documentation.php", + "sources": [ + { + "type": "documentation", + "base_url": "https://dotween.demigiant.com/documentation.php", + "extract_api": true, + "selectors": { + "main_content": "#content, .documentation, article, main", + "title": "h1, h2", + "code_blocks": "pre code, .code" + }, + "url_patterns": { + "include": [ + "/documentation", + "/getstarted", + "/pro", + "/support" + ], + "exclude": [ + "/download", + "/credits" + ] + }, + "categories": { + "getting_started": [ + "getstarted", + "setup", + "installation", + "initialization" + ], + "core_api": [ + "DOTween", + "Tweener", + "Sequence", + "Tween", + "TweenParams", + "DOVirtual" + ], + "shortcuts": [ + "shortcuts", + "transform", + "material", + "rigidbody", + "camera", + "audio", + "light", + "spriterenderer" + ], + "easing": [ + "ease", + "easing", + "animationcurve", + "custom-ease" + ], + "sequences": [ + "sequence", + "append", + "insert", + "join", + "prepend", + "callbacks" + ], + "advanced": [ + "paths", + "blendable", + "DOTweenAnimation", + "DOTweenVisualManager", + "pro" + ] + }, + "rate_limit": 1.0 + }, + { + "type": "github", + "repo": "Demigiant/dotween", + "include_code": true, + "language": "C#", + "enable_codebase_analysis": true, + "code_analysis_depth": "deep", + "fetch_issues": true, + "max_issues": 50, + "fetch_changelog": false, + "fetch_releases": true + } + ], + "analysis_features": { + "pattern_detection": true, + "test_extraction": true, + "how_to_guides": true, + "config_extraction": true, + "architecture_overview": true, + "api_reference": true, + "dependency_graph": true + }, + + "enhancement": { + "enabled": true, + "level": 3, + "mode": "LOCAL", + "agent": "kimi", + "timeout": "unlimited" + }, + + "chunking": { + "enabled": true, + "chunk_size": 1000, + "chunk_overlap": 200 + }, + + "output_formats": [ + "claude", + "markdown" + ], + + "metadata": { + "version": "1.2", + "framework": "unity", + "language": "csharp", + "tags": ["unity", "dotween", "tweening", "animation", "easing", "csharp"], + "documentation_url": "https://dotween.demigiant.com/documentation.php", + "repository_url": "https://github.com/Demigiant/dotween" + } +} diff --git a/configs/unity-spine.json b/configs/unity-spine.json new file mode 100644 index 0000000..c598e42 --- /dev/null +++ b/configs/unity-spine.json @@ -0,0 +1,136 @@ +{ + "name": "spine-unity", + "description": "Spine 2D skeletal animation runtime for Unity - Use when implementing Spine animations, SkeletonAnimation components, skin management, or spine-unity integration in Unity projects.", + "version": "1.0.0", + "merge_mode": "claude-enhanced", + "base_url": "http://en.esotericsoftware.com/spine-unity", + "sources": [ + { + "type": "documentation", + "base_url": "http://en.esotericsoftware.com/spine-unity", + "extract_api": true, + "selectors": { + "main_content": "#wiki_page_content, .wiki-content, article, main", + "title": "h1", + "code_blocks": "pre code" + }, + "url_patterns": { + "include": [ + "/spine-unity", + "/spine-runtime", + "/spine-api", + "/spine-applying-animations", + "/spine-attachments", + "/spine-events", + "/spine-skeleton", + "/spine-skins", + "/spine-slots" + ], + "exclude": [ + "/spine-corona", + "/spine-cocos2d", + "/spine-godot", + "/spine-unreal", + "/spine-flutter", + "/spine-sfml", + "/spine-monogame", + "/spine-love", + "/spine-haxe", + "/spine-ts", + "/spine-phaser", + "/spine-pixi", + "/spine-lwjgl", + "/spine-libgdx", + "/spine-changelog" + ] + }, + "categories": { + "getting_started": [ + "Installation", + "Download", + "Assets", + "Runtime Documentation", + "FAQ" + ], + "components": [ + "Main Components", + "Utility Components", + "SkeletonAnimation", + "SkeletonGraphic", + "SkeletonMecanim", + "BoneFollower" + ], + "animation": [ + "Events", + "AnimationState", + "callback", + "Timeline", + "Examples" + ], + "rendering": [ + "Rendering", + "shader", + "material" + ], + "advanced": [ + "On-Demand Loading", + "UPM", + "Packages" + ] + }, + "rate_limit": 1.0 + }, + { + "type": "github", + "repo": "EsotericSoftware/spine-runtimes", + "include_code": true, + "language": "C#", + "enable_codebase_analysis": true, + "code_analysis_depth": "deep", + "fetch_issues": true, + "max_issues": 50, + "fetch_changelog": true, + "fetch_releases": true, + "file_patterns": [ + "spine-unity/Assets/Spine/**/*.cs", + "spine-unity/Assets/Spine Examples/**/*.cs", + "spine-csharp/src/**/*.cs" + ] + } + ], + "analysis_features": { + "pattern_detection": true, + "test_extraction": true, + "how_to_guides": true, + "config_extraction": true, + "architecture_overview": true, + "api_reference": true, + "dependency_graph": true + }, + + "enhancement": { + "enabled": true, + "level": 3, + "mode": "LOCAL" + }, + + "chunking": { + "enabled": true, + "chunk_size": 1000, + "chunk_overlap": 200 + }, + + "output_formats": [ + "claude", + "markdown" + ], + + "metadata": { + "version": "4.2", + "framework": "unity", + "language": "csharp", + "tags": ["unity", "spine", "2d-animation", "skeletal-animation", "csharp"], + "documentation_url": "http://en.esotericsoftware.com/spine-unity", + "repository_url": "https://github.com/EsotericSoftware/spine-runtimes" + } +} diff --git a/src/skill_seekers/cli/__init__.py b/src/skill_seekers/cli/__init__.py index 21806c2..7def83e 100644 --- a/src/skill_seekers/cli/__init__.py +++ b/src/skill_seekers/cli/__init__.py @@ -1,7 +1,7 @@ """Skill Seekers CLI tools package. This package provides command-line tools for converting documentation -websites into Claude AI skills. +websites into AI skills. Main modules: - doc_scraper: Main documentation scraping and skill building tool @@ -13,7 +13,7 @@ Main modules: - enhance_skill_local: AI-powered skill enhancement (local) - estimate_pages: Estimate page count before scraping - package_skill: Package skills into .zip files - - upload_skill: Upload skills to Claude + - upload_skill: Upload skills to target platform - utils: Shared utility functions """ diff --git a/src/skill_seekers/cli/adaptors/claude.py b/src/skill_seekers/cli/adaptors/claude.py index b8f97c3..ea9f78c 100644 --- a/src/skill_seekers/cli/adaptors/claude.py +++ b/src/skill_seekers/cli/adaptors/claude.py @@ -378,7 +378,7 @@ version: {metadata.version} client = anthropic.Anthropic(**client_kwargs) message = client.messages.create( - model="claude-sonnet-4-20250514", + model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"), max_tokens=4096, temperature=0.3, messages=[{"role": "user", "content": prompt}], diff --git a/src/skill_seekers/cli/agent_client.py b/src/skill_seekers/cli/agent_client.py new file mode 100644 index 0000000..0a842d9 --- /dev/null +++ b/src/skill_seekers/cli/agent_client.py @@ -0,0 +1,553 @@ +""" +Unified AI Client for Skill Seekers + +Centralizes all AI invocations (API and LOCAL mode) so that every enhancer +uses a single abstraction instead of hardcoding subprocess calls or model names. + +Supports: +- API mode: Anthropic, Moonshot/Kimi, Google Gemini, OpenAI (via adaptor pattern) +- LOCAL mode: Claude Code, Kimi Code, Codex, Copilot, OpenCode, custom agents + +Usage: + from skill_seekers.cli.agent_client import AgentClient + + client = AgentClient(mode="auto") + response = client.call("Analyze this code and return JSON") + + # Or with explicit agent + client = AgentClient(mode="local", agent="kimi") + response = client.call(prompt, timeout=300) + + # Static helpers + key, provider = AgentClient.detect_api_key() + model = AgentClient.get_model() +""" + +import logging +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Agent presets for LOCAL mode โ€” reused from enhance_skill_local.py pattern +AGENT_PRESETS = { + "claude": { + "display_name": "Claude Code", + "command": ["claude", "--dangerously-skip-permissions", "{prompt_file}"], + "version_check": ["claude", "--version"], + }, + "codex": { + "display_name": "OpenAI Codex CLI", + "command": ["codex", "exec", "--full-auto", "--skip-git-repo-check", "-"], + "version_check": ["codex", "--version"], + "uses_stdin": True, + }, + "copilot": { + "display_name": "GitHub Copilot CLI", + "command": ["gh", "copilot", "chat", "-"], + "version_check": ["gh", "copilot", "--version"], + "uses_stdin": True, + }, + "opencode": { + "display_name": "OpenCode CLI", + "command": ["opencode"], + "version_check": ["opencode", "--version"], + }, + "kimi": { + "display_name": "Kimi Code CLI", + "command": [ + "kimi", + "--print", + "--input-format", + "text", + "--work-dir", + "{cwd}", + ], + "version_check": ["kimi", "--version"], + "uses_stdin": True, + "parse_output": "kimi", # Needs special output parsing + }, +} + +# Default models per API provider +DEFAULT_MODELS = { + "anthropic": "claude-sonnet-4-20250514", + "moonshot": "moonshot-v1-auto", + "google": "gemini-2.0-flash", + "openai": "gpt-4o", +} + +# API key env var โ†’ provider mapping +API_KEY_MAP = { + "ANTHROPIC_API_KEY": "anthropic", + "ANTHROPIC_AUTH_TOKEN": "anthropic", + "MOONSHOT_API_KEY": "moonshot", + "GOOGLE_API_KEY": "google", + "OPENAI_API_KEY": "openai", +} + +DEFAULT_ENHANCE_TIMEOUT = 2700 # 45 minutes +UNLIMITED_TIMEOUT = 86400 # 24 hours (subprocess requires a finite number) + + +def get_default_timeout() -> int: + """Return default enhancement timeout in seconds. + + Priority: + 1. SKILL_SEEKER_ENHANCE_TIMEOUT environment variable + 2. DEFAULT_ENHANCE_TIMEOUT (45 minutes) + + Supports 'unlimited' or 0/negative values which map to UNLIMITED_TIMEOUT (24h). + """ + env_val = os.environ.get("SKILL_SEEKER_ENHANCE_TIMEOUT", "").strip().lower() + if env_val in ("unlimited", "none", "0"): + return UNLIMITED_TIMEOUT + try: + timeout = int(env_val) + if timeout <= 0: + return UNLIMITED_TIMEOUT + return timeout + except ValueError: + return DEFAULT_ENHANCE_TIMEOUT + + +# Provider โ†’ target platform mapping (for --target defaults) +PROVIDER_TARGET_MAP = { + "anthropic": "claude", + "moonshot": "kimi", + "google": "gemini", + "openai": "openai", +} + + +def normalize_agent_name(agent_name: str) -> str: + """Normalize agent name to canonical form.""" + if not agent_name: + return "claude" + normalized = agent_name.strip().lower() + aliases = { + "claude-code": "claude", + "claude_code": "claude", + "codex-cli": "codex", + "copilot-cli": "copilot", + "open-code": "opencode", + "open_code": "opencode", + "kimi-cli": "kimi", + "kimi_code": "kimi", + "kimi-code": "kimi", + } + return aliases.get(normalized, normalized) + + +class AgentClient: + """ + Unified AI client that routes to API or LOCAL agent based on configuration. + + All enhancers should use this instead of direct subprocess calls or API imports. + """ + + def __init__( + self, + mode: str = "auto", + agent: str | None = None, + api_key: str | None = None, + ): + """ + Initialize the agent client. + + Args: + mode: "auto" (detect from env), "api" (force API), "local" (force CLI agent) + agent: LOCAL mode agent name ("claude", "kimi", "codex", "copilot", "opencode", "custom") + Resolved from: arg โ†’ env SKILL_SEEKER_AGENT โ†’ "claude" + api_key: API key override. If None, auto-detected from env vars. + """ + # Resolve agent name + env_agent = os.environ.get("SKILL_SEEKER_AGENT", "").strip() + self.agent = normalize_agent_name(agent or env_agent or "claude") + self.agent_display = AGENT_PRESETS.get(self.agent, {}).get("display_name", self.agent) + + # Detect API key and provider + if api_key: + self.api_key = api_key + # Detect provider from key prefix or env vars + self.provider = self._detect_provider_from_key(api_key) + else: + self.api_key, self.provider = self.detect_api_key() + + # Determine mode (keep original for error handling decisions) + self._requested_mode = mode + self.mode = mode + if mode == "auto": + if self.api_key: + self.mode = "api" + else: + self.mode = "local" + + # Initialize API client if needed + self.client = None + if self.mode == "api" and self.api_key: + self.client = self._init_api_client() + + @staticmethod + def _detect_provider_from_key(api_key: str) -> str: + """Detect provider from API key prefix or fall back to env var check.""" + if api_key.startswith("sk-ant-"): + return "anthropic" + if api_key.startswith("sk-"): + # Could be OpenAI or Moonshot โ€” check env vars + if os.environ.get("MOONSHOT_API_KEY", "").strip() == api_key: + return "moonshot" + return "openai" + if api_key.startswith("AIza"): + return "google" + # Default: check which env var matches + for env_var, provider in API_KEY_MAP.items(): + if os.environ.get(env_var, "").strip() == api_key: + return provider + return "anthropic" # Safe fallback + + def _init_api_client(self): + """Initialize the API client based on detected provider.""" + try: + if self.provider == "anthropic": + import anthropic + + kwargs = {"api_key": self.api_key} + base_url = os.environ.get("ANTHROPIC_BASE_URL") + if base_url: + kwargs["base_url"] = base_url + return anthropic.Anthropic(**kwargs) + elif self.provider == "moonshot": + import anthropic + + return anthropic.Anthropic( + api_key=self.api_key, + base_url="https://api.moonshot.cn/v1", + ) + elif self.provider == "openai": + from openai import OpenAI + + return OpenAI(api_key=self.api_key) + elif self.provider == "google": + import google.generativeai as genai + + genai.configure(api_key=self.api_key) + return genai + except ImportError as e: + logger.info(f"{self.provider} SDK not installed, falling back to LOCAL mode: {e}") + self.mode = "local" + except Exception as e: + if self._requested_mode == "api": + raise RuntimeError(f"Failed to initialize {self.provider} API client: {e}") from e + logger.error(f"Failed to initialize {self.provider} API client: {e}") + self.mode = "local" + return None + + def call( + self, + prompt: str, + max_tokens: int = 4096, + timeout: int | None = None, + output_file: str | Path | None = None, + cwd: str | Path | None = None, + ) -> str | None: + """ + Call the AI agent (API or LOCAL mode). + + Args: + prompt: The prompt to send + max_tokens: Max response tokens (API mode only) + timeout: Timeout in seconds (default from SKILL_SEEKER_ENHANCE_TIMEOUT or 2700 = 45m) + output_file: Path for agent to write output (LOCAL mode, some agents) + cwd: Working directory for LOCAL mode subprocess + + Returns: + Response text, or None on failure + """ + if timeout is None: + timeout = get_default_timeout() + + if self.mode == "api": + return self._call_api(prompt, max_tokens) + elif self.mode == "local": + return self._call_local(prompt, timeout, output_file, cwd) + return None + + def _call_api(self, prompt: str, max_tokens: int = 4096) -> str | None: + """Call via API using the detected provider.""" + if not self.client: + return None + + model = self.get_model(self.provider) + + try: + if self.provider in ("anthropic", "moonshot"): + response = self.client.messages.create( + model=model, + max_tokens=max_tokens, + messages=[{"role": "user", "content": prompt}], + timeout=120, + ) + return response.content[0].text + + elif self.provider == "openai": + response = self.client.chat.completions.create( + model=model, + max_tokens=max_tokens, + messages=[{"role": "user", "content": prompt}], + timeout=120, + ) + return response.choices[0].message.content + + elif self.provider == "google": + gmodel = self.client.GenerativeModel(model) + response = gmodel.generate_content(prompt) + return response.text + + except Exception as e: + error_type = type(e).__name__ + error_module = type(e).__module__ or "" + + # Rate limit errors + if "rate" in error_type.lower() or "ratelimit" in error_type.lower(): + logger.error( + f"{self.provider} API rate limited: {e}. " + "Retry after waiting or reduce request frequency." + ) + return None + + # Auth / permission errors + if "auth" in error_type.lower() or "permission" in error_type.lower(): + logger.error( + f"{self.provider} API authentication failed: {e}. " + "Check your API key is valid and has sufficient permissions." + ) + return None + + # Timeout / connection errors + if ( + any( + kw in error_type.lower() + for kw in ("timeout", "connect", "connection", "network") + ) + or "httpx" in error_module.lower() + ): + logger.error( + f"{self.provider} API connection error: {e}. " + "Check your network connectivity and try again." + ) + return None + + # All other errors + logger.error(f"{self.provider} API call failed: {e}") + return None + + def _call_local( + self, + prompt: str, + timeout: int | None = None, + output_file: str | Path | None = None, + cwd: str | Path | None = None, + ) -> str | None: + """Call via LOCAL CLI agent using agent presets.""" + if timeout is None: + timeout = get_default_timeout() + # Handle custom agent from env var + if self.agent == "custom": + custom_cmd = os.environ.get("SKILL_SEEKER_AGENT_CMD", "").strip() + if not custom_cmd: + logger.warning("โš ๏ธ Custom agent selected but SKILL_SEEKER_AGENT_CMD not set") + return None + preset = { + "display_name": "Custom Agent", + "command": custom_cmd.split(), + "version_check": custom_cmd.split()[:1] + ["--version"], + } + else: + preset = AGENT_PRESETS.get(self.agent) + if not preset: + logger.warning(f"โš ๏ธ Unknown agent: {self.agent}") + return None + + try: + with tempfile.TemporaryDirectory(prefix="agent_client_") as temp_dir: + temp_path = Path(temp_dir) + prompt_file = temp_path / "prompt.md" + resp_file = Path(output_file) if output_file else (temp_path / "response.json") + + # Only append output file instruction when caller explicitly requests it + full_prompt = prompt + if output_file: + full_prompt += f"\n\nWrite your response to: {resp_file}\n" + + prompt_file.write_text(full_prompt) + + # Build command from preset + cmd = [] + for part in preset["command"]: + part = part.replace("{prompt_file}", str(prompt_file)) + part = part.replace("{cwd}", str(cwd or temp_path)) + part = part.replace("{skill_dir}", str(cwd or temp_path)) + cmd.append(part) + + # Execute โ€” pipe stdin for agents that read from it (e.g., codex) + stdin_input = full_prompt if preset.get("uses_stdin") else None + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd or temp_path), + input=stdin_input, + ) + + if result.returncode != 0: + logger.error(f"{self.agent_display} returned error code {result.returncode}") + if result.stderr and result.stderr.strip(): + logger.error(f"{self.agent_display} stderr: {result.stderr.strip()}") + + # Try to read output file first + resp_path = Path(resp_file) + if resp_path.exists(): + return resp_path.read_text(encoding="utf-8") + + # Try any JSON file in temp dir + for json_file in temp_path.glob("*.json"): + if json_file.name != "prompt.json": + return json_file.read_text(encoding="utf-8") + + # Fall back to stdout (with agent-specific parsing) + if result.stdout and result.stdout.strip(): + stdout = result.stdout.strip() + parser = preset.get("parse_output") + if parser == "kimi": + stdout = self._parse_kimi_output(stdout) + return stdout + + logger.warning(f"โš ๏ธ No output from {self.agent_display}") + return None + + except subprocess.TimeoutExpired: + logger.warning(f"โš ๏ธ {self.agent_display} timeout ({timeout}s)") + return None + except FileNotFoundError: + logger.warning( + f"โš ๏ธ {self.agent_display} CLI not found. " + f"Install it or set SKILL_SEEKER_AGENT to a different agent." + ) + return None + except Exception as e: + logger.error(f"{self.agent_display} error: {e}") + return None + + @staticmethod + def _parse_kimi_output(raw_output: str) -> str: + """Parse Kimi CLI --print mode output to extract text content. + + Kimi's --print mode outputs structured lines like: + TurnBegin(...) + StepBegin(...) + TextPart(type='text', text='actual content') + ThinkPart(type='think', think='...') + + This extracts the text= values from TextPart lines. + """ + import re + + text_parts = re.findall(r"TextPart\(type='text', text='(.+?)'\)", raw_output) + if text_parts: + return "\n".join(text_parts) + # Fallback: return raw if no TextPart found + return raw_output + + def is_available(self) -> bool: + """Check if the configured agent/API is available.""" + if self.mode == "api": + return self.client is not None + + # LOCAL mode: check if CLI exists + preset = AGENT_PRESETS.get(self.agent) + if not preset: + return False + + version_cmd = preset.get("version_check") + if not version_cmd: + return shutil.which(preset["command"][0]) is not None + + try: + result = subprocess.run( + version_cmd, + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + @staticmethod + def detect_api_key() -> tuple[str | None, str | None]: + """ + Detect API key from environment variables. + + Returns: + (api_key, provider) tuple. Provider is "anthropic", "moonshot", "google", or "openai". + Returns (None, None) if no key found. + """ + for env_var, provider in API_KEY_MAP.items(): + key = os.environ.get(env_var, "").strip() + if key: + return key, provider + return None, None + + @staticmethod + def get_model(provider: str = "anthropic") -> str: + """ + Get the model name for a provider. + + Checks SKILL_SEEKER_MODEL env var first, then provider-specific env vars, + then falls back to defaults. + """ + # Global override + global_model = os.environ.get("SKILL_SEEKER_MODEL", "").strip() + if global_model: + return global_model + + # Provider-specific env vars + provider_env_map = { + "anthropic": "ANTHROPIC_MODEL", + "moonshot": "MOONSHOT_MODEL", + "google": "GOOGLE_MODEL", + "openai": "OPENAI_MODEL", + } + env_var = provider_env_map.get(provider) + if env_var: + model = os.environ.get(env_var, "").strip() + if model: + return model + + return DEFAULT_MODELS.get(provider, "claude-sonnet-4-20250514") + + @staticmethod + def detect_default_target() -> str: + """ + Auto-detect the default --target platform from available API keys. + + Returns platform name: "claude", "kimi", "gemini", "openai", or "markdown" (fallback). + """ + _, provider = AgentClient.detect_api_key() + if provider: + return PROVIDER_TARGET_MAP.get(provider, "markdown") + return "markdown" + + def log_mode(self) -> None: + """Log the current mode and agent for UX.""" + if self.mode == "api": + logger.info(f"โœ… AI enhancement enabled (using {self.provider} API)") + elif self.mode == "local": + logger.info(f"โœ… AI enhancement enabled (using LOCAL mode - {self.agent_display})") + else: + logger.info("โ„น๏ธ AI enhancement disabled") diff --git a/src/skill_seekers/cli/ai_enhancer.py b/src/skill_seekers/cli/ai_enhancer.py index 9fbfcd8..d1e3dfa 100644 --- a/src/skill_seekers/cli/ai_enhancer.py +++ b/src/skill_seekers/cli/ai_enhancer.py @@ -13,23 +13,17 @@ Features: - Identifies best practices Modes: -- API mode: Uses Claude API (requires ANTHROPIC_API_KEY) -- LOCAL mode: Uses Claude Code CLI (no API key needed, uses your Claude Max plan) +- API mode: Uses AI API (Anthropic, Moonshot/Kimi, Google, OpenAI) +- LOCAL mode: Uses AI coding agent CLI (Claude Code, Kimi, Codex, Copilot, OpenCode) - AUTO mode: Tries API first, falls back to LOCAL -Credits: -- Uses Claude AI (Anthropic) for analysis -- Graceful degradation if API unavailable +Uses AgentClient for all AI invocations โ€” fully agent-agnostic. """ import json import logging -import os -import subprocess -import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass -from pathlib import Path logger = logging.getLogger(__name__) @@ -55,24 +49,25 @@ class AIAnalysis: class AIEnhancer: - """Base class for AI enhancement""" + """Base class for AI enhancement โ€” delegates to AgentClient for all AI calls.""" - def __init__(self, api_key: str | None = None, enabled: bool = True, mode: str = "auto"): + def __init__( + self, + api_key: str | None = None, + enabled: bool = True, + mode: str = "auto", + agent: str | None = None, + ): """ Initialize AI enhancer. Args: - api_key: Anthropic API key (uses ANTHROPIC_API_KEY env if None) + api_key: API key (auto-detected from env if None) enabled: Enable AI enhancement (default: True) mode: Enhancement mode - "auto" (default), "api", or "local" - - "auto": Use API if key available, otherwise fall back to LOCAL - - "api": Force API mode (fails if no key) - - "local": Use Claude Code CLI (no API key needed) + agent: Local CLI agent name (e.g., "kimi", "claude") """ self.enabled = enabled - self.mode = mode - self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") - self.client = None # Get settings from config (with defaults) if CONFIG_AVAILABLE: @@ -80,161 +75,35 @@ class AIEnhancer: self.local_batch_size = config.get_local_batch_size() self.local_parallel_workers = config.get_local_parallel_workers() else: - self.local_batch_size = 20 # Default - self.local_parallel_workers = 3 # Default + self.local_batch_size = 20 + self.local_parallel_workers = 3 - # Determine actual mode - if mode == "auto": - if self.api_key: - self.mode = "api" + # Initialize AgentClient + from skill_seekers.cli.agent_client import AgentClient + + self._agent = AgentClient(mode=mode, api_key=api_key, agent=agent) + self.mode = self._agent.mode + self.client = self._agent.client # For backward compatibility + + if self.enabled: + if self._agent.is_available(): + self._agent.log_mode() else: - # Fall back to LOCAL mode (Claude Code CLI) - self.mode = "local" - logger.info("โ„น๏ธ No API key found, using LOCAL mode (Claude Code CLI)") - - if self.mode == "api" and self.enabled: - try: - import anthropic - - # Support custom base_url for GLM-4.7 and other Claude-compatible APIs - client_kwargs = {"api_key": self.api_key} - base_url = os.environ.get("ANTHROPIC_BASE_URL") - if base_url: - client_kwargs["base_url"] = base_url - logger.info(f"โœ… Using custom API base URL: {base_url}") - self.client = anthropic.Anthropic(**client_kwargs) - logger.info("โœ… AI enhancement enabled (using Claude API)") - except ImportError: - logger.warning("โš ๏ธ anthropic package not installed, falling back to LOCAL mode") - self.mode = "local" - except Exception as e: logger.warning( - f"โš ๏ธ Failed to initialize API client: {e}, falling back to LOCAL mode" + f"โš ๏ธ {self._agent.agent_display} not available. AI enhancement disabled." ) - self.mode = "local" - - if self.mode == "local" and self.enabled: - # Verify Claude CLI is available - if self._check_claude_cli(): - logger.info("โœ… AI enhancement enabled (using LOCAL mode - Claude Code CLI)") - else: - logger.warning("โš ๏ธ Claude Code CLI not found. AI enhancement disabled.") - logger.warning(" Install with: npm install -g @anthropic-ai/claude-code") self.enabled = False - def _check_claude_cli(self) -> bool: - """Check if Claude Code CLI is available""" - try: - result = subprocess.run( - ["claude", "--version"], - capture_output=True, - text=True, - timeout=5, - ) - return result.returncode == 0 - except (FileNotFoundError, subprocess.TimeoutExpired): - return False - def _call_claude(self, prompt: str, max_tokens: int = 1000) -> str | None: - """Call Claude (API or LOCAL mode) with error handling""" - if self.mode == "api": - return self._call_claude_api(prompt, max_tokens) - elif self.mode == "local": - return self._call_claude_local(prompt) - return None + """Call AI agent (API or LOCAL mode) with error handling. - def _call_claude_api(self, prompt: str, max_tokens: int = 1000) -> str | None: - """Call Claude API""" - if not self.client: - return None + Named _call_claude for backward compatibility โ€” delegates to AgentClient. + """ + return self._agent.call(prompt, max_tokens=max_tokens) - try: - response = self.client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=max_tokens, - messages=[{"role": "user", "content": prompt}], - ) - return response.content[0].text - except Exception as e: - logger.warning(f"โš ๏ธ AI API call failed: {e}") - return None - - def _call_claude_local(self, prompt: str) -> str | None: - """Call Claude using LOCAL mode (Claude Code CLI)""" - try: - # Create a temporary directory for this enhancement - with tempfile.TemporaryDirectory(prefix="ai_enhance_") as temp_dir: - temp_path = Path(temp_dir) - - # Create prompt file - prompt_file = temp_path / "prompt.md" - output_file = temp_path / "response.json" - - # Write prompt with instructions to output JSON - full_prompt = f"""# AI Analysis Task - -IMPORTANT: You MUST write your response as valid JSON to this file: -{output_file} - -## Task - -{prompt} - -## Instructions - -1. Analyze the input carefully -2. Generate the JSON response as specified -3. Use the Write tool to save the JSON to: {output_file} -4. The JSON must be valid and parseable - -DO NOT include any explanation - just write the JSON file. -""" - prompt_file.write_text(full_prompt) - - # Run Claude CLI - result = subprocess.run( - ["claude", "--dangerously-skip-permissions", str(prompt_file)], - capture_output=True, - text=True, - timeout=120, # 2 minute timeout per call - cwd=str(temp_path), - ) - - if result.returncode != 0: - logger.warning(f"โš ๏ธ Claude CLI returned error: {result.returncode}") - return None - - # Read output file - if output_file.exists(): - response_text = output_file.read_text() - # Try to extract JSON from response - try: - # Validate it's valid JSON - json.loads(response_text) - return response_text - except json.JSONDecodeError: - # Try to find JSON in the response - import re - - json_match = re.search(r"\[[\s\S]*\]|\{[\s\S]*\}", response_text) - if json_match: - return json_match.group() - logger.warning("โš ๏ธ Could not parse JSON from LOCAL response") - return None - else: - # Look for any JSON file created - for json_file in temp_path.glob("*.json"): - if json_file.name != "prompt.json": - return json_file.read_text() - logger.warning("โš ๏ธ No output file from LOCAL mode") - return None - - except subprocess.TimeoutExpired: - logger.warning("โš ๏ธ Claude CLI timeout (2 minutes)") - return None - except Exception as e: - logger.warning(f"โš ๏ธ LOCAL mode error: {e}") - return None + def call(self, prompt: str, max_tokens: int = 1000) -> str | None: + """Call AI agent โ€” preferred method name over _call_claude.""" + return self._agent.call(prompt, max_tokens=max_tokens) class PatternEnhancer(AIEnhancer): diff --git a/src/skill_seekers/cli/architectural_pattern_detector.py b/src/skill_seekers/cli/architectural_pattern_detector.py index e4d86af..78e40d4 100644 --- a/src/skill_seekers/cli/architectural_pattern_detector.py +++ b/src/skill_seekers/cli/architectural_pattern_detector.py @@ -139,12 +139,13 @@ class ArchitecturalPatternDetector: "Laravel": ["laravel", "illuminate", "artisan", "app/Http/Controllers", "app/Models"], } - def __init__(self, enhance_with_ai: bool = True): + def __init__(self, enhance_with_ai: bool = True, agent: str | None = None): """ Initialize detector. Args: enhance_with_ai: Enable AI enhancement for detected patterns (C3.6) + agent: Local CLI agent name (e.g., "kimi", "claude") """ self.enhance_with_ai = enhance_with_ai self.ai_enhancer = None @@ -153,7 +154,7 @@ class ArchitecturalPatternDetector: try: from skill_seekers.cli.ai_enhancer import AIEnhancer - self.ai_enhancer = AIEnhancer() + self.ai_enhancer = AIEnhancer(agent=agent) except Exception as e: logger.warning(f"โš ๏ธ Failed to initialize AI enhancer: {e}") self.enhance_with_ai = False diff --git a/src/skill_seekers/cli/arguments/asciidoc.py b/src/skill_seekers/cli/arguments/asciidoc.py index 2ea6e30..bb2285f 100644 --- a/src/skill_seekers/cli/arguments/asciidoc.py +++ b/src/skill_seekers/cli/arguments/asciidoc.py @@ -57,8 +57,8 @@ def add_asciidoc_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for AsciiDoc), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # AsciiDoc-specific args diff --git a/src/skill_seekers/cli/arguments/chat.py b/src/skill_seekers/cli/arguments/chat.py index 563f162..5a6d783 100644 --- a/src/skill_seekers/cli/arguments/chat.py +++ b/src/skill_seekers/cli/arguments/chat.py @@ -91,8 +91,8 @@ def add_chat_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for Chat), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # Chat-specific args diff --git a/src/skill_seekers/cli/arguments/common.py b/src/skill_seekers/cli/arguments/common.py index f1ed246..853bcee 100644 --- a/src/skill_seekers/cli/arguments/common.py +++ b/src/skill_seekers/cli/arguments/common.py @@ -55,7 +55,7 @@ COMMON_ARGUMENTS: dict[str, dict[str, Any]] = { "help": ( "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled, 1=SKILL.md only, 2=+architecture/config (default), 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), otherwise LOCAL (AI coding agent)" ), "metavar": "LEVEL", }, @@ -64,10 +64,27 @@ COMMON_ARGUMENTS: dict[str, dict[str, Any]] = { "flags": ("--api-key",), "kwargs": { "type": str, - "help": "Anthropic API key for --enhance (or set ANTHROPIC_API_KEY env var)", + "help": "API key for enhancement (ANTHROPIC_API_KEY, GOOGLE_API_KEY, OPENAI_API_KEY, MOONSHOT_API_KEY)", "metavar": "KEY", }, }, + "agent": { + "flags": ("--agent",), + "kwargs": { + "type": str, + "choices": ["claude", "codex", "copilot", "opencode", "kimi", "custom"], + "help": "Local coding agent for enhancement (default: AI agent from SKILL_SEEKER_AGENT env var)", + "metavar": "AGENT", + }, + }, + "agent_cmd": { + "flags": ("--agent-cmd",), + "kwargs": { + "type": str, + "help": "Override agent command template (advanced)", + "metavar": "CMD", + }, + }, "doc_version": { "flags": ("--doc-version",), "kwargs": { diff --git a/src/skill_seekers/cli/arguments/confluence.py b/src/skill_seekers/cli/arguments/confluence.py index f65673c..d12923d 100644 --- a/src/skill_seekers/cli/arguments/confluence.py +++ b/src/skill_seekers/cli/arguments/confluence.py @@ -98,8 +98,8 @@ def add_confluence_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for Confluence), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # Confluence-specific args diff --git a/src/skill_seekers/cli/arguments/create.py b/src/skill_seekers/cli/arguments/create.py index 45d97e9..b610a41 100644 --- a/src/skill_seekers/cli/arguments/create.py +++ b/src/skill_seekers/cli/arguments/create.py @@ -57,7 +57,7 @@ UNIVERSAL_ARGUMENTS: dict[str, dict[str, Any]] = { "help": ( "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled, 1=SKILL.md only, 2=+architecture/config (default), 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), otherwise LOCAL (AI coding agent)" ), "metavar": "LEVEL", }, @@ -66,7 +66,7 @@ UNIVERSAL_ARGUMENTS: dict[str, dict[str, Any]] = { "flags": ("--api-key",), "kwargs": { "type": str, - "help": "Anthropic API key (or set ANTHROPIC_API_KEY env var)", + "help": "API key for enhancement (ANTHROPIC_API_KEY, GOOGLE_API_KEY, OPENAI_API_KEY, MOONSHOT_API_KEY)", "metavar": "KEY", }, }, @@ -162,11 +162,30 @@ UNIVERSAL_ARGUMENTS: dict[str, dict[str, Any]] = { "metavar": "VERSION", }, }, + # Agent selection for enhancement (added in v3.4.0 - multi-agent support) + "agent": { + "flags": ("--agent",), + "kwargs": { + "type": str, + "choices": ["claude", "codex", "copilot", "opencode", "kimi", "custom"], + "help": "Local coding agent for enhancement (default: AI agent from SKILL_SEEKER_AGENT env var)", + "metavar": "AGENT", + }, + }, + "agent_cmd": { + "flags": ("--agent-cmd",), + "kwargs": { + "type": str, + "help": "Override agent command template (advanced)", + "metavar": "CMD", + }, + }, } # Merge RAG arguments from common.py into universal arguments UNIVERSAL_ARGUMENTS.update(RAG_ARGUMENTS) + # ============================================================================= # TIER 2: SOURCE-SPECIFIC ARGUMENTS # ============================================================================= @@ -376,6 +395,43 @@ LOCAL_ARGUMENTS: dict[str, dict[str, Any]] = { "help": "Skip documentation extraction", }, }, + "skip_api_reference": { + "flags": ("--skip-api-reference",), + "kwargs": { + "action": "store_true", + "help": "Skip API reference generation", + }, + }, + "skip_dependency_graph": { + "flags": ("--skip-dependency-graph",), + "kwargs": { + "action": "store_true", + "help": "Skip dependency graph analysis", + }, + }, + "skip_config_patterns": { + "flags": ("--skip-config-patterns",), + "kwargs": { + "action": "store_true", + "help": "Skip configuration pattern extraction", + }, + }, + "no_comments": { + "flags": ("--no-comments",), + "kwargs": { + "action": "store_true", + "help": "Skip comment extraction from source code", + }, + }, + "depth": { + "flags": ("--depth",), + "kwargs": { + "type": str, + "choices": ["surface", "deep", "full"], + "help": "Analysis depth (deprecated, use --preset instead)", + "metavar": "LEVEL", + }, + }, } # PDF specific (from pdf.py) @@ -431,6 +487,13 @@ EPUB_ARGUMENTS: dict[str, dict[str, Any]] = { # Video specific (from video.py) VIDEO_ARGUMENTS: dict[str, dict[str, Any]] = { + "setup": { + "flags": ("--setup",), + "kwargs": { + "action": "store_true", + "help": "Auto-detect GPU and install video dependencies", + }, + }, "video_url": { "flags": ("--video-url",), "kwargs": { @@ -535,13 +598,14 @@ VIDEO_ARGUMENTS: dict[str, dict[str, Any]] = { } # Multi-source config specific (from unified_scraper.py) +# Note: --fresh is in WEB_ARGUMENTS, shared with config sources via dynamic forwarding CONFIG_ARGUMENTS: dict[str, dict[str, Any]] = { "merge_mode": { "flags": ("--merge-mode",), "kwargs": { "type": str, - "choices": ["rule-based", "claude-enhanced"], - "help": "Override merge mode from config (rule-based or claude-enhanced)", + "choices": ["rule-based", "ai-enhanced", "claude-enhanced"], + "help": "Override merge mode from config (rule-based or ai-enhanced). 'claude-enhanced' accepted as alias.", "metavar": "MODE", }, }, @@ -677,6 +741,14 @@ CHAT_ARGUMENTS: dict[str, dict[str, Any]] = { # Hidden from default help, shown only with --help-advanced ADVANCED_ARGUMENTS: dict[str, dict[str, Any]] = { + "from_json": { + "flags": ("--from-json",), + "kwargs": { + "type": str, + "help": "Build skill from pre-extracted JSON data (skip scraping). Supported by: PDF, Video, Jupyter, HTML, OpenAPI, AsciiDoc, PPTX, RSS, Manpage, Confluence, Notion, Chat.", + "metavar": "PATH", + }, + }, "no_rate_limit": { "flags": ("--no-rate-limit",), "kwargs": { diff --git a/src/skill_seekers/cli/arguments/enhance.py b/src/skill_seekers/cli/arguments/enhance.py index 01389da..7d1d1d4 100644 --- a/src/skill_seekers/cli/arguments/enhance.py +++ b/src/skill_seekers/cli/arguments/enhance.py @@ -22,12 +22,12 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = { "flags": ("--target",), "kwargs": { "type": str, - "choices": ["claude", "gemini", "openai"], + "choices": ["claude", "gemini", "openai", "kimi"], "help": ( "AI platform for enhancement (uses API mode). " "Auto-detected from env vars if not specified: " - "ANTHROPIC_API_KEY->claude, GOOGLE_API_KEY->gemini, OPENAI_API_KEY->openai. " - "Falls back to LOCAL mode (Claude Code CLI) when no API keys are found." + "ANTHROPIC_API_KEY->claude, GOOGLE_API_KEY->gemini, OPENAI_API_KEY->openai, MOONSHOT_API_KEY->kimi. " + "Falls back to LOCAL mode (AI coding agent) when no API keys are found." ), "metavar": "PLATFORM", }, @@ -38,7 +38,7 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = { "type": str, "help": ( "API key for the target platform " - "(or set ANTHROPIC_API_KEY / GOOGLE_API_KEY / OPENAI_API_KEY)" + "(or set ANTHROPIC_API_KEY / GOOGLE_API_KEY / OPENAI_API_KEY / MOONSHOT_API_KEY)" ), "metavar": "KEY", }, @@ -55,8 +55,8 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = { "flags": ("--agent",), "kwargs": { "type": str, - "choices": ["claude", "codex", "copilot", "opencode", "custom"], - "help": "Local coding agent to use (default: claude or SKILL_SEEKER_AGENT)", + "choices": ["claude", "codex", "copilot", "opencode", "kimi", "custom"], + "help": "Local coding agent to use (default: AI agent from SKILL_SEEKER_AGENT env var)", "metavar": "AGENT", }, }, @@ -101,8 +101,11 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = { "flags": ("--timeout",), "kwargs": { "type": int, - "default": 600, - "help": "Timeout in seconds (default: 600)", + "default": None, # Resolved at runtime via get_default_timeout() + "help": ( + "Timeout in seconds " + "(default: 45 minutes, set SKILL_SEEKER_ENHANCE_TIMEOUT to override)" + ), "metavar": "SECONDS", }, }, diff --git a/src/skill_seekers/cli/arguments/epub.py b/src/skill_seekers/cli/arguments/epub.py index d41eda4..366cb35 100644 --- a/src/skill_seekers/cli/arguments/epub.py +++ b/src/skill_seekers/cli/arguments/epub.py @@ -56,7 +56,7 @@ def add_epub_arguments(parser: argparse.ArgumentParser) -> None: action.help = ( "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for EPUB), 1=SKILL.md only, 2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), otherwise LOCAL (AI coding agent)" ) # EPUB-specific args diff --git a/src/skill_seekers/cli/arguments/html.py b/src/skill_seekers/cli/arguments/html.py index 56ee554..3ece282 100644 --- a/src/skill_seekers/cli/arguments/html.py +++ b/src/skill_seekers/cli/arguments/html.py @@ -57,8 +57,8 @@ def add_html_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for HTML), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # HTML-specific args diff --git a/src/skill_seekers/cli/arguments/jupyter.py b/src/skill_seekers/cli/arguments/jupyter.py index f4f0bbd..2335aef 100644 --- a/src/skill_seekers/cli/arguments/jupyter.py +++ b/src/skill_seekers/cli/arguments/jupyter.py @@ -57,8 +57,8 @@ def add_jupyter_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for Jupyter), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # Jupyter-specific args diff --git a/src/skill_seekers/cli/arguments/manpage.py b/src/skill_seekers/cli/arguments/manpage.py index f867c35..5f17e6d 100644 --- a/src/skill_seekers/cli/arguments/manpage.py +++ b/src/skill_seekers/cli/arguments/manpage.py @@ -73,8 +73,8 @@ def add_manpage_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for ManPage), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # ManPage-specific args diff --git a/src/skill_seekers/cli/arguments/notion.py b/src/skill_seekers/cli/arguments/notion.py index b48f161..8b059c1 100644 --- a/src/skill_seekers/cli/arguments/notion.py +++ b/src/skill_seekers/cli/arguments/notion.py @@ -90,8 +90,8 @@ def add_notion_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for Notion), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # Notion-specific args diff --git a/src/skill_seekers/cli/arguments/openapi.py b/src/skill_seekers/cli/arguments/openapi.py index ed0ffa5..b31f359 100644 --- a/src/skill_seekers/cli/arguments/openapi.py +++ b/src/skill_seekers/cli/arguments/openapi.py @@ -65,8 +65,8 @@ def add_openapi_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for OpenAPI), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # OpenAPI-specific args diff --git a/src/skill_seekers/cli/arguments/package.py b/src/skill_seekers/cli/arguments/package.py index ad91ae8..51e4596 100644 --- a/src/skill_seekers/cli/arguments/package.py +++ b/src/skill_seekers/cli/arguments/package.py @@ -43,6 +43,14 @@ PACKAGE_ARGUMENTS: dict[str, dict[str, Any]] = { "claude", "gemini", "openai", + "kimi", + "minimax", + "opencode", + "deepseek", + "qwen", + "openrouter", + "together", + "fireworks", "markdown", "langchain", "llama-index", @@ -53,8 +61,8 @@ PACKAGE_ARGUMENTS: dict[str, dict[str, Any]] = { "qdrant", "pinecone", ], - "default": "claude", - "help": "Target LLM platform (default: claude)", + "default": None, + "help": "Target LLM platform (auto-detected from API keys, or 'markdown' if none set)", "metavar": "PLATFORM", }, }, @@ -133,6 +141,32 @@ PACKAGE_ARGUMENTS: dict[str, dict[str, Any]] = { "help": "Allow code block splitting (default: code blocks preserved)", }, }, + # Marketplace options + "marketplace": { + "flags": ("--marketplace",), + "kwargs": { + "type": str, + "default": None, + "help": "Publish to registered marketplace after packaging (use add_marketplace to register)", + "metavar": "NAME", + }, + }, + "marketplace_category": { + "flags": ("--marketplace-category",), + "kwargs": { + "type": str, + "default": "development", + "help": "Plugin category in marketplace (default: development)", + "metavar": "CAT", + }, + }, + "create_branch": { + "flags": ("--create-branch",), + "kwargs": { + "action": "store_true", + "help": "Create a feature branch in marketplace repo instead of committing to main", + }, + }, } diff --git a/src/skill_seekers/cli/arguments/pdf.py b/src/skill_seekers/cli/arguments/pdf.py index efd0542..8f2f233 100644 --- a/src/skill_seekers/cli/arguments/pdf.py +++ b/src/skill_seekers/cli/arguments/pdf.py @@ -64,7 +64,7 @@ def add_pdf_arguments(parser: argparse.ArgumentParser) -> None: action.help = ( "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for PDF), 1=SKILL.md only, 2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), otherwise LOCAL (AI coding agent)" ) # PDF-specific args diff --git a/src/skill_seekers/cli/arguments/pptx.py b/src/skill_seekers/cli/arguments/pptx.py index ce0b114..ff6f4db 100644 --- a/src/skill_seekers/cli/arguments/pptx.py +++ b/src/skill_seekers/cli/arguments/pptx.py @@ -57,8 +57,8 @@ def add_pptx_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for PPTX), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # PPTX-specific args diff --git a/src/skill_seekers/cli/arguments/rss.py b/src/skill_seekers/cli/arguments/rss.py index 6ca89c7..ed7c90d 100644 --- a/src/skill_seekers/cli/arguments/rss.py +++ b/src/skill_seekers/cli/arguments/rss.py @@ -90,8 +90,8 @@ def add_rss_arguments(parser: argparse.ArgumentParser) -> None: "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for RSS), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), " + "otherwise LOCAL (AI coding agent)" ) # RSS-specific args diff --git a/src/skill_seekers/cli/arguments/unified.py b/src/skill_seekers/cli/arguments/unified.py index e4da6bb..ade4a1c 100644 --- a/src/skill_seekers/cli/arguments/unified.py +++ b/src/skill_seekers/cli/arguments/unified.py @@ -22,7 +22,7 @@ UNIFIED_ARGUMENTS: dict[str, dict[str, Any]] = { "flags": ("--merge-mode",), "kwargs": { "type": str, - "help": "Merge mode (rule-based, claude-enhanced)", + "help": "Merge mode (rule-based, ai-enhanced). 'claude-enhanced' is accepted as alias.", "metavar": "MODE", }, }, @@ -72,12 +72,30 @@ UNIFIED_ARGUMENTS: dict[str, dict[str, Any]] = { "help": "Preview workflow stages without executing (requires --enhance-workflow)", }, }, + # Agent selection for LOCAL mode enhancement + "agent": { + "flags": ("--agent",), + "kwargs": { + "type": str, + "choices": ["claude", "codex", "copilot", "opencode", "kimi", "custom"], + "help": "Local coding agent for enhancement (default: AI agent from SKILL_SEEKER_AGENT env var)", + "metavar": "AGENT", + }, + }, + "agent_cmd": { + "flags": ("--agent-cmd",), + "kwargs": { + "type": str, + "help": "Override agent command template (use {prompt_file} or stdin)", + "metavar": "CMD", + }, + }, # API key and enhance-level (parity with scrape/github/analyze/pdf) "api_key": { "flags": ("--api-key",), "kwargs": { "type": str, - "help": "Anthropic API key (or set ANTHROPIC_API_KEY env var)", + "help": "API key for enhancement (ANTHROPIC_API_KEY, GOOGLE_API_KEY, OPENAI_API_KEY, or MOONSHOT_API_KEY)", "metavar": "KEY", }, }, diff --git a/src/skill_seekers/cli/arguments/upload.py b/src/skill_seekers/cli/arguments/upload.py index dccae0f..c31d1ab 100644 --- a/src/skill_seekers/cli/arguments/upload.py +++ b/src/skill_seekers/cli/arguments/upload.py @@ -22,9 +22,9 @@ UPLOAD_ARGUMENTS: dict[str, dict[str, Any]] = { "flags": ("--target",), "kwargs": { "type": str, - "choices": ["claude", "gemini", "openai", "chroma", "weaviate"], - "default": "claude", - "help": "Target platform (default: claude)", + "choices": ["claude", "gemini", "openai", "kimi", "chroma", "weaviate"], + "default": None, + "help": "Target platform (auto-detected from API keys, or 'claude' if none set)", "metavar": "PLATFORM", }, }, diff --git a/src/skill_seekers/cli/arguments/video.py b/src/skill_seekers/cli/arguments/video.py index 2bd99ce..d2699d4 100644 --- a/src/skill_seekers/cli/arguments/video.py +++ b/src/skill_seekers/cli/arguments/video.py @@ -156,7 +156,7 @@ def add_video_arguments(parser: argparse.ArgumentParser) -> None: action.help = ( "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for video), 1=SKILL.md only, 2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), otherwise LOCAL (AI coding agent)" ) # Video-specific args diff --git a/src/skill_seekers/cli/arguments/word.py b/src/skill_seekers/cli/arguments/word.py index 0c254b2..3bc889a 100644 --- a/src/skill_seekers/cli/arguments/word.py +++ b/src/skill_seekers/cli/arguments/word.py @@ -56,7 +56,7 @@ def add_word_arguments(parser: argparse.ArgumentParser) -> None: action.help = ( "AI enhancement level (auto-detects API vs LOCAL mode): " "0=disabled (default for Word), 1=SKILL.md only, 2=+architecture/config, 3=full enhancement. " - "Mode selection: uses API if ANTHROPIC_API_KEY is set, otherwise LOCAL (Claude Code)" + "Mode selection: uses API if API key is set (ANTHROPIC_API_KEY, MOONSHOT_API_KEY, etc.), otherwise LOCAL (AI coding agent)" ) # Word-specific args diff --git a/src/skill_seekers/cli/asciidoc_scraper.py b/src/skill_seekers/cli/asciidoc_scraper.py index b5082ed..cb24851 100644 --- a/src/skill_seekers/cli/asciidoc_scraper.py +++ b/src/skill_seekers/cli/asciidoc_scraper.py @@ -1059,13 +1059,17 @@ def main() -> int: print("โŒ API enhancement not available. Falling back to LOCAL mode...") from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/browser_renderer.py b/src/skill_seekers/cli/browser_renderer.py index adcacb7..60620b0 100644 --- a/src/skill_seekers/cli/browser_renderer.py +++ b/src/skill_seekers/cli/browser_renderer.py @@ -64,12 +64,21 @@ class BrowserRenderer: html = renderer.render_page(url) """ - def __init__(self, timeout: int = 30000, wait_until: str = "networkidle"): + def __init__( + self, + timeout: int = 60000, + wait_until: str = "domcontentloaded", + extra_wait: int = 0, + ): """Initialize renderer. Args: - timeout: Page load timeout in milliseconds (default: 30s) + timeout: Page load timeout in milliseconds (default: 60s) wait_until: Playwright wait condition โ€” "networkidle", "load", "domcontentloaded" + Default changed to "domcontentloaded" for better compatibility + with heavy sites (Unity docs, DocFX, etc.) that never reach networkidle. + extra_wait: Additional milliseconds to wait after page load for lazy-loaded + navigation/content (e.g., 5000 for DocFX sidebar). Default: 0. """ if not _check_playwright_available(): raise ImportError( @@ -80,6 +89,7 @@ class BrowserRenderer: self._timeout = timeout self._wait_until = wait_until + self._extra_wait = extra_wait self._playwright = None self._browser = None self._context = None @@ -127,6 +137,8 @@ class BrowserRenderer: page = self._context.new_page() try: page.goto(url, wait_until=self._wait_until, timeout=self._timeout) + if self._extra_wait > 0: + page.wait_for_timeout(self._extra_wait) html = page.content() return html finally: diff --git a/src/skill_seekers/cli/chat_scraper.py b/src/skill_seekers/cli/chat_scraper.py index 2d60c7c..7d98a2f 100644 --- a/src/skill_seekers/cli/chat_scraper.py +++ b/src/skill_seekers/cli/chat_scraper.py @@ -1885,7 +1885,9 @@ Examples: LocalSkillEnhancer, ) - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: @@ -1893,7 +1895,9 @@ Examples: LocalSkillEnhancer, ) - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/codebase_scraper.py b/src/skill_seekers/cli/codebase_scraper.py index 3f532bf..5eba262 100644 --- a/src/skill_seekers/cli/codebase_scraper.py +++ b/src/skill_seekers/cli/codebase_scraper.py @@ -650,6 +650,7 @@ def process_markdown_docs( gitignore_spec: pathspec.PathSpec | None = None, enhance_with_ai: bool = False, ai_mode: str = "none", + agent: str | None = None, ) -> dict[str, Any]: """ Process all markdown documentation files in a directory. @@ -822,7 +823,7 @@ def process_markdown_docs( if enhance_with_ai and ai_mode != "none" and processed_docs: logger.info("๐Ÿค– Enhancing documentation analysis with AI...") try: - processed_docs = _enhance_docs_with_ai(processed_docs, ai_mode) + processed_docs = _enhance_docs_with_ai(processed_docs, ai_mode, agent=agent) logger.info("โœ… AI documentation enhancement complete") except Exception as e: logger.warning(f"โš ๏ธ AI enhancement failed: {e}") @@ -898,55 +899,46 @@ def process_markdown_docs( return index_data -def _enhance_docs_with_ai(docs: list[dict], ai_mode: str) -> list[dict]: +def _enhance_docs_with_ai(docs: list[dict], ai_mode: str, agent: str | None = None) -> list[dict]: """ - Enhance documentation analysis with AI. + Enhance documentation analysis with AI via AgentClient. Args: docs: List of processed document dictionaries - ai_mode: AI mode ('api' or 'local') + ai_mode: AI mode ('api', 'local', or 'auto') + agent: Local CLI agent name (e.g., "kimi", "claude") Returns: Enhanced document list """ - # Try API mode first - if ai_mode in ("api", "auto"): - api_key = os.environ.get("ANTHROPIC_API_KEY") - if api_key: - return _enhance_docs_api(docs, api_key) + from skill_seekers.cli.agent_client import AgentClient - # Fall back to LOCAL mode - if ai_mode in ("local", "auto"): - return _enhance_docs_local(docs) + client = AgentClient(mode=ai_mode, agent=agent) - return docs + if not client.is_available(): + logger.warning("โš ๏ธ No AI agent available for documentation enhancement") + return docs + # Batch documents for efficiency + batch_size = 10 + docs_with_summary = [d for d in docs if d.get("summary")] + if not docs_with_summary: + return docs -def _enhance_docs_api(docs: list[dict], api_key: str) -> list[dict]: - """Enhance docs using Claude API.""" - try: - import anthropic + for i in range(0, len(docs_with_summary), batch_size): + batch = docs_with_summary[i : i + batch_size] - client = anthropic.Anthropic(api_key=api_key) + docs_text = "\n\n".join( + [ + f"## {d.get('title', d['filename'])}\nCategory: {d['category']}\nSummary: {d.get('summary', 'N/A')}" + for d in batch + ] + ) - # Batch documents for efficiency - batch_size = 10 - for i in range(0, len(docs), batch_size): - batch = docs[i : i + batch_size] + if not docs_text: + continue - # Create prompt for batch - docs_text = "\n\n".join( - [ - f"## {d.get('title', d['filename'])}\nCategory: {d['category']}\nSummary: {d.get('summary', 'N/A')}" - for d in batch - if d.get("summary") - ] - ) - - if not docs_text: - continue - - prompt = f"""Analyze these documentation files and provide: + prompt = f"""Analyze these documentation files and provide: 1. A brief description of what each document covers 2. Key topics/concepts mentioned 3. How they relate to each other @@ -957,15 +949,10 @@ Documents: Return JSON with format: {{"enhancements": [{{"filename": "...", "description": "...", "key_topics": [...], "related_to": [...]}}]}}""" - response = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=2000, - messages=[{"role": "user", "content": prompt}], - ) - - # Parse response and merge enhancements - try: - json_match = re.search(r"\{.*\}", response.content[0].text, re.DOTALL) + try: + response = client.call(prompt, max_tokens=2000) + if response: + json_match = re.search(r"\{.*\}", response, re.DOTALL) if json_match: enhancements = json.loads(json_match.group()) for enh in enhancements.get("enhancements", []): @@ -974,72 +961,8 @@ Return JSON with format: doc["ai_description"] = enh.get("description") doc["ai_topics"] = enh.get("key_topics", []) doc["ai_related"] = enh.get("related_to", []) - except Exception: - pass - - except Exception as e: - logger.warning(f"API enhancement failed: {e}") - - return docs - - -def _enhance_docs_local(docs: list[dict]) -> list[dict]: - """Enhance docs using Claude Code CLI (LOCAL mode).""" - import subprocess - import tempfile - - # Prepare batch of docs for enhancement - docs_with_summary = [d for d in docs if d.get("summary")] - if not docs_with_summary: - return docs - - docs_text = "\n\n".join( - [ - f"## {d.get('title', d['filename'])}\nCategory: {d['category']}\nPath: {d['path']}\nSummary: {d.get('summary', 'N/A')}" - for d in docs_with_summary[:20] # Limit to 20 docs - ] - ) - - prompt = f"""Analyze these documentation files from a codebase and provide insights. - -For each document, provide: -1. A brief description of what it covers -2. Key topics/concepts -3. Related documents - -Documents: -{docs_text} - -Output JSON only: -{{"enhancements": [{{"filename": "...", "description": "...", "key_topics": ["..."], "related_to": ["..."]}}]}}""" - - try: - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write(prompt) - prompt_file = f.name - - result = subprocess.run( - ["claude", "--dangerously-skip-permissions", "-p", prompt], - capture_output=True, - text=True, - timeout=120, - ) - - os.unlink(prompt_file) - - if result.returncode == 0 and result.stdout: - json_match = re.search(r"\{.*\}", result.stdout, re.DOTALL) - if json_match: - enhancements = json.loads(json_match.group()) - for enh in enhancements.get("enhancements", []): - for doc in docs: - if doc["filename"] == enh.get("filename"): - doc["ai_description"] = enh.get("description") - doc["ai_topics"] = enh.get("key_topics", []) - doc["ai_related"] = enh.get("related_to", []) - - except Exception as e: - logger.warning(f"LOCAL enhancement failed: {e}") + except Exception as e: + logger.warning(f"AI enhancement batch failed: {e}") return docs @@ -1062,6 +985,8 @@ def analyze_codebase( skill_name: str | None = None, skill_description: str | None = None, doc_version: str = "", + agent: str | None = None, + agent_cmd: str | None = None, ) -> dict[str, Any]: """ Analyze local codebase and extract code knowledge. @@ -1467,7 +1392,7 @@ def analyze_codebase( from skill_seekers.cli.config_enhancer import ConfigEnhancer logger.info(f"๐Ÿค– Enhancing config analysis with AI (mode: {ai_mode})...") - enhancer = ConfigEnhancer(mode=ai_mode) + enhancer = ConfigEnhancer(mode=ai_mode, agent=agent) result_dict = enhancer.enhance_config_result(result_dict) logger.info("โœ… AI enhancement complete") except Exception as e: @@ -1514,7 +1439,7 @@ def analyze_codebase( logger.info("Analyzing architectural patterns...") from skill_seekers.cli.architectural_pattern_detector import ArchitecturalPatternDetector - arch_detector = ArchitecturalPatternDetector(enhance_with_ai=enhance_architecture) + arch_detector = ArchitecturalPatternDetector(enhance_with_ai=enhance_architecture, agent=agent) arch_report = arch_detector.analyze(directory, results["files"]) # Save architecture analysis if we have patterns OR frameworks (fixes #239) @@ -1579,6 +1504,7 @@ def analyze_codebase( gitignore_spec=gitignore_spec, enhance_with_ai=enhance_docs_ai, ai_mode=ai_mode, + agent=agent, ) if docs_data and docs_data.get("total_files", 0) > 0: @@ -1831,8 +1757,8 @@ def _get_language_stats(files: list[dict]) -> dict[str, int]: def _format_patterns_section(output_dir: Path) -> str: - """Format design patterns section from patterns/detected_patterns.json.""" - patterns_file = output_dir / "patterns" / "detected_patterns.json" + """Format design patterns section from patterns/all_patterns.json.""" + patterns_file = output_dir / "patterns" / "all_patterns.json" if not patterns_file.exists(): return "" @@ -2315,8 +2241,8 @@ Examples: help=( "AI enhancement mode for how-to guides: " "auto (auto-detect: API if ANTHROPIC_API_KEY set, else LOCAL), " - "api (Claude API, requires ANTHROPIC_API_KEY), " - "local (Claude Code Max, FREE, no API key), " + "api (Anthropic API, requires ANTHROPIC_API_KEY), " + "local (coding agent CLI, FREE, no API key), " "none (disable AI enhancement). " "๐Ÿ’ก TIP: Use --enhance flag instead for simpler UX!" ), diff --git a/src/skill_seekers/cli/config_command.py b/src/skill_seekers/cli/config_command.py index 21d6119..6afac9c 100644 --- a/src/skill_seekers/cli/config_command.py +++ b/src/skill_seekers/cli/config_command.py @@ -313,7 +313,7 @@ def api_keys_menu(): print("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n") print("Current status:") - for provider in ["anthropic", "google", "openai"]: + for provider in ["anthropic", "google", "openai", "moonshot"]: key = config.get_api_key(provider) status = "โœ… Set" if key else "โŒ Not set" source = "" @@ -324,6 +324,7 @@ def api_keys_menu(): "anthropic": "ANTHROPIC_API_KEY", "google": "GOOGLE_API_KEY", "openai": "OPENAI_API_KEY", + "moonshot": "MOONSHOT_API_KEY", }[provider] source = " (from environment)" if os.getenv(env_var) else " (from config)" print(f" โ€ข {provider.capitalize()}: {status}{source}") @@ -332,14 +333,16 @@ def api_keys_menu(): print(" 1. Set Anthropic (Claude) API Key") print(" 2. Set Google (Gemini) API Key") print(" 3. Set OpenAI (ChatGPT) API Key") + print(" 4. Set Moonshot (Kimi) API Key") print(" 0. Back to Main Menu\n") - choice = input("Select an option [0-3]: ").strip() + choice = input("Select an option [0-4]: ").strip() provider_map = { "1": ("anthropic", "https://console.anthropic.com/settings/keys"), "2": ("google", "https://makersuite.google.com/app/apikey"), "3": ("openai", "https://platform.openai.com/api-keys"), + "4": ("moonshot", "https://platform.moonshot.cn/"), } if choice in provider_map: diff --git a/src/skill_seekers/cli/config_enhancer.py b/src/skill_seekers/cli/config_enhancer.py index 67b32b7..cec290a 100644 --- a/src/skill_seekers/cli/config_enhancer.py +++ b/src/skill_seekers/cli/config_enhancer.py @@ -14,26 +14,15 @@ Similar to GuideEnhancer (C3.3) but for configuration files. import json import logging -import os -import subprocess import sys -import tempfile from dataclasses import dataclass, field -from pathlib import Path + +from skill_seekers.cli.agent_client import AgentClient # Configure logging logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) -# Optional anthropic import -ANTHROPIC_AVAILABLE = False -try: - import anthropic - - ANTHROPIC_AVAILABLE = True -except ImportError: - pass - @dataclass class ConfigEnhancement: @@ -62,51 +51,22 @@ class ConfigEnhancer: AI enhancement for configuration extraction results. Supports dual-mode operation: - - API mode: Uses Claude API (requires ANTHROPIC_API_KEY) - - LOCAL mode: Uses Claude Code CLI (no API key needed) + - API mode: Uses Anthropic API (requires ANTHROPIC_API_KEY) + - LOCAL mode: Uses a coding agent CLI (no API key needed) - AUTO mode: Automatically detects best available mode """ - def __init__(self, mode: str = "auto"): + def __init__(self, mode: str = "auto", agent: str | None = None): """ Initialize ConfigEnhancer. Args: mode: Enhancement mode - "api", "local", or "auto" (default) + agent: Local CLI agent name (e.g., "kimi", "claude") """ - self.mode = self._detect_mode(mode) - self.api_key = os.environ.get("ANTHROPIC_API_KEY") - self.client = None - - if self.mode == "api" and ANTHROPIC_AVAILABLE and self.api_key: - # Support custom base_url for GLM-4.7 and other Claude-compatible APIs - client_kwargs = {"api_key": self.api_key} - base_url = os.environ.get("ANTHROPIC_BASE_URL") - if base_url: - client_kwargs["base_url"] = base_url - logger.info(f"โœ… Using custom API base URL: {base_url}") - self.client = anthropic.Anthropic(**client_kwargs) - - def _detect_mode(self, requested_mode: str) -> str: - """ - Detect best enhancement mode. - - Args: - requested_mode: User-requested mode - - Returns: - Actual mode to use - """ - if requested_mode in ["api", "local"]: - return requested_mode - - # Auto-detect - if os.environ.get("ANTHROPIC_API_KEY") and ANTHROPIC_AVAILABLE: - logger.info("๐Ÿค– AI enhancement: API mode (Claude API detected)") - return "api" - else: - logger.info("๐Ÿค– AI enhancement: LOCAL mode (using Claude Code CLI)") - return "local" + self._agent = AgentClient(mode=mode, agent=agent) + self.mode = self._agent.mode + self._agent.log_mode() def enhance_config_result(self, result: dict) -> dict: """ @@ -126,29 +86,29 @@ class ConfigEnhancer: return self._enhance_via_local(result) # ========================================================================= - # API MODE - Direct Claude API calls + # API MODE - Direct AI API calls # ========================================================================= def _enhance_via_api(self, result: dict) -> dict: - """Enhance configs using Claude API""" - if not self.client: - logger.error("โŒ API mode requested but no API key available") + """Enhance configs using AI API""" + if not self._agent.is_available(): + logger.error("โŒ API mode requested but no API client available") return result try: # Create enhancement prompt prompt = self._create_enhancement_prompt(result) - # Call Claude API - logger.info("๐Ÿ“ก Calling Claude API for config analysis...") - response = self.client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=8000, - messages=[{"role": "user", "content": prompt}], - ) + # Call AI agent for config analysis + logger.info("๐Ÿ“ก Calling AI agent for config analysis...") + response_text = self._agent.call(prompt, max_tokens=8000) + + if not response_text: + logger.error("โŒ AI agent returned no response") + return result # Parse response - enhanced_result = self._parse_api_response(response.content[0].text, result) + enhanced_result = self._parse_api_response(response_text, result) logger.info("โœ… API enhancement complete") return enhanced_result @@ -157,7 +117,7 @@ class ConfigEnhancer: return result def _create_enhancement_prompt(self, result: dict) -> str: - """Create prompt for Claude API""" + """Create prompt for AI API""" config_files = result.get("config_files", []) # Summarize configs for prompt @@ -217,7 +177,7 @@ Focus on actionable insights that help developers understand and improve their c return prompt def _parse_api_response(self, response_text: str, original_result: dict) -> dict: - """Parse Claude API response and merge with original result""" + """Parse AI API response and merge with original result""" try: # Extract JSON from response import re @@ -248,56 +208,54 @@ Focus on actionable insights that help developers understand and improve their c return original_result # ========================================================================= - # LOCAL MODE - Claude Code CLI + # LOCAL MODE - Coding Agent CLI # ========================================================================= def _enhance_via_local(self, result: dict) -> dict: - """Enhance configs using Claude Code CLI""" + """Enhance configs using LOCAL CLI agent""" try: - # Create a temporary directory for this enhancement session - with tempfile.TemporaryDirectory(prefix="config_enhance_") as temp_dir: - temp_path = Path(temp_dir) + logger.info("๐Ÿ–ฅ๏ธ Launching LOCAL agent for config analysis...") + logger.info("โฑ๏ธ This will take 30-60 seconds...") - # Define output file path (absolute path that Claude will write to) - output_file = temp_path / "config_enhancement.json" + # Build the prompt โ€” AgentClient handles output file management + prompt_content = self._create_local_prompt(result) - # Create prompt file with the output path embedded - prompt_file = temp_path / "enhance_prompt.md" - prompt_content = self._create_local_prompt(result, output_file) - prompt_file.write_text(prompt_content) + # Call via AgentClient which handles temp dirs, file polling, etc. + response_text = self._agent.call(prompt_content) - logger.info("๐Ÿ–ฅ๏ธ Launching Claude Code CLI for config analysis...") - logger.info("โฑ๏ธ This will take 30-60 seconds...") + if response_text: + try: + import re - # Run Claude Code CLI - result_data = self._run_claude_cli(prompt_file, output_file, temp_path) + json_match = re.search(r"\{.*\}", response_text, re.DOTALL) + if json_match: + data = json.loads(json_match.group()) + if "file_enhancements" in data or "overall_insights" in data: + result["ai_enhancements"] = data + logger.info("โœ… LOCAL enhancement complete") + return result + except json.JSONDecodeError as e: + logger.error(f"โŒ Failed to parse LOCAL response as JSON: {e}") - if result_data: - # Merge LOCAL enhancements - result["ai_enhancements"] = result_data - logger.info("โœ… LOCAL enhancement complete") - return result - else: - logger.warning("โš ๏ธ LOCAL enhancement produced no results") - return result + logger.warning("โš ๏ธ LOCAL enhancement produced no results") + return result except Exception as e: logger.error(f"โŒ LOCAL enhancement failed: {e}") return result - def _create_local_prompt(self, result: dict, output_file: Path) -> str: - """Create prompt file for Claude Code CLI + def _create_local_prompt(self, result: dict) -> str: + """Create prompt for AI agent. Args: result: Config extraction result dict - output_file: Absolute path where Claude should write the JSON output Returns: Prompt content string """ config_files = result.get("config_files", []) - # Format config data for Claude (limit to 15 files for reasonable prompt size) + # Format config data for AI agent (limit to 15 files for reasonable prompt size) config_data = [] for cf in config_files[:15]: # Support both "type" (from config_extractor) and "config_type" (legacy) @@ -318,9 +276,6 @@ Focus on actionable insights that help developers understand and improve their c prompt = f"""# Configuration Analysis Task -IMPORTANT: You MUST write the output to this EXACT file path: -{output_file} - ## Configuration Files ({len(config_files)} total, showing first 15) {chr(10).join(config_data)} @@ -354,7 +309,7 @@ The JSON must have this EXACT structure: ## Instructions -1. Use the Write tool to create the JSON file at: {output_file} +1. Return the JSON response directly 2. Include an enhancement entry for each config file shown above 3. Focus on actionable insights: - Explain what each config does in 1-2 sentences @@ -366,97 +321,6 @@ DO NOT explain your work - just write the JSON file directly. """ return prompt - def _run_claude_cli( - self, prompt_file: Path, output_file: Path, working_dir: Path - ) -> dict | None: - """Run Claude Code CLI and wait for completion - - Args: - prompt_file: Path to the prompt markdown file - output_file: Expected path where Claude will write the JSON output - working_dir: Working directory to run Claude from - - Returns: - Parsed JSON dict if successful, None otherwise - """ - import time - - try: - start_time = time.time() - - # Run claude command with --dangerously-skip-permissions to bypass all prompts - # This allows Claude to write files without asking for confirmation - logger.info(f" Running: claude --dangerously-skip-permissions {prompt_file.name}") - logger.info(f" Output expected at: {output_file}") - - result = subprocess.run( - ["claude", "--dangerously-skip-permissions", str(prompt_file)], - capture_output=True, - text=True, - timeout=300, # 5 minute timeout - cwd=str(working_dir), - ) - - elapsed = time.time() - start_time - logger.info(f" Claude finished in {elapsed:.1f} seconds") - - if result.returncode != 0: - logger.error(f"โŒ Claude CLI failed (exit code {result.returncode})") - if result.stderr: - logger.error(f" Error: {result.stderr[:200]}") - return None - - # Check if the expected output file was created - if output_file.exists(): - try: - with open(output_file) as f: - data = json.load(f) - if "file_enhancements" in data or "overall_insights" in data: - logger.info(f"โœ… Found enhancement data in {output_file.name}") - return data - else: - logger.warning("โš ๏ธ Output file exists but missing expected keys") - except json.JSONDecodeError as e: - logger.error(f"โŒ Failed to parse output JSON: {e}") - return None - - # Fallback: Look for any JSON files created in the working directory - logger.info(" Looking for JSON files in working directory...") - current_time = time.time() - potential_files = [] - - for json_file in working_dir.glob("*.json"): - # Check if created recently (within last 2 minutes) - if current_time - json_file.stat().st_mtime < 120: - potential_files.append(json_file) - - # Try to load the most recent JSON file with expected structure - for json_file in sorted(potential_files, key=lambda f: f.stat().st_mtime, reverse=True): - try: - with open(json_file) as f: - data = json.load(f) - if "file_enhancements" in data or "overall_insights" in data: - logger.info(f"โœ… Found enhancement data in {json_file.name}") - return data - except Exception: - continue - - logger.warning("โš ๏ธ Could not find enhancement output file") - logger.info(f" Expected file: {output_file}") - logger.info(f" Files in dir: {list(working_dir.glob('*'))}") - return None - - except subprocess.TimeoutExpired: - logger.error("โŒ Claude CLI timeout (5 minutes)") - return None - except FileNotFoundError: - logger.error("โŒ 'claude' command not found. Is Claude Code CLI installed?") - logger.error(" Install with: npm install -g @anthropic-ai/claude-code") - return None - except Exception as e: - logger.error(f"โŒ Error running Claude CLI: {e}") - return None - def main(): """Command-line interface for config enhancement""" diff --git a/src/skill_seekers/cli/config_extractor.py b/src/skill_seekers/cli/config_extractor.py index f258d93..1950766 100644 --- a/src/skill_seekers/cli/config_extractor.py +++ b/src/skill_seekers/cli/config_extractor.py @@ -271,13 +271,13 @@ class ConfigFileDetector: ".tmp", } - def find_config_files(self, directory: Path, max_files: int = 100) -> list[ConfigFile]: + def find_config_files(self, directory: Path, max_files: int = 0) -> list[ConfigFile]: """ Find all configuration files in directory. Args: directory: Root directory to search - max_files: Maximum number of config files to find + max_files: Maximum number of config files to find (0 = unlimited) Returns: List of ConfigFile objects @@ -286,7 +286,7 @@ class ConfigFileDetector: found_count = 0 for file_path in self._walk_directory(directory): - if found_count >= max_files: + if max_files > 0 and found_count >= max_files: logger.info(f"Reached max_files limit ({max_files})") break @@ -760,15 +760,13 @@ class ConfigExtractor: self.parser = ConfigParser() self.pattern_detector = ConfigPatternDetector() - def extract_from_directory( - self, directory: Path, max_files: int = 100 - ) -> ConfigExtractionResult: + def extract_from_directory(self, directory: Path, max_files: int = 0) -> ConfigExtractionResult: """ Extract configuration patterns from directory. Args: directory: Root directory to analyze - max_files: Maximum config files to process + max_files: Maximum config files to process (0 = unlimited) Returns: ConfigExtractionResult with all findings @@ -857,7 +855,7 @@ def main(): parser.add_argument("directory", type=Path, help="Directory to analyze") parser.add_argument("--output", "-o", type=Path, help="Output JSON file") parser.add_argument( - "--max-files", type=int, default=100, help="Maximum config files to process" + "--max-files", type=int, default=0, help="Maximum config files to process (0 = unlimited)" ) parser.add_argument( "--enhance", @@ -867,13 +865,13 @@ def main(): parser.add_argument( "--enhance-local", action="store_true", - help="Enhance with AI analysis (LOCAL mode, uses Claude Code CLI)", + help="Enhance with AI analysis (LOCAL mode, uses coding agent CLI)", ) parser.add_argument( "--ai-mode", choices=["auto", "api", "local", "none"], default="none", - help="AI enhancement mode: auto (detect), api (Claude API), local (Claude Code CLI), none (disable)", + help="AI enhancement mode: auto (detect), api (Anthropic API), local (coding agent CLI), none (disable)", ) args = parser.parse_args() diff --git a/src/skill_seekers/cli/config_manager.py b/src/skill_seekers/cli/config_manager.py index c8febf7..5c33594 100644 --- a/src/skill_seekers/cli/config_manager.py +++ b/src/skill_seekers/cli/config_manager.py @@ -47,12 +47,12 @@ class ConfigManager: "show_countdown": True, }, "resume": {"auto_save_interval_seconds": 60, "keep_progress_days": 7}, - "api_keys": {"anthropic": None, "google": None, "openai": None}, + "api_keys": {"anthropic": None, "google": None, "openai": None, "moonshot": None}, "ai_enhancement": { "default_enhance_level": 1, # Default AI enhancement level (0-3) - "default_agent": None, # "claude", "gemini", "openai", or None (auto-detect) - "local_batch_size": 20, # Patterns per Claude CLI call (default was 5) - "local_parallel_workers": 3, # Concurrent Claude CLI calls + "default_agent": None, # "claude", "gemini", "openai", "kimi", or None (auto-detect) + "local_batch_size": 20, # Patterns per CLI agent call (default was 5) + "local_parallel_workers": 3, # Concurrent CLI agent calls }, "first_run": {"completed": False, "version": "2.7.0"}, } diff --git a/src/skill_seekers/cli/config_validator.py b/src/skill_seekers/cli/config_validator.py index 086d2ef..d85faba 100644 --- a/src/skill_seekers/cli/config_validator.py +++ b/src/skill_seekers/cli/config_validator.py @@ -61,7 +61,11 @@ class ConfigValidator: } # Valid merge modes - VALID_MERGE_MODES = {"rule-based", "claude-enhanced"} + VALID_MERGE_MODES = { + "rule-based", + "ai-enhanced", + "claude-enhanced", + } # claude-enhanced kept as alias # Valid code analysis depth levels VALID_DEPTH_LEVELS = {"surface", "deep", "full"} @@ -161,6 +165,21 @@ class ConfigValidator: f"Invalid merge_mode: '{merge_mode}'. Must be one of {self.VALID_MERGE_MODES}" ) + # Validate marketplace_targets (optional) + marketplace_targets = self.config.get("marketplace_targets") + if marketplace_targets is not None: + if not isinstance(marketplace_targets, list): + raise ValueError("'marketplace_targets' must be an array") + for i, mt in enumerate(marketplace_targets): + if not isinstance(mt, dict): + raise ValueError(f"marketplace_targets[{i}]: must be an object") + if "marketplace" not in mt: + raise ValueError( + f"marketplace_targets[{i}]: missing required field 'marketplace'" + ) + if not isinstance(mt["marketplace"], str): + raise ValueError(f"marketplace_targets[{i}]: 'marketplace' must be a string") + # Validate each source for i, source in enumerate(sources): self._validate_source(source, i) diff --git a/src/skill_seekers/cli/confluence_scraper.py b/src/skill_seekers/cli/confluence_scraper.py index 6204606..853500a 100644 --- a/src/skill_seekers/cli/confluence_scraper.py +++ b/src/skill_seekers/cli/confluence_scraper.py @@ -1962,7 +1962,7 @@ def main() -> int: "0=disabled (default for Confluence), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "otherwise LOCAL (Claude Code, Kimi, etc.)" ) # Confluence-specific arguments @@ -2137,7 +2137,9 @@ def main() -> int: LocalSkillEnhancer, ) - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print(" Local enhancement complete!") else: @@ -2145,7 +2147,9 @@ def main() -> int: LocalSkillEnhancer, ) - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print(" Local enhancement complete!") diff --git a/src/skill_seekers/cli/constants.py b/src/skill_seekers/cli/constants.py index 87fcb9a..cb35db2 100644 --- a/src/skill_seekers/cli/constants.py +++ b/src/skill_seekers/cli/constants.py @@ -28,7 +28,7 @@ CONTENT_MATCH_POINTS = 1 # points for content keyword match API_CONTENT_LIMIT = 100000 # max characters for API enhancement API_PREVIEW_LIMIT = 40000 # max characters for preview -# Local enhancement limits (uses Claude Code Max) +# Local enhancement limits (uses coding agent CLI) LOCAL_CONTENT_LIMIT = 50000 # max characters for local enhancement LOCAL_PREVIEW_LIMIT = 20000 # max characters for preview diff --git a/src/skill_seekers/cli/create_command.py b/src/skill_seekers/cli/create_command.py index 3ee1446..bca790d 100644 --- a/src/skill_seekers/cli/create_command.py +++ b/src/skill_seekers/cli/create_command.py @@ -104,12 +104,20 @@ class CreateCommand: if arg_value is None: return False - # Check against common defaults + # Check against common defaults โ€” args with these values were NOT + # explicitly set by the user and should not be forwarded. defaults = { "max_issues": 100, "chunk_tokens": DEFAULT_CHUNK_TOKENS, "chunk_overlap_tokens": DEFAULT_CHUNK_OVERLAP_TOKENS, "output": None, + "doc_version": "", + "video_languages": "en", + "whisper_model": "base", + "platform": "slack", + "visual_interval": 0.7, + "visual_min_gap": 0.5, + "visual_similarity": 3.0, } if arg_name in defaults: @@ -164,436 +172,227 @@ class CreateCommand: logger.error(f"Unknown source type: {self.source_info.type}") return 1 + # โ”€โ”€ Dynamic argument forwarding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # + # Instead of manually checking each flag in every _route_*() method, + # _build_argv() dynamically iterates vars(self.args) and forwards all + # explicitly-set arguments. This is the same pattern used by + # main.py::_reconstruct_argv() and eliminates ~40 missing-flag gaps. + + # Dest names that differ from their CLI flag (dest โ†’ flag) + _DEST_TO_FLAG = { + "async_mode": "--async", + "video_url": "--url", + "video_playlist": "--playlist", + "video_languages": "--languages", + "skip_config": "--skip-config-patterns", + } + + # Internal args that should never be forwarded to sub-scrapers. + # video_url/video_playlist/video_file are handled as positionals by _route_video(). + # config is forwarded manually only by routes that need it (web, github). + _SKIP_ARGS = frozenset( + { + "source", + "func", + "subcommand", + "command", + "config", + "video_url", + "video_playlist", + "video_file", + } + ) + + def _build_argv( + self, + module_name: str, + positional_args: list[str], + allowlist: frozenset[str] | None = None, + ) -> list[str]: + """Build argv dynamically by forwarding all explicitly-set arguments. + + Uses the same pattern as main.py::_reconstruct_argv(). + Replaces manual per-flag checking in _route_*() and _add_common_args(). + + Args: + module_name: Scraper module name (e.g., "doc_scraper") + positional_args: Positional arguments to prepend (e.g., [url] or ["--repo", repo]) + allowlist: If provided, ONLY forward args in this set (overrides _SKIP_ARGS). + Used for targets with strict arg sets like unified_scraper. + + Returns: + Complete argv list for the scraper + """ + argv = [module_name] + positional_args + + # Auto-add suggested name if user didn't provide one (skip for allowlisted targets) + if not allowlist and not self.args.name and self.source_info: + argv.extend(["--name", self.source_info.suggested_name]) + + for key, value in vars(self.args).items(): + # If allowlist provided, only forward args in the allowlist + if allowlist is not None: + if key not in allowlist: + continue + elif key in self._SKIP_ARGS or key.startswith("_help_"): + continue + if not self._is_explicitly_set(key, value): + continue + + # Use translation map for mismatched destโ†’flag names, else derive from key + if key in self._DEST_TO_FLAG: + arg_flag = self._DEST_TO_FLAG[key] + else: + arg_flag = f"--{key.replace('_', '-')}" + + if isinstance(value, bool): + if value: + argv.append(arg_flag) + elif isinstance(value, list): + for item in value: + argv.extend([arg_flag, str(item)]) + elif value is not None: + argv.extend([arg_flag, str(value)]) + + return argv + + def _call_module(self, module, argv: list[str]) -> int: + """Call a scraper module with the given argv. + + Swaps sys.argv, calls module.main(), restores sys.argv. + """ + logger.debug(f"Calling {argv[0]} with argv: {argv}") + original_argv = sys.argv + try: + sys.argv = argv + result = module.main() + if result is None: + logger.warning(f"Module returned None exit code, treating as success") + return 0 + return result + finally: + sys.argv = original_argv + def _route_web(self) -> int: """Route to web documentation scraper (doc_scraper.py).""" from skill_seekers.cli import doc_scraper - # Reconstruct argv for doc_scraper - argv = ["doc_scraper"] + url = self.source_info.parsed.get("url", self.source_info.raw_source) + argv = self._build_argv("doc_scraper", [url]) - # Add URL - url = self.source_info.parsed["url"] - argv.append(url) - - # Add universal arguments - self._add_common_args(argv) - - # Config file (web-specific โ€” loads selectors, categories, etc.) + # Forward config if set (not in _build_argv since it's in SKIP_ARGS + # to avoid double-forwarding for config-type sources) if self.args.config: argv.extend(["--config", self.args.config]) - # RAG arguments (web scraper only) - if getattr(self.args, "chunk_for_rag", False): - argv.append("--chunk-for-rag") - if ( - getattr(self.args, "chunk_tokens", None) - and self.args.chunk_tokens != DEFAULT_CHUNK_TOKENS - ): - argv.extend(["--chunk-tokens", str(self.args.chunk_tokens)]) - if ( - getattr(self.args, "chunk_overlap_tokens", None) - and self.args.chunk_overlap_tokens != DEFAULT_CHUNK_OVERLAP_TOKENS - ): - argv.extend(["--chunk-overlap-tokens", str(self.args.chunk_overlap_tokens)]) - - # Advanced web-specific arguments - if getattr(self.args, "no_preserve_code_blocks", False): - argv.append("--no-preserve-code-blocks") - if getattr(self.args, "no_preserve_paragraphs", False): - argv.append("--no-preserve-paragraphs") - if getattr(self.args, "interactive_enhancement", False): - argv.append("--interactive-enhancement") - - # Web-specific arguments - if getattr(self.args, "max_pages", None): - argv.extend(["--max-pages", str(self.args.max_pages)]) - if getattr(self.args, "skip_scrape", False): - argv.append("--skip-scrape") - if getattr(self.args, "resume", False): - argv.append("--resume") - if getattr(self.args, "fresh", False): - argv.append("--fresh") - if getattr(self.args, "rate_limit", None): - argv.extend(["--rate-limit", str(self.args.rate_limit)]) - if getattr(self.args, "workers", None): - argv.extend(["--workers", str(self.args.workers)]) - if getattr(self.args, "async_mode", False): - argv.append("--async") - if getattr(self.args, "no_rate_limit", False): - argv.append("--no-rate-limit") - if getattr(self.args, "browser", False): - argv.append("--browser") - - # Call doc_scraper with modified argv - logger.debug(f"Calling doc_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return doc_scraper.main() - finally: - sys.argv = original_argv + return self._call_module(doc_scraper, argv) def _route_github(self) -> int: """Route to GitHub repository scraper (github_scraper.py).""" from skill_seekers.cli import github_scraper - # Reconstruct argv for github_scraper - argv = ["github_scraper"] + repo = self.source_info.parsed.get("repo", self.source_info.raw_source) + argv = self._build_argv("github_scraper", ["--repo", repo]) - # Add repo - repo = self.source_info.parsed["repo"] - argv.extend(["--repo", repo]) - - # Add universal arguments - self._add_common_args(argv) - - # Config file (github-specific) if self.args.config: argv.extend(["--config", self.args.config]) - # Add GitHub-specific arguments - if getattr(self.args, "token", None): - argv.extend(["--token", self.args.token]) - if getattr(self.args, "profile", None): - argv.extend(["--profile", self.args.profile]) - if getattr(self.args, "non_interactive", False): - argv.append("--non-interactive") - if getattr(self.args, "no_issues", False): - argv.append("--no-issues") - if getattr(self.args, "no_changelog", False): - argv.append("--no-changelog") - if getattr(self.args, "no_releases", False): - argv.append("--no-releases") - if getattr(self.args, "max_issues", None) and self.args.max_issues != 100: - argv.extend(["--max-issues", str(self.args.max_issues)]) - if getattr(self.args, "scrape_only", False): - argv.append("--scrape-only") - if getattr(self.args, "local_repo_path", None): - argv.extend(["--local-repo-path", self.args.local_repo_path]) - - # Call github_scraper with modified argv - logger.debug(f"Calling github_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return github_scraper.main() - finally: - sys.argv = original_argv + return self._call_module(github_scraper, argv) def _route_local(self) -> int: """Route to local codebase analyzer (codebase_scraper.py).""" from skill_seekers.cli import codebase_scraper - # Reconstruct argv for codebase_scraper - argv = ["codebase_scraper"] - - # Add directory - directory = self.source_info.parsed["directory"] - argv.extend(["--directory", directory]) - - # Add universal arguments - self._add_common_args(argv) - - # Preset (local codebase scraper has preset support) - if getattr(self.args, "preset", None): - argv.extend(["--preset", self.args.preset]) - - # Add local-specific arguments - if getattr(self.args, "languages", None): - argv.extend(["--languages", self.args.languages]) - if getattr(self.args, "file_patterns", None): - argv.extend(["--file-patterns", self.args.file_patterns]) - if getattr(self.args, "skip_patterns", False): - argv.append("--skip-patterns") - if getattr(self.args, "skip_test_examples", False): - argv.append("--skip-test-examples") - if getattr(self.args, "skip_how_to_guides", False): - argv.append("--skip-how-to-guides") - if getattr(self.args, "skip_config", False): - argv.append("--skip-config") - if getattr(self.args, "skip_docs", False): - argv.append("--skip-docs") - - # Call codebase_scraper with modified argv - logger.debug(f"Calling codebase_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return codebase_scraper.main() - finally: - sys.argv = original_argv + directory = self.source_info.parsed.get("directory", self.source_info.raw_source) + argv = self._build_argv("codebase_scraper", ["--directory", directory]) + return self._call_module(codebase_scraper, argv) def _route_pdf(self) -> int: """Route to PDF scraper (pdf_scraper.py).""" from skill_seekers.cli import pdf_scraper - # Reconstruct argv for pdf_scraper - argv = ["pdf_scraper"] - - # Add PDF file - file_path = self.source_info.parsed["file_path"] - argv.extend(["--pdf", file_path]) - - # Add universal arguments - self._add_common_args(argv) - - # Add PDF-specific arguments - if getattr(self.args, "ocr", False): - argv.append("--ocr") - if getattr(self.args, "pages", None): - argv.extend(["--pages", self.args.pages]) - - # Call pdf_scraper with modified argv - logger.debug(f"Calling pdf_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return pdf_scraper.main() - finally: - sys.argv = original_argv + file_path = self.source_info.parsed.get("file_path", self.source_info.raw_source) + argv = self._build_argv("pdf_scraper", ["--pdf", file_path]) + return self._call_module(pdf_scraper, argv) def _route_word(self) -> int: """Route to Word document scraper (word_scraper.py).""" from skill_seekers.cli import word_scraper - # Reconstruct argv for word_scraper - argv = ["word_scraper"] - - # Add DOCX file - file_path = self.source_info.parsed["file_path"] - argv.extend(["--docx", file_path]) - - # Add universal arguments - self._add_common_args(argv) - - # Call word_scraper with modified argv - logger.debug(f"Calling word_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return word_scraper.main() - finally: - sys.argv = original_argv + file_path = self.source_info.parsed.get("file_path", self.source_info.raw_source) + argv = self._build_argv("word_scraper", ["--docx", file_path]) + return self._call_module(word_scraper, argv) def _route_epub(self) -> int: """Route to EPUB scraper (epub_scraper.py).""" from skill_seekers.cli import epub_scraper - # Reconstruct argv for epub_scraper - argv = ["epub_scraper"] - - # Add EPUB file - file_path = self.source_info.parsed["file_path"] - argv.extend(["--epub", file_path]) - - # Add universal arguments - self._add_common_args(argv) - - # Call epub_scraper with modified argv - logger.debug(f"Calling epub_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return epub_scraper.main() - finally: - sys.argv = original_argv + file_path = self.source_info.parsed.get("file_path", self.source_info.raw_source) + argv = self._build_argv("epub_scraper", ["--epub", file_path]) + return self._call_module(epub_scraper, argv) def _route_video(self) -> int: """Route to video scraper (video_scraper.py).""" from skill_seekers.cli import video_scraper - # Reconstruct argv for video_scraper - argv = ["video_scraper"] - - # Add video source (URL or file) parsed = self.source_info.parsed - video_playlist = getattr(self.args, "video_playlist", None) if parsed.get("source_kind") == "file": - argv.extend(["--video-file", parsed["file_path"]]) - elif video_playlist: - # Explicit --video-playlist flag takes precedence - argv.extend(["--playlist", video_playlist]) + positional = ["--video-file", parsed["file_path"]] elif parsed.get("url"): url = parsed["url"] - # Detect playlist vs single video - if "playlist" in url.lower(): - argv.extend(["--playlist", url]) - else: - argv.extend(["--url", url]) + flag = "--playlist" if "playlist" in url.lower() else "--url" + positional = [flag, url] + else: + positional = [] - # Add universal arguments - self._add_common_args(argv) + argv = self._build_argv("video_scraper", positional) + return self._call_module(video_scraper, argv) - # Add video-specific arguments - video_langs = getattr(self.args, "video_languages", None) or getattr( - self.args, "languages", None - ) - if video_langs: - argv.extend(["--languages", video_langs]) - if getattr(self.args, "visual", False): - argv.append("--visual") - if getattr(self.args, "vision_ocr", False): - argv.append("--vision-ocr") - if getattr(self.args, "whisper_model", None) and self.args.whisper_model != "base": - argv.extend(["--whisper-model", self.args.whisper_model]) - vi = getattr(self.args, "visual_interval", None) - if vi is not None and vi != 0.7: - argv.extend(["--visual-interval", str(vi)]) - vmg = getattr(self.args, "visual_min_gap", None) - if vmg is not None and vmg != 0.5: - argv.extend(["--visual-min-gap", str(vmg)]) - vs = getattr(self.args, "visual_similarity", None) - if vs is not None and vs != 3.0: - argv.extend(["--visual-similarity", str(vs)]) - st = getattr(self.args, "start_time", None) - if st is not None: - argv.extend(["--start-time", str(st)]) - et = getattr(self.args, "end_time", None) - if et is not None: - argv.extend(["--end-time", str(et)]) - - # Call video_scraper with modified argv - logger.debug(f"Calling video_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return video_scraper.main() - finally: - sys.argv = original_argv + # Args accepted by unified_scraper (allowlist for config route) + _UNIFIED_SCRAPER_ARGS = frozenset( + { + "merge_mode", + "skip_codebase_analysis", + "fresh", + "dry_run", + "enhance_workflow", + "enhance_stage", + "var", + "workflow_dry_run", + "api_key", + "enhance_level", + "agent", + "agent_cmd", + } + ) def _route_config(self) -> int: """Route to unified scraper for config files (unified_scraper.py).""" from skill_seekers.cli import unified_scraper - # Reconstruct argv for unified_scraper - argv = ["unified_scraper"] - - # Add config file config_path = self.source_info.parsed["config_path"] - argv.extend(["--config", config_path]) - - # Behavioral flags supported by unified_scraper - # Note: name/output/enhance-level come from the JSON config file, not CLI - if self.args.dry_run: - argv.append("--dry-run") - if getattr(self.args, "fresh", False): - argv.append("--fresh") - - # Config-specific flags (--merge-mode, --skip-codebase-analysis) - if getattr(self.args, "merge_mode", None): - argv.extend(["--merge-mode", self.args.merge_mode]) - if getattr(self.args, "skip_codebase_analysis", False): - argv.append("--skip-codebase-analysis") - - # Enhancement workflow flags (unified_scraper now supports these) - if getattr(self.args, "enhance_workflow", None): - for wf in self.args.enhance_workflow: - argv.extend(["--enhance-workflow", wf]) - if getattr(self.args, "enhance_stage", None): - for stage in self.args.enhance_stage: - argv.extend(["--enhance-stage", stage]) - if getattr(self.args, "var", None): - for var in self.args.var: - argv.extend(["--var", var]) - if getattr(self.args, "workflow_dry_run", False): - argv.append("--workflow-dry-run") - - # Call unified_scraper with modified argv - logger.debug(f"Calling unified_scraper with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return unified_scraper.main() - finally: - sys.argv = original_argv + argv = self._build_argv( + "unified_scraper", + ["--config", config_path], + allowlist=self._UNIFIED_SCRAPER_ARGS, + ) + return self._call_module(unified_scraper, argv) def _route_generic(self, module_name: str, file_flag: str) -> int: """Generic routing for new source types. - Most new source types (jupyter, html, openapi, asciidoc, pptx, rss, - manpage, confluence, notion, chat) follow the same pattern: - import module, build argv with --flag , add common args, call main(). - - Args: - module_name: Python module name under skill_seekers.cli (e.g., "jupyter_scraper") - file_flag: CLI flag for the source file (e.g., "--notebook") - - Returns: - Exit code from scraper + All new source types (jupyter, html, openapi, asciidoc, pptx, rss, + manpage, confluence, notion, chat) use dynamic argument forwarding. """ import importlib module = importlib.import_module(f"skill_seekers.cli.{module_name}") - argv = [module_name] - file_path = self.source_info.parsed.get("file_path", "") - if file_path: - argv.extend([file_flag, file_path]) - - self._add_common_args(argv) - - logger.debug(f"Calling {module_name} with argv: {argv}") - original_argv = sys.argv - try: - sys.argv = argv - return module.main() - finally: - sys.argv = original_argv - - def _add_common_args(self, argv: list[str]) -> None: - """Add truly universal arguments to argv list. - - These flags are accepted by ALL scrapers (doc, github, codebase, pdf) - because each scraper calls ``add_all_standard_arguments(parser)`` - which registers: name, description, output, enhance-level, api-key, - dry-run, verbose, quiet, and workflow args. - - Route-specific flags (preset, config, RAG, preserve, etc.) are - forwarded only by the _route_*() method that needs them. - """ - # Identity arguments - if self.args.name: - argv.extend(["--name", self.args.name]) - elif hasattr(self, "source_info") and self.source_info: - # Use suggested name from source detection - argv.extend(["--name", self.source_info.suggested_name]) - - if self.args.description: - argv.extend(["--description", self.args.description]) - if self.args.output: - argv.extend(["--output", self.args.output]) - - # Enhancement arguments (consolidated to --enhance-level only) - if self.args.enhance_level > 0: - argv.extend(["--enhance-level", str(self.args.enhance_level)]) - if self.args.api_key: - argv.extend(["--api-key", self.args.api_key]) - - # Behavior arguments - if self.args.dry_run: - argv.append("--dry-run") - if self.args.verbose: - argv.append("--verbose") - if self.args.quiet: - argv.append("--quiet") - - # Documentation version metadata - if getattr(self.args, "doc_version", ""): - argv.extend(["--doc-version", self.args.doc_version]) - - # Enhancement Workflow arguments - if getattr(self.args, "enhance_workflow", None): - for wf in self.args.enhance_workflow: - argv.extend(["--enhance-workflow", wf]) - if getattr(self.args, "enhance_stage", None): - for stage in self.args.enhance_stage: - argv.extend(["--enhance-stage", stage]) - if getattr(self.args, "var", None): - for var in self.args.var: - argv.extend(["--var", var]) - if getattr(self.args, "workflow_dry_run", False): - argv.append("--workflow-dry-run") + positional = [file_flag, file_path] if file_path else [] + argv = self._build_argv(module_name, positional) + return self._call_module(module, argv) def main() -> int: diff --git a/src/skill_seekers/cli/doc_scraper.py b/src/skill_seekers/cli/doc_scraper.py index af517f7..f41cb2b 100755 --- a/src/skill_seekers/cli/doc_scraper.py +++ b/src/skill_seekers/cli/doc_scraper.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Documentation to Claude Skill Converter -Single tool to scrape any documentation and create high-quality Claude skills. +Documentation to AI Skill Converter +Single tool to scrape any documentation and create high-quality AI skills. Usage: skill-seekers scrape --interactive @@ -187,6 +187,8 @@ class DocToSkillConverter: # Browser rendering mode (for JavaScript SPA sites) self.browser_mode = config.get("browser", False) self._browser_renderer = None + self._browser_wait_until = config.get("browser_wait_until", "domcontentloaded") + self._browser_extra_wait = config.get("browser_extra_wait", 0) # ms # Parallel scraping config self.workers = config.get("workers", 1) @@ -250,7 +252,10 @@ class DocToSkillConverter: Returns: bool: True if URL matches include patterns and doesn't match exclude patterns """ - if not url.startswith(self.base_url): + # Use directory part of base_url for prefix check so sibling pages match. + # e.g., base_url "https://example.com/docs/index.html" โ†’ prefix "https://example.com/docs/" + base_dir = self.base_url if self.base_url.endswith("/") else self.base_url + "/" + if not url.startswith(base_dir): return False if self._include_patterns and not any(pattern in url for pattern in self._include_patterns): @@ -730,8 +735,14 @@ class DocToSkillConverter: if self._browser_renderer is None: from skill_seekers.cli.browser_renderer import BrowserRenderer - self._browser_renderer = BrowserRenderer() - logger.info("Launched headless browser for JavaScript rendering") + self._browser_renderer = BrowserRenderer( + wait_until=self._browser_wait_until, + extra_wait=self._browser_extra_wait, + ) + logger.info( + f"Launched headless browser for JavaScript rendering " + f"(wait_until={self._browser_wait_until})" + ) return self._browser_renderer.render_page(url) def scrape_page(self, url: str) -> None: @@ -1092,6 +1103,126 @@ class DocToSkillConverter: return True + def _try_sitemap(self) -> list[str]: + """Layer 1: Try to discover pages via sitemap.xml. + + Checks common sitemap locations at the domain root. + Parses XML for tags, filters by is_valid_url(). + + Returns: + List of discovered valid URLs (empty if no sitemap found). + """ + try: + import defusedxml.ElementTree as ET + except ImportError: + import xml.etree.ElementTree as ET + + from urllib.parse import urlparse + + parsed = urlparse(self.base_url) + domain = f"{parsed.scheme}://{parsed.netloc}" + + sitemap_urls_to_try = [ + f"{domain}/sitemap.xml", + f"{domain}/sitemap_index.xml", + ] + + discovered = [] + + for sitemap_url in sitemap_urls_to_try: + try: + response = requests.get( + sitemap_url, timeout=10, headers={"User-Agent": "SkillSeekers/3.4"} + ) + if response.status_code != 200: + continue + + if "xml" not in response.headers.get("content-type", ""): + continue + + root = ET.fromstring(response.text) + ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"} + + # Handle sitemap index (nested sitemaps) + for sitemap in root.findall(".//sm:sitemap/sm:loc", ns): + try: + sub_resp = requests.get( + sitemap.text.strip(), + timeout=10, + headers={"User-Agent": "SkillSeekers/3.4"}, + ) + if sub_resp.status_code == 200: + sub_root = ET.fromstring(sub_resp.text) + for loc in sub_root.findall(".//sm:url/sm:loc", ns): + url = loc.text.strip().split("#")[0] + if self.is_valid_url(url): + discovered.append(url) + except Exception: + continue + + # Handle direct sitemap + for loc in root.findall(".//sm:url/sm:loc", ns): + url = loc.text.strip().split("#")[0] + if self.is_valid_url(url): + discovered.append(url) + + if discovered: + logger.info(f"๐Ÿ“‹ Found sitemap at {sitemap_url} ({len(discovered)} valid URLs)") + return list(set(discovered)) + + except Exception as e: + logger.debug(f"Sitemap check failed for {sitemap_url}: {e}") + continue + + return [] + + def _discover_spa_nav(self) -> list[str]: + """Layer 3: Render index page with networkidle to discover SPA navigation. + + Used when browser mode is on and sitemap/llms.txt didn't find pages. + Renders the first page with networkidle (slower but discovers full nav), + then normal crawl uses domcontentloaded (fast). + + Returns: + List of discovered valid URLs from the rendered navigation. + """ + if not self.browser_mode: + return [] + + logger.info("๐ŸŒ Rendering index page with networkidle to discover SPA navigation...") + + try: + from skill_seekers.cli.browser_renderer import BrowserRenderer + + # Use a separate renderer with networkidle for discovery only + discovery_renderer = BrowserRenderer( + timeout=60000, + wait_until="networkidle", + extra_wait=3000, # 3s extra for lazy-loaded nav + ) + html = discovery_renderer.render_page(self.base_url) + discovery_renderer.close() + + # Parse rendered DOM for all links + soup = BeautifulSoup(html, "html.parser") + discovered = [] + seen = set() + + for link in soup.find_all("a", href=True): + href = urljoin(self.base_url, link["href"]).split("#")[0] + if href not in seen and self.is_valid_url(href): + seen.add(href) + discovered.append(href) + + if discovered: + logger.info(f"๐ŸŒ Discovered {len(discovered)} pages from rendered SPA navigation") + + return discovered + + except Exception as e: + logger.warning(f"โš ๏ธ SPA navigation discovery failed: {e}") + return [] + def scrape_all(self) -> None: """Scrape all pages (supports llms.txt and HTML scraping) @@ -1102,16 +1233,35 @@ class DocToSkillConverter: asyncio.run(self.scrape_all_async()) return - # Try llms.txt first (unless dry-run or explicitly disabled) - if not self.dry_run and not self.skip_llms_txt: - llms_result = self._try_llms_txt() - if llms_result: - logger.info( - "\nโœ… Used llms.txt (%s) - skipping HTML scraping", - self.llms_txt_variant, - ) - self.save_summary() - return + # === Three-Layer Discovery Engine === + # Discovers pages before the BFS crawl loop starts. + # Layer 1: sitemap.xml โ€” instant, no rendering needed + # Layer 2: llms.txt โ€” existing mechanism + # Layer 3: SPA nav โ€” renders index with networkidle to find JS-rendered links + + if not self.dry_run: + # Layer 1: Try sitemap.xml + sitemap_urls = self._try_sitemap() + if sitemap_urls: + for url in sitemap_urls: + self._enqueue_url(url) + + # Layer 2: Try llms.txt (unless explicitly disabled) + if not sitemap_urls and not self.skip_llms_txt: + llms_result = self._try_llms_txt() + if llms_result: + logger.info( + "\nโœ… Used llms.txt (%s) - skipping HTML scraping", + self.llms_txt_variant, + ) + self.save_summary() + return + + # Layer 3: SPA nav discovery (browser mode only, when other layers found few pages) + if self.browser_mode and len(self.pending_urls) <= 1: + spa_urls = self._discover_spa_nav() + for url in spa_urls: + self._enqueue_url(url) # HTML scraping (sync/thread-based logic) logger.info("\n" + "=" * 60) @@ -2158,7 +2308,7 @@ def setup_argument_parser() -> argparse.ArgumentParser: configs/react.json """ parser = argparse.ArgumentParser( - description="Convert documentation websites to Claude skills", + description="Convert documentation websites to AI skills", formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -2444,11 +2594,11 @@ def execute_scraping_and_building( def execute_enhancement(config: dict[str, Any], args: argparse.Namespace, converter=None) -> None: - """Execute optional SKILL.md enhancement with Claude. + """Execute optional SKILL.md enhancement with AI. Supports two enhancement modes: 1. API-based enhancement (requires ANTHROPIC_API_KEY) - 2. Local enhancement using Claude Code (no API key needed) + 2. Local enhancement using a coding agent CLI (no API key needed) Prints appropriate messages and suggestions based on whether enhancement was requested and whether it succeeded. @@ -2490,10 +2640,11 @@ def execute_enhancement(config: dict[str, Any], args: argparse.Namespace, conver try: enhance_cmd = ["skill-seekers-enhance", f"output/{config['name']}/"] - enhance_cmd.extend(["--enhance-level", str(args.enhance_level)]) if args.api_key: enhance_cmd.extend(["--api-key", args.api_key]) + if getattr(args, "agent", None): + enhance_cmd.extend(["--agent", args.agent]) if getattr(args, "interactive_enhancement", False): enhance_cmd.append("--interactive-enhancement") @@ -2505,9 +2656,8 @@ def execute_enhancement(config: dict[str, Any], args: argparse.Namespace, conver except FileNotFoundError: logger.warning("\nโš  skill-seekers-enhance command not found. Run manually:") logger.info( - " skill-seekers-enhance output/%s/ --enhance-level %d", + " skill-seekers enhance output/%s/", config["name"], - args.enhance_level, ) # Print packaging instructions @@ -2516,8 +2666,8 @@ def execute_enhancement(config: dict[str, Any], args: argparse.Namespace, conver # Suggest enhancement if not done if getattr(args, "enhance_level", 0) == 0: - logger.info("\n๐Ÿ’ก Optional: Enhance SKILL.md with Claude:") - logger.info(" skill-seekers-enhance output/%s/ --enhance-level 2", config["name"]) + logger.info("\n๐Ÿ’ก Optional: Enhance SKILL.md with AI:") + logger.info(" skill-seekers enhance output/%s/", config["name"]) logger.info(" or re-run with: --enhance-level 2 (auto-detects API vs LOCAL mode)") logger.info( " API-based: skill-seekers-enhance-api output/%s/", diff --git a/src/skill_seekers/cli/doctor.py b/src/skill_seekers/cli/doctor.py index 6bac5a9..5fedceb 100644 --- a/src/skill_seekers/cli/doctor.py +++ b/src/skill_seekers/cli/doctor.py @@ -60,7 +60,13 @@ OPTIONAL_DEPS = { } # โ”€โ”€ API keys to check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -API_KEYS = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN", "GOOGLE_API_KEY", "OPENAI_API_KEY"] +API_KEYS = [ + "ANTHROPIC_API_KEY", + "GITHUB_TOKEN", + "GOOGLE_API_KEY", + "OPENAI_API_KEY", + "MOONSHOT_API_KEY", +] def _try_import(module_name: str) -> tuple[bool, str]: diff --git a/src/skill_seekers/cli/enhance_command.py b/src/skill_seekers/cli/enhance_command.py index a9e29b7..a713c8a 100644 --- a/src/skill_seekers/cli/enhance_command.py +++ b/src/skill_seekers/cli/enhance_command.py @@ -4,25 +4,28 @@ Smart Enhancement Dispatcher Routes `skill-seekers enhance` to the correct backend: - API mode โ€” when an API key is available (Claude/Gemini/OpenAI). + API mode โ€” when an API key is available (Anthropic/Gemini/OpenAI). Calls enhance_skill.py which uses platform adaptors. LOCAL mode โ€” when no API key is found. Calls LocalSkillEnhancer from enhance_skill_local.py. + Supports: Claude Code, OpenAI Codex, GitHub Copilot, OpenCode, Kimi, and other agents. Decision priority: 1. Explicit --target flag โ†’ API mode with that platform. 2. Config ai_enhancement.default_agent + matching env key โ†’ API mode. 3. Auto-detect from env vars: ANTHROPIC_API_KEY โ†’ claude, GOOGLE_API_KEY โ†’ gemini, OPENAI_API_KEY โ†’ openai. - 4. No API keys โ†’ LOCAL mode (Claude Code CLI). - 5. LOCAL mode + running as root โ†’ clear error (Claude Code refuses root). + 4. No API keys โ†’ LOCAL mode (AI coding agent). + 5. LOCAL mode + running as root โ†’ clear error (AI coding agent refuses root). """ import os import sys from pathlib import Path +from skill_seekers.cli.agent_client import get_default_timeout + # --------------------------------------------------------------------------- # Helpers @@ -43,6 +46,7 @@ def _get_api_keys() -> dict[str, str | None]: "claude": (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")), "gemini": os.environ.get("GOOGLE_API_KEY"), "openai": os.environ.get("OPENAI_API_KEY"), + "kimi": os.environ.get("MOONSHOT_API_KEY"), } @@ -73,11 +77,11 @@ def _pick_mode(args) -> tuple[str, str | None]: # 2. Config default_agent preference (if a matching key is available). config_agent = _get_config_default_agent() - if config_agent in ("claude", "gemini", "openai") and api_keys.get(config_agent): + if config_agent in ("claude", "gemini", "openai", "kimi") and api_keys.get(config_agent): return "api", config_agent # 3. Auto-detect from environment variables. - # Priority: Claude > Gemini > OpenAI (Claude is Anthropic's native platform). + # Priority: Anthropic > Gemini > OpenAI. if api_keys["claude"]: return "api", "claude" if api_keys["gemini"]: @@ -156,7 +160,7 @@ def _run_local_mode(args) -> int: headless = not interactive success = enhancer.run( headless=headless, - timeout=getattr(args, "timeout", 600), + timeout=getattr(args, "timeout", None) or get_default_timeout(), background=getattr(args, "background", False), daemon=getattr(args, "daemon", False), ) @@ -176,15 +180,15 @@ def main() -> int: parser = argparse.ArgumentParser( description=( "Enhance SKILL.md using AI. " - "Automatically selects API mode (Gemini/OpenAI/Claude API) when an API key " - "is available, or falls back to LOCAL mode (Claude Code CLI)." + "Automatically selects API mode (Anthropic/Gemini/OpenAI API) when an API key " + "is available, or falls back to LOCAL mode (AI coding agent)." ), formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Mode selection (automatic โ€” no flags required): API mode : Set ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY. Or use --target to force a platform. - LOCAL mode: Falls back when no API keys are found. Requires Claude Code CLI. + LOCAL mode: Falls back when no API keys are found. Requires AI coding agent. Does NOT work as root (Docker/VPS) โ€” use API mode instead. Examples: @@ -194,7 +198,7 @@ Examples: # Force Gemini API skill-seekers enhance output/react/ --target gemini - # Force Claude API with explicit key + # Force Anthropic API with explicit key skill-seekers enhance output/react/ --target claude --api-key sk-ant-... # LOCAL mode options @@ -244,10 +248,10 @@ Examples: if _is_root(): print("โŒ Cannot run LOCAL enhancement as root.") print() - print(" Claude Code CLI refuses to execute as root (Docker/VPS security policy).") + print(" AI coding agent refuses to execute as root (Docker/VPS security policy).") print(" Use API mode instead by setting one of these environment variables:") print() - print(" export ANTHROPIC_API_KEY=sk-ant-... # Claude") + print(" export ANTHROPIC_API_KEY=sk-ant-... # Anthropic") print(" export GOOGLE_API_KEY=AIza... # Gemini") print(" export OPENAI_API_KEY=sk-proj-... # OpenAI") print() @@ -255,7 +259,8 @@ Examples: print(f" skill-seekers enhance {args.skill_directory}") return 1 - print("๐Ÿค– Enhancement mode: LOCAL (Claude Code CLI)") + agent_name = os.environ.get("SKILL_SEEKER_AGENT", "claude").strip() or "claude" + print(f"๐Ÿค– Enhancement mode: LOCAL ({agent_name})") return _run_local_mode(args) diff --git a/src/skill_seekers/cli/enhance_skill.py b/src/skill_seekers/cli/enhance_skill.py index 0523eab..b9f020a 100644 --- a/src/skill_seekers/cli/enhance_skill.py +++ b/src/skill_seekers/cli/enhance_skill.py @@ -4,7 +4,7 @@ SKILL.md Enhancement Script Uses platform AI APIs to improve SKILL.md by analyzing reference documentation. Usage: - # Claude (default) + # Anthropic (default) skill-seekers enhance output/react/ skill-seekers enhance output/react/ --api-key sk-ant-... @@ -63,17 +63,17 @@ class SkillEnhancer: return self.skill_md_path.read_text(encoding="utf-8") def enhance_skill_md(self, references, current_skill_md): - """Use Claude to enhance SKILL.md""" + """Use AI to enhance SKILL.md""" # Build prompt prompt = self._build_enhancement_prompt(references, current_skill_md) - print("\n๐Ÿค– Asking Claude to enhance SKILL.md...") + print("\n๐Ÿค– Asking AI to enhance SKILL.md...") print(f" Input: {len(prompt):,} characters") try: message = self.client.messages.create( - model="claude-sonnet-4-20250514", + model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"), max_tokens=4096, temperature=0.3, messages=[{"role": "user", "content": prompt}], @@ -94,7 +94,7 @@ class SkillEnhancer: return enhanced_content except Exception as e: - print(f"โŒ Error calling Claude API: {e}") + print(f"โŒ Error calling AI API: {e}") return None def _is_video_source(self, references): @@ -102,7 +102,7 @@ class SkillEnhancer: return any(meta["source"] == "video_tutorial" for meta in references.values()) def _build_enhancement_prompt(self, references, current_skill_md): - """Build the prompt for Claude with multi-source awareness""" + """Build the prompt for AI with multi-source awareness""" # Dispatch to video-specific prompt if video source detected if self._is_video_source(references): @@ -119,7 +119,7 @@ class SkillEnhancer: # Analyze conflicts if present has_conflicts = any("conflicts" in meta["path"] for meta in references.values()) - prompt = f"""You are enhancing a Claude skill's SKILL.md file. This skill is about: {skill_name} + prompt = f"""You are enhancing an LLM skill's SKILL.md file. This skill is about: {skill_name} I've scraped documentation from multiple sources and organized it into reference files. Your job is to create an EXCELLENT SKILL.md that synthesizes knowledge from these sources. @@ -275,7 +275,7 @@ IMPORTANT: - Prioritize SHORT, clear examples (5-20 lines max) - Make it actionable and practical - Don't be too verbose - be concise but useful -- Maintain the markdown structure for Claude skills +- Maintain the markdown structure for LLM skills - Keep code examples properly formatted with language tags OUTPUT: @@ -294,7 +294,7 @@ Return ONLY the complete SKILL.md content, starting with the frontmatter (---). """ skill_name = self.skill_dir.name - prompt = f"""You are enhancing a Claude skill built from VIDEO TUTORIAL extraction. This skill is about: {skill_name} + prompt = f"""You are enhancing an LLM skill built from VIDEO TUTORIAL extraction. This skill is about: {skill_name} The raw data was extracted from video tutorials using: 1. **Transcript** (speech-to-text) โ€” HIGH quality, this is the primary signal @@ -471,7 +471,7 @@ Return ONLY the complete SKILL.md content, starting with the frontmatter (---). else: print(" โ„น No existing SKILL.md, will create new one") - # Enhance with Claude + # Enhance with AI enhanced = self.enhance_skill_md(references, current_skill_md) if not enhanced: @@ -502,7 +502,7 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - # Claude (default) + # Anthropic (default) export ANTHROPIC_API_KEY=sk-ant-... skill-seekers enhance output/react/ @@ -530,9 +530,9 @@ Examples: ) parser.add_argument( "--target", - choices=["claude", "gemini", "openai"], - default="claude", - help="Target LLM platform (default: claude)", + choices=["claude", "gemini", "openai", "kimi"], + default=None, + help="Target LLM platform (auto-detected from API keys, or 'claude' if none set)", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be done without calling API" @@ -540,6 +540,12 @@ Examples: args = parser.parse_args() + # Auto-detect target platform if not specified + if args.target is None: + from skill_seekers.cli.agent_client import AgentClient + + args.target = AgentClient.detect_default_target() + # Validate skill directory skill_dir = Path(args.skill_dir) if not skill_dir.exists(): @@ -578,7 +584,7 @@ Examples: if not adaptor.supports_enhancement(): print(f"โŒ Error: {adaptor.PLATFORM_NAME} does not support AI enhancement") print("\nSupported platforms for enhancement:") - print(" - Claude AI (Anthropic)") + print(" - Anthropic (Claude AI)") print(" - Google Gemini") print(" - OpenAI ChatGPT") sys.exit(1) diff --git a/src/skill_seekers/cli/enhance_skill_local.py b/src/skill_seekers/cli/enhance_skill_local.py index a4d2ee2..020fcb5 100644 --- a/src/skill_seekers/cli/enhance_skill_local.py +++ b/src/skill_seekers/cli/enhance_skill_local.py @@ -132,6 +132,12 @@ AGENT_PRESETS = { "command": ["opencode"], "supports_skip_permissions": False, }, + "kimi": { + "display_name": "Kimi Code CLI", + "command": ["kimi", "--print", "--input-format", "text", "--work-dir", "{skill_dir}"], + "supports_skip_permissions": False, + "uses_stdin": True, + }, } @@ -146,6 +152,7 @@ def _normalize_agent_name(agent_name: str) -> str: "copilot-cli": "copilot", "open-code": "opencode", "open_code": "opencode", + "kimi-cli": "kimi", } return aliases.get(normalized, normalized) @@ -238,6 +245,8 @@ class LocalSkillEnhancer: if "{prompt_file}" in arg: cmd_parts[idx] = arg.replace("{prompt_file}", prompt_file) uses_prompt_file = True + if "{skill_dir}" in arg: + cmd_parts[idx] = arg.replace("{skill_dir}", str(self.skill_dir.resolve())) return cmd_parts, uses_prompt_file @@ -652,7 +661,7 @@ After writing, the file SKILL.md should: except Exception: return None - def run(self, headless=True, timeout=600, background=False, daemon=False): + def run(self, headless=True, timeout=2700, background=False, daemon=False): """Main enhancement workflow with automatic smart summarization for large skills. Automatically detects large skills (>30K chars) and applies smart summarization @@ -666,7 +675,7 @@ After writing, the file SKILL.md should: Args: headless: If True, run local agent directly without opening terminal (default: True) - timeout: Maximum time to wait for enhancement in seconds (default: 600 = 10 minutes) + timeout: Maximum time to wait for enhancement in seconds (default: 2700 = 45 minutes) background: If True, run in background and return immediately (default: False) daemon: If True, run as persistent daemon with monitoring (default: False) @@ -711,7 +720,7 @@ After writing, the file SKILL.md should: print("โš ๏ธ LARGE SKILL DETECTED") print(f" ๐Ÿ“Š Reference content: {total_size:,} characters") if self.agent == "claude": - print(" ๐Ÿ’ก Claude CLI limit: ~30,000-40,000 characters") + print(" ๐Ÿ’ก CLI agent limit: ~30,000-40,000 characters") else: print(" ๐Ÿ’ก Local CLI agents often have input limits; summarizing to be safe") print() @@ -739,7 +748,7 @@ After writing, the file SKILL.md should: if use_summarization: print(f" โœ“ Prompt created and optimized ({len(prompt):,} characters)") if self.agent == "claude": - print(" โœ“ Ready for Claude CLI (within safe limits)") + print(" โœ“ Ready for CLI agent (within safe limits)") else: print(" โœ“ Ready for local CLI (within safe limits)") print() @@ -905,6 +914,45 @@ rm {prompt_file} print("โŒ SKILL.md not found after enhancement") return False else: + # Exit code 75 = EX_TEMPFAIL (retryable temporary failure from Kimi CLI) + if result.returncode == 75: + print(f"โš ๏ธ {self.agent_display} returned temporary failure (exit code: 75)") + print( + " This usually means a transient API issue (timeout, rate limit, or empty response)." + ) + print(" Retrying once in 5 seconds...") + print() + time.sleep(5) + result_retry, error = self._run_agent_command( + prompt_file, timeout, include_permissions_flag=True + ) + if error: + print(f"โŒ {error}") + with contextlib.suppress(Exception): + os.unlink(prompt_file) + return False + if result_retry.returncode == 0: + elapsed = time.time() - start_time + if self.skill_md_path.exists(): + new_mtime = self.skill_md_path.stat().st_mtime + new_size = self.skill_md_path.stat().st_size + if new_mtime > initial_mtime and new_size > initial_size: + print(f"โœ… Enhancement complete on retry! ({elapsed:.1f} seconds)") + print(f" SKILL.md updated: {new_size:,} bytes") + print() + with contextlib.suppress(Exception): + os.unlink(prompt_file) + return True + print(f"โŒ Retry also failed (exit code: {result_retry.returncode})") + if result_retry.stderr: + stderr_lines = result_retry.stderr.strip().split("\n") + for line in stderr_lines[:10]: + print(f" | {line}") + print(" Try again later or use API mode:") + print(" export ANTHROPIC_API_KEY=sk-ant-...") + print(f" skill-seekers enhance {self.skill_dir} --target claude") + return False + print(f"โŒ {self.agent_display} returned error (exit code: {result.returncode})") if result.stderr: stderr_lines = result.stderr.strip().split("\n") @@ -919,7 +967,7 @@ rm {prompt_file} ): print() print(" โš ๏ธ This looks like a root/permission error.") - print(" Claude Code CLI refuses to run as root (security policy).") + print(" The CLI agent refuses to run as root (security policy).") print(" Use API mode instead:") print(" export ANTHROPIC_API_KEY=sk-ant-...") print(f" skill-seekers enhance {self.skill_dir} --target claude") @@ -1289,10 +1337,10 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Auto-detection (no flags needed): - If ANTHROPIC_API_KEY is set โ†’ Claude API mode + If ANTHROPIC_API_KEY is set โ†’ Anthropic API mode If GOOGLE_API_KEY is set โ†’ Gemini API mode If OPENAI_API_KEY is set โ†’ OpenAI API mode - Otherwise โ†’ LOCAL mode (Claude Code Max, free) + Otherwise โ†’ LOCAL mode (coding agent CLI, free) Examples: # Auto-detect mode based on env vars (recommended) @@ -1373,11 +1421,16 @@ Force Mode (LOCAL only, Default ON): help="Disable force mode: enable confirmation prompts (default: force mode ON)", ) + from skill_seekers.cli.agent_client import get_default_timeout + parser.add_argument( "--timeout", type=int, - default=600, - help="Timeout in seconds for headless mode (default: 600 = 10 minutes)", + default=get_default_timeout(), + help=( + "Timeout in seconds for headless mode " + "(default: 45 minutes, set SKILL_SEEKER_ENHANCE_TIMEOUT to override)" + ), ) args = parser.parse_args() diff --git a/src/skill_seekers/cli/enhancement_workflow.py b/src/skill_seekers/cli/enhancement_workflow.py index 3658a17..ccb50e0 100644 --- a/src/skill_seekers/cli/enhancement_workflow.py +++ b/src/skill_seekers/cli/enhancement_workflow.py @@ -84,12 +84,13 @@ class WorkflowEngine: - Target specific parts of the analysis """ - def __init__(self, workflow: EnhancementWorkflow | str | Path): + def __init__(self, workflow: EnhancementWorkflow | str | Path, agent: str | None = None): """ Initialize workflow engine. Args: workflow: EnhancementWorkflow object or path to YAML file + agent: Local CLI agent name (e.g., "kimi", "claude") """ if isinstance(workflow, (str, Path)): self.workflow = self._load_workflow(workflow) @@ -98,6 +99,7 @@ class WorkflowEngine: self.history: list[dict[str, Any]] = [] self.enhancer = None # Lazy load UnifiedEnhancer + self.agent = agent def _load_workflow(self, workflow_ref: str | Path) -> EnhancementWorkflow: """Load workflow from YAML file using 3-level search order. @@ -350,15 +352,23 @@ class WorkflowEngine: current = context["current_results"] # Determine what to enhance based on target - if stage.target == "patterns" and "patterns" in current: - enhancer = PatternEnhancer() - enhanced_patterns = enhancer.enhance_patterns(current["patterns"]) - return {"patterns": enhanced_patterns} + if stage.target == "patterns": + if "patterns" in current: + enhancer = PatternEnhancer(agent=self.agent) + enhanced_patterns = enhancer.enhance_patterns(current["patterns"]) + return {"patterns": enhanced_patterns} + else: + logger.info(f" โ„น๏ธ No {stage.target} data available, skipping builtin stage") + return {} - elif stage.target == "examples" and "examples" in current: - enhancer = TestExampleEnhancer() - enhanced_examples = enhancer.enhance_examples(current["examples"]) - return {"examples": enhanced_examples} + elif stage.target == "examples": + if "examples" in current: + enhancer = TestExampleEnhancer(agent=self.agent) + enhanced_examples = enhancer.enhance_examples(current["examples"]) + return {"examples": enhanced_examples} + else: + logger.info(f" โ„น๏ธ No {stage.target} data available, skipping builtin stage") + return {} else: logger.warning(f"Unknown builtin target: {stage.target}") @@ -374,7 +384,7 @@ class WorkflowEngine: if not self.enhancer: from skill_seekers.cli.ai_enhancer import AIEnhancer - self.enhancer = AIEnhancer() + self.enhancer = AIEnhancer(agent=self.agent) # Format prompt with context try: @@ -385,7 +395,11 @@ class WorkflowEngine: # Call AI with custom prompt logger.info(f" ๐Ÿค– Running custom AI prompt...") - response = self.enhancer._call_claude(formatted_prompt, max_tokens=3000) + # Use call() (agent-agnostic) with _call_claude() as fallback for older enhancers + if hasattr(self.enhancer, "call"): + response = self.enhancer.call(formatted_prompt, max_tokens=3000) + else: + response = self.enhancer._call_claude(formatted_prompt, max_tokens=3000) if not response: logger.warning(f" โš ๏ธ No response from AI") diff --git a/src/skill_seekers/cli/epub_scraper.py b/src/skill_seekers/cli/epub_scraper.py index 545831f..70adaa6 100644 --- a/src/skill_seekers/cli/epub_scraper.py +++ b/src/skill_seekers/cli/epub_scraper.py @@ -69,7 +69,7 @@ def infer_description_from_epub(metadata: dict | None = None, name: str = "") -> class EpubToSkillConverter: - """Convert EPUB e-book to Claude skill.""" + """Convert EPUB e-book to AI skill.""" def __init__(self, config): self.config = config @@ -1179,13 +1179,17 @@ def main(): print("โŒ API enhancement not available. Falling back to LOCAL mode...") from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/generate_router.py b/src/skill_seekers/cli/generate_router.py index f9598dc..60a82e6 100644 --- a/src/skill_seekers/cli/generate_router.py +++ b/src/skill_seekers/cli/generate_router.py @@ -1202,7 +1202,7 @@ Examples: print(f" skill-seekers scrape --config {config_path}") print("3. Package router skill:") print(f" skill-seekers package output/{generator.router_name}/") - print("4. Upload router + all sub-skills to Claude") + print("4. Upload router + all sub-skills to target platform") print("") diff --git a/src/skill_seekers/cli/github_scraper.py b/src/skill_seekers/cli/github_scraper.py index b5c63b2..6afe6ca 100644 --- a/src/skill_seekers/cli/github_scraper.py +++ b/src/skill_seekers/cli/github_scraper.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -GitHub Repository to Claude Skill Converter (Tasks C1.1-C1.12) +GitHub Repository to AI Skill Converter (Tasks C1.1-C1.12) -Converts GitHub repositories into Claude AI skills by extracting: +Converts GitHub repositories into AI skills by extracting: - README and documentation - Code structure and signatures - GitHub Issues, Changelog, and Releases @@ -714,17 +714,7 @@ class GitHubScraper: logger.info(f"Extracting code signatures ({self.code_analysis_depth} analysis)...") - # Get primary language for the repository - languages = self.extracted_data.get("languages", {}) - if not languages: - logger.warning("No languages detected - skipping code analysis") - return - - # Determine primary language - primary_language = max(languages.items(), key=lambda x: x[1]["bytes"])[0] - logger.info(f"Primary language: {primary_language}") - - # Determine file extensions to analyze + # Build reverse extension โ†’ language map for per-file detection extension_map = { "Python": [".py"], "JavaScript": [".js", ".jsx"], @@ -733,72 +723,86 @@ class GitHubScraper: "Java": [".java"], "C": [".c", ".h"], "C++": [".cpp", ".hpp", ".cc", ".hh", ".cxx"], + "C#": [".cs"], + "Go": [".go"], + "Rust": [".rs"], + "Swift": [".swift"], + "Ruby": [".rb"], + "PHP": [".php"], + "GDScript": [".gd"], } + ext_to_lang = {} + for lang, exts in extension_map.items(): + for ext in exts: + ext_to_lang[ext] = lang - extensions = extension_map.get(primary_language, []) - if not extensions: - logger.warning(f"No file extensions mapped for {primary_language}") - return + # Optional: filter to specific languages from config + target_languages = None + config_language = self.config.get("language", "") + if config_language: + target_languages = {lang.strip() for lang in config_language.split(",")} + logger.info(f"Language filter from config: {', '.join(sorted(target_languages))}") - # Analyze files matching patterns and extensions + # Analyze ALL files, detecting language per-file from extension analyzed_files = [] file_tree = self.extracted_data.get("file_tree", []) + languages_found = set() for file_info in file_tree: file_path = file_info["path"] - # Check if file matches extension - if not any(file_path.endswith(ext) for ext in extensions): + # Detect language from file extension + ext = os.path.splitext(file_path)[1].lower() + language = ext_to_lang.get(ext) + if not language: continue - # Check if file matches patterns (if specified) + # Apply language filter if config specifies target languages + if target_languages and language not in target_languages: + continue + + # Check if file matches patterns (if specified in config) if self.file_patterns and not any( fnmatch.fnmatch(file_path, pattern) for pattern in self.file_patterns ): continue - # Analyze this file + # Analyze this file with the correct language try: - # Read file content based on mode if self.local_repo_path: - # Local mode - read from filesystem full_path = os.path.join(self.local_repo_path, file_path) with open(full_path, encoding="utf-8") as f: content = f.read() else: - # GitHub API mode - fetch from API file_content = self.repo.get_contents(file_path) content = file_content.decoded_content.decode("utf-8") - analysis_result = self.code_analyzer.analyze_file( - file_path, content, primary_language - ) + analysis_result = self.code_analyzer.analyze_file(file_path, content, language) if analysis_result and ( analysis_result.get("classes") or analysis_result.get("functions") ): analyzed_files.append( - {"file": file_path, "language": primary_language, **analysis_result} - ) - - logger.debug( - f"Analyzed {file_path}: " - f"{len(analysis_result.get('classes', []))} classes, " - f"{len(analysis_result.get('functions', []))} functions" + {"file": file_path, "language": language, **analysis_result} ) + languages_found.add(language) except Exception as e: logger.debug(f"Could not analyze {file_path}: {e}") continue - # Limit number of files analyzed to avoid rate limits (GitHub API mode only) - if not self.local_repo_path and len(analyzed_files) >= 50: - logger.info("Reached analysis limit (50 files, GitHub API mode)") - break + # Determine primary language for backward compat in output + repo_languages = self.extracted_data.get("languages", {}) + primary_language = ( + max(repo_languages.items(), key=lambda x: x[1]["bytes"])[0] + if repo_languages + else "Unknown" + ) self.extracted_data["code_analysis"] = { "depth": self.code_analysis_depth, "language": primary_language, + "languages_analyzed": sorted(languages_found), "files_analyzed": len(analyzed_files), "files": analyzed_files, } @@ -807,8 +811,9 @@ class GitHubScraper: total_classes = sum(len(f.get("classes", [])) for f in analyzed_files) total_functions = sum(len(f.get("functions", [])) for f in analyzed_files) + lang_summary = ", ".join(sorted(languages_found)) if languages_found else "none" logger.info( - f"Code analysis complete: {len(analyzed_files)} files, {total_classes} classes, {total_functions} functions" + f"Code analysis complete: {len(analyzed_files)} files, {total_classes} classes, {total_functions} functions ({lang_summary})" ) def _extract_issues(self): @@ -913,7 +918,7 @@ class GitHubScraper: class GitHubToSkillConverter: """ - Convert extracted GitHub data to Claude skill format (C1.10). + Convert extracted GitHub data to AI skill format (C1.10). """ def __init__(self, config: dict[str, Any]): @@ -1387,7 +1392,7 @@ def setup_argument_parser() -> argparse.ArgumentParser: argparse.ArgumentParser: Configured argument parser """ parser = argparse.ArgumentParser( - description="GitHub Repository to Claude Skill Converter", + description="GitHub Repository to AI Skill Converter", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -1518,23 +1523,29 @@ def main(): from pathlib import Path from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) - logger.info("โœ… Local enhancement complete!") + agent_name = agent or "claude" + logger.info(f"โœ… Local enhancement complete! (via {agent_name})") else: # LOCAL enhancement (no API key) from pathlib import Path from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) - logger.info("โœ… Local enhancement complete!") + agent_name = agent or "claude" + logger.info(f"โœ… Local enhancement complete! (via {agent_name})") logger.info(f"\nโœ… Success! Skill created at: {skill_dir}/") # Only suggest enhancement if neither workflow nor traditional enhancement was done if not workflow_executed and getattr(args, "enhance_level", 0) == 0: - logger.info("\n๐Ÿ’ก Optional: Enhance SKILL.md with Claude:") + logger.info("\n๐Ÿ’ก Optional: Enhance SKILL.md with AI:") logger.info(f" skill-seekers enhance {skill_dir}/ --enhance-level 2") logger.info(" (auto-detects API vs LOCAL mode based on ANTHROPIC_API_KEY)") logger.info("\n๐Ÿ’ก Or use a workflow:") diff --git a/src/skill_seekers/cli/guide_enhancer.py b/src/skill_seekers/cli/guide_enhancer.py index 6ebddca..393a397 100644 --- a/src/skill_seekers/cli/guide_enhancer.py +++ b/src/skill_seekers/cli/guide_enhancer.py @@ -2,8 +2,8 @@ AI Enhancement for How-To Guides (C3.3) This module provides comprehensive AI enhancement for how-to guides with dual-mode support: -- API mode: Uses Claude API (requires ANTHROPIC_API_KEY) -- LOCAL mode: Uses Claude Code CLI (no API key needed) +- API mode: Uses Anthropic API (requires ANTHROPIC_API_KEY) +- LOCAL mode: Uses a coding agent CLI (no API key needed) Provides 5 automatic enhancements: 1. Step Descriptions - Natural language explanations (not just syntax) @@ -15,11 +15,7 @@ Provides 5 automatic enhancements: import json import logging -import os -import subprocess -import tempfile from dataclasses import dataclass, field -from pathlib import Path from typing import TYPE_CHECKING # Avoid circular imports by using TYPE_CHECKING @@ -47,14 +43,8 @@ else: logger = logging.getLogger(__name__) -# Conditional import for Anthropic API -try: - import anthropic - - ANTHROPIC_AVAILABLE = True -except ImportError: - ANTHROPIC_AVAILABLE = False - logger.debug("Anthropic library not available - API mode will be unavailable") +# ANTHROPIC_AVAILABLE kept for backward compatibility โ€” AgentClient handles detection +ANTHROPIC_AVAILABLE = True # Detection delegated to AgentClient @dataclass @@ -71,8 +61,8 @@ class GuideEnhancer: AI enhancement for how-to guides with dual-mode support. Modes: - - api: Uses Claude API (requires ANTHROPIC_API_KEY) - - local: Uses Claude Code CLI (no API key needed) + - api: Uses Anthropic API (requires ANTHROPIC_API_KEY) + - local: Uses a coding agent CLI (no API key needed) - auto: Automatically detect best mode """ @@ -83,77 +73,17 @@ class GuideEnhancer: Args: mode: Enhancement mode - "api", "local", or "auto" """ - self.mode = self._detect_mode(mode) - self.api_key = os.environ.get("ANTHROPIC_API_KEY") - self.client = None + from skill_seekers.cli.agent_client import AgentClient - if self.mode == "api": - if ANTHROPIC_AVAILABLE and self.api_key: - # Support custom base_url for GLM-4.7 and other Claude-compatible APIs - client_kwargs = {"api_key": self.api_key} - base_url = os.environ.get("ANTHROPIC_BASE_URL") - if base_url: - client_kwargs["base_url"] = base_url - logger.info(f"โœ… Using custom API base URL: {base_url}") - self.client = anthropic.Anthropic(**client_kwargs) - logger.info("โœจ GuideEnhancer initialized in API mode") - else: - logger.warning( - "โš ๏ธ API mode requested but anthropic library not available or no API key" - ) - self.mode = "none" - elif self.mode == "local": - # Check if claude CLI is available - if not self._check_claude_cli(): - logger.warning("โš ๏ธ Claude CLI not found - falling back to API mode") - self.mode = "api" - if ANTHROPIC_AVAILABLE and self.api_key: - # Support custom base_url for GLM-4.7 and other Claude-compatible APIs - client_kwargs = {"api_key": self.api_key} - base_url = os.environ.get("ANTHROPIC_BASE_URL") - if base_url: - client_kwargs["base_url"] = base_url - logger.info(f"โœ… Using custom API base URL: {base_url}") - self.client = anthropic.Anthropic(**client_kwargs) - else: - logger.warning("โš ๏ธ API fallback also unavailable") - self.mode = "none" - else: - logger.info("โœจ GuideEnhancer initialized in LOCAL mode") + self._agent = AgentClient(mode=mode) + self.mode = self._agent.mode + + if self._agent.is_available(): + self._agent.log_mode() else: - logger.warning("โš ๏ธ No AI enhancement available (no API key or Claude CLI)") + logger.warning("โš ๏ธ No AI enhancement available") self.mode = "none" - def _detect_mode(self, requested_mode: str) -> str: - """ - Detect the best enhancement mode. - - Args: - requested_mode: User-requested mode - - Returns: - Detected mode: "api", "local", or "none" - """ - if requested_mode == "auto": - # Prefer API if key available, else LOCAL - if os.environ.get("ANTHROPIC_API_KEY") and ANTHROPIC_AVAILABLE: - return "api" - elif self._check_claude_cli(): - return "local" - else: - return "none" - return requested_mode - - def _check_claude_cli(self) -> bool: - """Check if Claude Code CLI is available.""" - try: - result = subprocess.run( - ["claude", "--version"], capture_output=True, text=True, timeout=5 - ) - return result.returncode == 0 - except (FileNotFoundError, subprocess.TimeoutExpired): - return False - def enhance_guide(self, guide_data: dict) -> dict: """ Apply all 5 enhancements to a guide. @@ -332,7 +262,7 @@ class GuideEnhancer: def _call_ai(self, prompt: str, max_tokens: int = 4000) -> str | None: """ - Call AI with the given prompt. + Call AI with the given prompt via AgentClient. Args: prompt: Prompt text @@ -341,73 +271,7 @@ class GuideEnhancer: Returns: AI response text or None if failed """ - if self.mode == "api": - return self._call_claude_api(prompt, max_tokens) - elif self.mode == "local": - return self._call_claude_local(prompt) - return None - - def _call_claude_api(self, prompt: str, max_tokens: int = 4000) -> str | None: - """ - Call Claude API. - - Args: - prompt: Prompt text - max_tokens: Maximum tokens in response - - Returns: - API response text or None if failed - """ - if not self.client: - return None - - try: - response = self.client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=max_tokens, - messages=[{"role": "user", "content": prompt}], - ) - return response.content[0].text - except Exception as e: - logger.warning(f"โš ๏ธ Claude API call failed: {e}") - return None - - def _call_claude_local(self, prompt: str) -> str | None: - """ - Call Claude Code CLI. - - Args: - prompt: Prompt text - - Returns: - CLI response text or None if failed - """ - try: - # Create temporary prompt file - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: - f.write(prompt) - prompt_file = f.name - - # Run claude CLI - result = subprocess.run( - ["claude", prompt_file], - capture_output=True, - text=True, - timeout=300, # 5 min timeout - ) - - # Clean up prompt file - Path(prompt_file).unlink(missing_ok=True) - - if result.returncode == 0: - return result.stdout - else: - logger.warning(f"โš ๏ธ Claude CLI failed: {result.stderr}") - return None - - except (subprocess.TimeoutExpired, Exception) as e: - logger.warning(f"โš ๏ธ Claude CLI execution failed: {e}") - return None + return self._agent.call(prompt, max_tokens=max_tokens) # === Prompt Creation Methods === @@ -422,7 +286,7 @@ class GuideEnhancer: Enhanced guide data """ prompt = self._create_enhancement_prompt(guide_data) - response = self._call_claude_api(prompt) + response = self._call_ai(prompt) if not response: return guide_data diff --git a/src/skill_seekers/cli/html_scraper.py b/src/skill_seekers/cli/html_scraper.py index 1109c16..5d1a5fd 100644 --- a/src/skill_seekers/cli/html_scraper.py +++ b/src/skill_seekers/cli/html_scraper.py @@ -1785,7 +1785,7 @@ def main() -> int: "0=disabled (default for HTML), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "otherwise LOCAL (Claude Code, Kimi, etc.)" ) # HTML-specific args @@ -1907,7 +1907,9 @@ def main() -> int: LocalSkillEnhancer, ) - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: @@ -1915,7 +1917,9 @@ def main() -> int: LocalSkillEnhancer, ) - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/install_agent.py b/src/skill_seekers/cli/install_agent.py index 47f187f..efa5f3e 100644 --- a/src/skill_seekers/cli/install_agent.py +++ b/src/skill_seekers/cli/install_agent.py @@ -301,6 +301,8 @@ def install_to_agent( msg += "Restart Cursor to load the new skill." elif agent_name.lower() in ["vscode", "copilot"]: msg += "Restart VS Code to load the new skill." + elif agent_name.lower() == "kimi-code": + msg += "Restart Kimi Code to load the new skill." else: msg += f"Restart {agent_name.capitalize()} to load the new skill." diff --git a/src/skill_seekers/cli/install_skill.py b/src/skill_seekers/cli/install_skill.py index c23c7e7..996ffb3 100644 --- a/src/skill_seekers/cli/install_skill.py +++ b/src/skill_seekers/cli/install_skill.py @@ -74,7 +74,7 @@ Examples: # Preview workflow (dry run) skill-seekers install --config react --dry-run - # Install for Gemini instead of Claude + # Install for Gemini instead of default platform skill-seekers install --config react --target gemini # Install for OpenAI ChatGPT @@ -107,7 +107,9 @@ Phases: 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( + "--no-upload", action="store_true", help="Skip automatic upload to target platform" + ) parser.add_argument( "--unlimited", @@ -119,13 +121,19 @@ Phases: parser.add_argument( "--target", - choices=["claude", "gemini", "openai", "markdown"], - default="claude", - help="Target LLM platform (default: claude)", + choices=["claude", "gemini", "openai", "kimi", "markdown"], + default=None, + help="Target LLM platform (auto-detected from API keys, or 'claude' if none set)", ) args = parser.parse_args() + # Auto-detect target platform if not specified + if args.target is None: + from skill_seekers.cli.agent_client import AgentClient + + args.target = AgentClient.detect_default_target() + # 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: diff --git a/src/skill_seekers/cli/jupyter_scraper.py b/src/skill_seekers/cli/jupyter_scraper.py index ab7563e..663dea5 100644 --- a/src/skill_seekers/cli/jupyter_scraper.py +++ b/src/skill_seekers/cli/jupyter_scraper.py @@ -1182,13 +1182,17 @@ def main() -> int: print("โŒ API enhancement not available. Falling back to LOCAL mode...") from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/main.py b/src/skill_seekers/cli/main.py index e46f9e5..f0d41d4 100644 --- a/src/skill_seekers/cli/main.py +++ b/src/skill_seekers/cli/main.py @@ -30,7 +30,7 @@ Commands: enhance AI-powered enhancement (auto: API or LOCAL mode) enhance-status Check enhancement status (for background/daemon modes) package Package skill into .zip file - upload Upload skill to Claude + upload Upload skill to target platform estimate Estimate page count before scraping extract-test-examples Extract usage examples from test files install-agent Install skill to AI agent directories @@ -47,6 +47,7 @@ Examples: import argparse import importlib +import os import sys from pathlib import Path @@ -101,7 +102,7 @@ def create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="skill-seekers", - description="Convert documentation, GitHub repos, and PDFs into Claude AI skills", + description="Convert documentation, GitHub repos, and PDFs into AI skills", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -391,7 +392,7 @@ def _handle_analyze_command(args: argparse.Namespace) -> int: background=False, daemon=False, no_force=False, - timeout=600, + timeout=2700, ) _mode, _target = _pick_mode(_fake_args) @@ -403,7 +404,10 @@ def _handle_analyze_command(args: argparse.Namespace) -> int: print(" Set ANTHROPIC_API_KEY / GOOGLE_API_KEY to enable API mode") success = False else: - print("\n๐Ÿค– Enhancement mode: LOCAL (Claude Code CLI)") + agent_name = ( + os.environ.get("SKILL_SEEKER_AGENT", "claude").strip() or "claude" + ) + print(f"\n๐Ÿค– Enhancement mode: LOCAL ({agent_name})") success = _run_local_mode(_fake_args) == 0 if success: diff --git a/src/skill_seekers/cli/man_scraper.py b/src/skill_seekers/cli/man_scraper.py index c48492d..42b77b1 100644 --- a/src/skill_seekers/cli/man_scraper.py +++ b/src/skill_seekers/cli/man_scraper.py @@ -1332,7 +1332,7 @@ def main() -> int: "0=disabled (default for man), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "otherwise LOCAL (Claude Code, Kimi, etc.)" ) # Man-specific arguments @@ -1486,13 +1486,17 @@ def main() -> int: print("โŒ API enhancement not available. Falling back to LOCAL mode...") from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/merge_sources.py b/src/skill_seekers/cli/merge_sources.py index f832d3e..15bfff5 100644 --- a/src/skill_seekers/cli/merge_sources.py +++ b/src/skill_seekers/cli/merge_sources.py @@ -4,7 +4,7 @@ Source Merger for Multi-Source Skills Merges documentation and code data intelligently with GitHub insights: - Rule-based merge: Fast, deterministic rules -- Claude-enhanced merge: AI-powered reconciliation +- AI-enhanced merge: AI-powered reconciliation Handles conflicts and creates unified API reference with GitHub metadata. @@ -18,7 +18,6 @@ Multi-layer architecture (Phase 3): import json import logging import os -import subprocess import tempfile from typing import Any, Optional @@ -440,11 +439,11 @@ class RuleBasedMerger: return signature -class ClaudeEnhancedMerger: +class AIEnhancedMerger: """ - Claude-enhanced API merger using local Claude Code with GitHub insights. + AI-enhanced API merger using local AI coding agent with GitHub insights. - Opens Claude Code in a new terminal to intelligently reconcile conflicts. + Uses the configured AI agent to intelligently reconcile conflicts. Uses the same approach as enhance_skill_local.py. Multi-layer architecture (Phase 3): @@ -462,7 +461,7 @@ class ClaudeEnhancedMerger: github_streams: Optional["ThreeStreamData"] = None, ): """ - Initialize Claude-enhanced merger with GitHub streams support. + Initialize AI-enhanced merger with GitHub streams support. Args: docs_data: Documentation scraper data (Layer 2: HTML docs) @@ -480,31 +479,31 @@ class ClaudeEnhancedMerger: def merge_all(self) -> dict[str, Any]: """ - Merge all APIs using Claude enhancement. + Merge all APIs using AI enhancement. Returns: Dict containing merged API data """ - logger.info("Starting Claude-enhanced merge...") + logger.info("Starting AI-enhanced merge...") # Create temporary workspace workspace_dir = self._create_workspace() - # Launch Claude Code for enhancement - logger.info("Launching Claude Code for intelligent merging...") - logger.info("Claude will analyze conflicts and create reconciled API reference") + # Launch AI agent for enhancement + logger.info("Launching AI agent for intelligent merging...") + logger.info("AI will analyze conflicts and create reconciled API reference") try: - self._launch_claude_merge(workspace_dir) + self._launch_ai_merge(workspace_dir) # Read enhanced results merged_data = self._read_merged_results(workspace_dir) - logger.info("Claude-enhanced merge complete") + logger.info("AI-enhanced merge complete") return merged_data except Exception as e: - logger.error(f"Claude enhancement failed: {e}") + logger.error(f"AI enhancement failed: {e}") logger.info("Falling back to rule-based merge") return self.rule_merger.merge_all() @@ -518,13 +517,13 @@ class ClaudeEnhancedMerger: workspace = tempfile.mkdtemp(prefix="skill_merge_") logger.info(f"Created merge workspace: {workspace}") - # Write context files for Claude + # Write context files for AI agent self._write_context_files(workspace) return workspace def _write_context_files(self, workspace: str): - """Write context files for Claude to analyze.""" + """Write context files for AI agent to analyze.""" # 1. Write conflicts summary conflicts_file = os.path.join(workspace, "conflicts.json") @@ -553,7 +552,7 @@ class ClaudeEnhancedMerger: with open(code_apis_file, "w") as f: json.dump(detector.code_apis, f, indent=2) - # 4. Write merge instructions for Claude + # 4. Write merge instructions for AI agent instructions = """# API Merge Task You are merging API documentation from two sources: @@ -625,75 +624,79 @@ Take your time to analyze each conflict carefully. The goal is to create the mos counts[value] = counts.get(value, 0) + 1 return counts - def _launch_claude_merge(self, workspace: str): + def _launch_ai_merge(self, workspace: str): """ - Launch Claude Code to perform merge. - - Similar to enhance_skill_local.py approach. + Run AI-enhanced merge via AgentClient (automated, no terminal). """ - # Create a script that Claude will execute - script_path = os.path.join(workspace, "merge_script.sh") + from skill_seekers.cli.agent_client import AgentClient - script_content = f"""#!/bin/bash -# Automatic merge script for Claude Code - -cd "{workspace}" - -echo "๐Ÿ“Š Analyzing conflicts..." -cat conflicts.json | head -20 - -echo "" -echo "๐Ÿ“– Documentation APIs: $(cat docs_apis.json | grep -c '\"name\"')" -echo "๐Ÿ’ป Code APIs: $(cat code_apis.json | grep -c '\"name\"')" -echo "" -echo "Please review the conflicts and create merged_apis.json" -echo "Follow the instructions in MERGE_INSTRUCTIONS.md" -echo "" -echo "When done, save merged_apis.json and close this terminal." - -# Wait for user to complete merge -read -p "Press Enter when merge is complete..." -""" - - with open(script_path, "w") as f: - f.write(script_content) - - os.chmod(script_path, 0o755) - - # Open new terminal with Claude Code - # Try different terminal emulators - terminals = [ - ["x-terminal-emulator", "-e"], - ["gnome-terminal", "--"], - ["xterm", "-e"], - ["konsole", "-e"], - ] - - for terminal_cmd in terminals: - try: - cmd = terminal_cmd + ["bash", script_path] - subprocess.Popen(cmd) - logger.info(f"Opened terminal with {terminal_cmd[0]}") - break - except FileNotFoundError: - continue - - # Wait for merge to complete + # Read context files to build prompt + conflicts_file = os.path.join(workspace, "conflicts.json") + docs_apis_file = os.path.join(workspace, "docs_apis.json") + code_apis_file = os.path.join(workspace, "code_apis.json") + instructions_file = os.path.join(workspace, "MERGE_INSTRUCTIONS.md") merged_file = os.path.join(workspace, "merged_apis.json") - logger.info(f"Waiting for merged results at: {merged_file}") - logger.info("Close the terminal when done to continue...") - # Poll for file existence - import time + with open(instructions_file) as f: + instructions = f.read() - timeout = 3600 # 1 hour max - elapsed = 0 - while not os.path.exists(merged_file) and elapsed < timeout: - time.sleep(5) - elapsed += 5 + with open(conflicts_file) as f: + conflicts_data = f.read() - if not os.path.exists(merged_file): - raise TimeoutError("Claude merge timed out after 1 hour") + # Limit conflict data to avoid token overflow + if len(conflicts_data) > 30000: + conflicts_data = conflicts_data[:30000] + "\n... (truncated)" + + with open(docs_apis_file) as f: + docs_apis = f.read() + if len(docs_apis) > 15000: + docs_apis = docs_apis[:15000] + "\n... (truncated)" + + with open(code_apis_file) as f: + code_apis = f.read() + if len(code_apis) > 15000: + code_apis = code_apis[:15000] + "\n... (truncated)" + + prompt = f"""{instructions} + +## Conflicts Data: +{conflicts_data} + +## Documentation APIs: +{docs_apis} + +## Code APIs: +{code_apis} + +Write the merged_apis.json output as valid JSON following the format in the instructions above. +Return ONLY the JSON, no explanation.""" + + client = AgentClient(mode="auto") + logger.info(f"Running AI merge via {client.agent_display}...") + + response = client.call(prompt, max_tokens=8192) + + if response: + # Try to extract JSON from response + import re + + json_match = re.search(r"\{[\s\S]*\}", response) + if json_match: + try: + merged = json.loads(json_match.group()) + with open(merged_file, "w") as f: + json.dump(merged, f, indent=2) + logger.info("โœ… AI merge complete โ€” merged_apis.json written") + return + except json.JSONDecodeError: + logger.warning("โš ๏ธ Could not parse JSON from AI response") + + # Fallback: write raw response + with open(merged_file, "w") as f: + f.write(response) + logger.info("โœ… AI merge complete (raw response saved)") + else: + raise RuntimeError("AI agent returned no response for merge") def _read_merged_results(self, workspace: str) -> dict[str, Any]: """Read merged results from workspace.""" @@ -804,3 +807,7 @@ if __name__ == "__main__": print(f" Code only: {summary.get('code_only', 0)}") print(f" Conflicts: {summary.get('conflict', 0)}") print(f"\n๐Ÿ“„ Saved to: {args.output}") + + +# Backward compatibility alias +ClaudeEnhancedMerger = AIEnhancedMerger diff --git a/src/skill_seekers/cli/notion_scraper.py b/src/skill_seekers/cli/notion_scraper.py index fa4ecd8..d951c07 100644 --- a/src/skill_seekers/cli/notion_scraper.py +++ b/src/skill_seekers/cli/notion_scraper.py @@ -1002,11 +1002,17 @@ def main() -> int: except ImportError: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - LocalSkillEnhancer(Path(skill_dir)).run(headless=True) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) + enhancer.run(headless=True) else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - LocalSkillEnhancer(Path(skill_dir)).run(headless=True) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) + enhancer.run(headless=True) except RuntimeError as e: print(f"\n Error: {e}", file=sys.stderr) sys.exit(1) # noqa: E702 diff --git a/src/skill_seekers/cli/openapi_scraper.py b/src/skill_seekers/cli/openapi_scraper.py index caf0ba3..bb00191 100644 --- a/src/skill_seekers/cli/openapi_scraper.py +++ b/src/skill_seekers/cli/openapi_scraper.py @@ -1817,7 +1817,7 @@ Examples: "0=disabled (default for OpenAPI), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "otherwise LOCAL (Claude Code, Kimi, etc.)" ) # OpenAPI-specific arguments @@ -1932,13 +1932,17 @@ Examples: print(" API enhancement not available. Falling back to LOCAL mode...") from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print(" Local enhancement complete!") else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print(" Local enhancement complete!") diff --git a/src/skill_seekers/cli/package_skill.py b/src/skill_seekers/cli/package_skill.py index ab7900f..9f3d07a 100644 --- a/src/skill_seekers/cli/package_skill.py +++ b/src/skill_seekers/cli/package_skill.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Simple Skill Packager -Packages a skill directory into a .zip file for Claude. +Packages a skill directory into a .zip file for LLM platforms. Usage: skill-seekers package output/steam-inventory/ @@ -197,7 +197,7 @@ def main(): from skill_seekers.cli.arguments.package import add_package_arguments parser = argparse.ArgumentParser( - description="Package a skill directory into a .zip file for Claude", + description="Package a skill directory into a .zip file for LLM platforms", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -210,7 +210,7 @@ Examples: # Skip quality checks (faster, but not recommended) skill-seekers package output/react/ --skip-quality-check - # Package and auto-upload to Claude + # Package and auto-upload to target platform skill-seekers package output/react/ --upload # Get help @@ -295,6 +295,31 @@ Examples: print(f"\nโŒ Upload error: {e}") sys.exit(1) + # Publish to marketplace if requested + marketplace_name = getattr(args, "marketplace", None) + if marketplace_name: + try: + from skill_seekers.mcp.marketplace_publisher import MarketplacePublisher + + publisher = MarketplacePublisher() + pub_result = publisher.publish( + skill_dir=args.skill_directory, + marketplace_name=marketplace_name, + category=getattr(args, "marketplace_category", "development"), + create_branch=getattr(args, "create_branch", False), + force=True, + ) + if pub_result["success"]: + print(f"\nโœ… {pub_result['message']}") + print(f" Plugin: {pub_result['plugin_path']}") + print(f" Branch: {pub_result['branch']}") + print(f" Commit: {pub_result['commit_sha']}") + else: + print(f"\nโš ๏ธ Marketplace publish failed: {pub_result['message']}") + except Exception as e: + print(f"\nโš ๏ธ Marketplace publish failed: {e}") + print(" Packaging was successful โ€” publish manually later.") + sys.exit(0) diff --git a/src/skill_seekers/cli/parsers/enhance_parser.py b/src/skill_seekers/cli/parsers/enhance_parser.py index f7c2e06..b041579 100644 --- a/src/skill_seekers/cli/parsers/enhance_parser.py +++ b/src/skill_seekers/cli/parsers/enhance_parser.py @@ -23,8 +23,8 @@ class EnhanceParser(SubcommandParser): def description(self) -> str: return ( "Enhance SKILL.md using AI. " - "Automatically uses API mode (Gemini/OpenAI/Claude) when an API key is " - "available, or falls back to LOCAL mode (Claude Code CLI)." + "Automatically uses API mode (Anthropic/Gemini/OpenAI) when an API key is " + "available, or falls back to LOCAL mode (coding agent CLI)." ) def add_arguments(self, parser): diff --git a/src/skill_seekers/cli/parsers/install_agent_parser.py b/src/skill_seekers/cli/parsers/install_agent_parser.py index 884d56e..eff010a 100644 --- a/src/skill_seekers/cli/parsers/install_agent_parser.py +++ b/src/skill_seekers/cli/parsers/install_agent_parser.py @@ -24,7 +24,7 @@ class InstallAgentParser(SubcommandParser): parser.add_argument( "--agent", required=True, - help="Agent name (claude, cursor, vscode, amp, goose, opencode, all)", + help="Agent name (claude, cursor, vscode, amp, goose, opencode, kimi-code, all)", ) parser.add_argument( "--force", action="store_true", help="Overwrite existing installation without asking" diff --git a/src/skill_seekers/cli/parsers/install_parser.py b/src/skill_seekers/cli/parsers/install_parser.py index 526afaa..19394b1 100644 --- a/src/skill_seekers/cli/parsers/install_parser.py +++ b/src/skill_seekers/cli/parsers/install_parser.py @@ -29,7 +29,7 @@ class InstallParser(SubcommandParser): "--destination", default="output", help="Output directory (default: output/)" ) parser.add_argument( - "--no-upload", action="store_true", help="Skip automatic upload to Claude" + "--no-upload", action="store_true", help="Skip automatic upload to target platform" ) parser.add_argument( "--unlimited", action="store_true", help="Remove page limits during scraping" diff --git a/src/skill_seekers/cli/pdf_scraper.py b/src/skill_seekers/cli/pdf_scraper.py index 7328ea4..867de5c 100644 --- a/src/skill_seekers/cli/pdf_scraper.py +++ b/src/skill_seekers/cli/pdf_scraper.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -PDF Documentation to Claude Skill Converter (Task B1.6) +PDF Documentation to AI Skill Converter (Task B1.6) -Converts PDF documentation into Claude AI skills. +Converts PDF documentation into AI skills. Uses pdf_extractor_poc.py for extraction, builds skill structure. Usage: @@ -63,7 +63,7 @@ def infer_description_from_pdf(pdf_metadata: dict = None, name: str = "") -> str class PDFToSkillConverter: - """Convert PDF documentation to Claude skill""" + """Convert PDF documentation to AI skill""" def __init__(self, config): self.config = config @@ -637,7 +637,7 @@ def main(): from .arguments.pdf import add_pdf_arguments parser = argparse.ArgumentParser( - description="Convert PDF documentation to Claude skill", + description="Convert PDF documentation to AI skill", formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -749,16 +749,22 @@ def main(): from pathlib import Path from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) - print("โœ… Local enhancement complete!") + agent_name = agent or "claude" + print(f"โœ… Local enhancement complete! (via {agent_name})") else: from pathlib import Path from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) - print("โœ… Local enhancement complete!") + agent_name = agent or "claude" + print(f"โœ… Local enhancement complete! (via {agent_name})") except RuntimeError as e: print(f"\nโŒ Error: {e}", file=sys.stderr) diff --git a/src/skill_seekers/cli/pptx_scraper.py b/src/skill_seekers/cli/pptx_scraper.py index 725299e..179ff18 100644 --- a/src/skill_seekers/cli/pptx_scraper.py +++ b/src/skill_seekers/cli/pptx_scraper.py @@ -1788,13 +1788,17 @@ def main() -> int: print("โŒ API enhancement not available. Falling back to LOCAL mode...") from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/quality_checker.py b/src/skill_seekers/cli/quality_checker.py index 1ceec78..7e9d91f 100644 --- a/src/skill_seekers/cli/quality_checker.py +++ b/src/skill_seekers/cli/quality_checker.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Quality Checker for Claude Skills +Quality Checker for AI Skills Validates skill quality, checks links, and generates quality reports. Usage: @@ -350,7 +350,7 @@ class SkillQualityChecker: else: self.report.add_info( "completeness", - "Consider adding prerequisites section - helps Claude verify conditions first", + "Consider adding prerequisites section - helps the LLM verify conditions first", "SKILL.md", ) diff --git a/src/skill_seekers/cli/rss_scraper.py b/src/skill_seekers/cli/rss_scraper.py index ce6837b..9e97c90 100644 --- a/src/skill_seekers/cli/rss_scraper.py +++ b/src/skill_seekers/cli/rss_scraper.py @@ -900,7 +900,7 @@ def main() -> int: "0=disabled (default for RSS), 1=SKILL.md only, " "2=+architecture/config, 3=full enhancement. " "Mode selection: uses API if ANTHROPIC_API_KEY is set, " - "otherwise LOCAL (Claude Code)" + "otherwise LOCAL (Claude Code, Kimi, etc.)" ) # RSS-specific arguments @@ -1060,13 +1060,17 @@ def main() -> int: print("โŒ API enhancement not available. Falling back to LOCAL mode...") from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/signal_flow_analyzer.py b/src/skill_seekers/cli/signal_flow_analyzer.py index f1395eb..819c4f2 100644 --- a/src/skill_seekers/cli/signal_flow_analyzer.py +++ b/src/skill_seekers/cli/signal_flow_analyzer.py @@ -268,7 +268,7 @@ class SignalFlowAnalyzer: Args: output_dir: Directory to save guides - ai_mode: "LOCAL" (Claude Code) or "API" (Anthropic API) + ai_mode: "LOCAL" (coding agent CLI) or "API" (Anthropic API) Returns: Path to generated guide file diff --git a/src/skill_seekers/cli/unified_codebase_analyzer.py b/src/skill_seekers/cli/unified_codebase_analyzer.py index d0143a8..53d5884 100644 --- a/src/skill_seekers/cli/unified_codebase_analyzer.py +++ b/src/skill_seekers/cli/unified_codebase_analyzer.py @@ -319,7 +319,7 @@ class UnifiedCodebaseAnalyzer: c3x_data = {} # C3.1: Design Patterns - patterns_file = output_dir / "patterns" / "design_patterns.json" + patterns_file = output_dir / "patterns" / "all_patterns.json" if patterns_file.exists(): with open(patterns_file) as f: patterns_data = json.load(f) diff --git a/src/skill_seekers/cli/unified_enhancer.py b/src/skill_seekers/cli/unified_enhancer.py index 2b1939c..ec44128 100644 --- a/src/skill_seekers/cli/unified_enhancer.py +++ b/src/skill_seekers/cli/unified_enhancer.py @@ -19,12 +19,8 @@ Benefits: import json import logging -import os -import subprocess -import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass -from pathlib import Path from typing import Literal logger = logging.getLogger(__name__) @@ -89,58 +85,24 @@ class UnifiedEnhancer: self.config.batch_size = cfg.get_local_batch_size() self.config.parallel_workers = cfg.get_local_parallel_workers() - # Determine actual mode - self.api_key = self.config.api_key or os.environ.get("ANTHROPIC_API_KEY") + # Initialize AgentClient for AI calls + from skill_seekers.cli.agent_client import AgentClient - if self.config.mode == "auto": - if self.api_key: - self.config.mode = "api" + self._agent = AgentClient( + mode=self.config.mode, + api_key=self.config.api_key, + ) + + # Sync resolved mode back to config + self.config.mode = self._agent.mode + + if self.config.enabled: + if self._agent.is_available(): + self._agent.log_mode() else: - self.config.mode = "local" - logger.info("โ„น๏ธ No API key found, using LOCAL mode (Claude Code CLI)") - - # Initialize API client if needed - self.client = None - if self.config.mode == "api" and self.config.enabled: - try: - import anthropic - - client_kwargs = {"api_key": self.api_key} - base_url = os.environ.get("ANTHROPIC_BASE_URL") - if base_url: - client_kwargs["base_url"] = base_url - logger.info(f"โœ… Using custom API base URL: {base_url}") - self.client = anthropic.Anthropic(**client_kwargs) - logger.info("โœ… AI enhancement enabled (using Claude API)") - except ImportError: - logger.warning("โš ๏ธ anthropic package not installed, falling back to LOCAL mode") - self.config.mode = "local" - except Exception as e: - logger.warning( - f"โš ๏ธ Failed to initialize API client: {e}, falling back to LOCAL mode" - ) - self.config.mode = "local" - - if self.config.mode == "local" and self.config.enabled: - if self._check_claude_cli(): - logger.info("โœ… AI enhancement enabled (using LOCAL mode - Claude Code CLI)") - else: - logger.warning("โš ๏ธ Claude Code CLI not found. AI enhancement disabled.") + logger.warning("โš ๏ธ AI agent not available. AI enhancement disabled.") self.config.enabled = False - def _check_claude_cli(self) -> bool: - """Check if Claude Code CLI is available.""" - try: - result = subprocess.run( - ["claude", "--version"], - capture_output=True, - text=True, - timeout=5, - ) - return result.returncode == 0 - except (FileNotFoundError, subprocess.TimeoutExpired): - return False - def enhance( self, items: list[dict], @@ -265,88 +227,11 @@ class UnifiedEnhancer: return items def _call_claude(self, prompt: str, max_tokens: int = 1000) -> str | None: - """Call Claude (API or LOCAL mode) with error handling.""" - if self.config.mode == "api": - return self._call_claude_api(prompt, max_tokens) - elif self.config.mode == "local": - return self._call_claude_local(prompt) - return None + """Call AI agent (API or LOCAL mode) via AgentClient.""" + from skill_seekers.cli.agent_client import get_default_timeout - def _call_claude_api(self, prompt: str, max_tokens: int = 1000) -> str | None: - """Call Claude API.""" - if not self.client: - return None - - try: - response = self.client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=max_tokens, - messages=[{"role": "user", "content": prompt}], - ) - return response.content[0].text - except Exception as e: - logger.warning(f"โš ๏ธ API call failed: {e}") - return None - - def _call_claude_local(self, prompt: str) -> str | None: - """Call Claude Code CLI in LOCAL mode.""" - try: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - # Write prompt to file - prompt_file = temp_path / "prompt.txt" - prompt_file.write_text(prompt) - - # Output file - output_file = temp_path / "response.json" - - # Call Claude CLI - result = subprocess.run( - [ - "claude", - str(prompt_file), - "--output", - str(output_file), - "--model", - "sonnet", - ], - capture_output=True, - text=True, - timeout=120, - cwd=str(temp_path), - ) - - if result.returncode != 0: - logger.warning(f"โš ๏ธ Claude CLI returned error: {result.returncode}") - return None - - # Read output - if output_file.exists(): - response_text = output_file.read_text() - try: - json.loads(response_text) - return response_text - except json.JSONDecodeError: - # Try to extract JSON - import re - - json_match = re.search(r"\[[\s\S]*\]|\{[\s\S]*\}", response_text) - if json_match: - return json_match.group() - return None - else: - for json_file in temp_path.glob("*.json"): - if json_file.name != "prompt.json": - return json_file.read_text() - return None - - except subprocess.TimeoutExpired: - logger.warning("โš ๏ธ Claude CLI timeout (2 minutes)") - return None - except Exception as e: - logger.warning(f"โš ๏ธ LOCAL mode error: {e}") - return None + timeout = get_default_timeout() if self._agent.mode == "local" else None + return self._agent.call(prompt, max_tokens=max_tokens, timeout=timeout) def _get_default_prompt(self, enhancement_type: str) -> str: """Get default prompt for enhancement type.""" diff --git a/src/skill_seekers/cli/unified_scraper.py b/src/skill_seekers/cli/unified_scraper.py index 42c4d3c..76894b4 100644 --- a/src/skill_seekers/cli/unified_scraper.py +++ b/src/skill_seekers/cli/unified_scraper.py @@ -9,7 +9,7 @@ This is the main entry point for unified config workflow. Usage: skill-seekers unified --config configs/godot_unified.json - skill-seekers unified --config configs/react_unified.json --merge-mode claude-enhanced + skill-seekers unified --config configs/react_unified.json --merge-mode ai-enhanced """ import argparse @@ -24,9 +24,10 @@ from typing import Any # Import validators and scrapers try: + from skill_seekers.cli.agent_client import get_default_timeout from skill_seekers.cli.config_validator import validate_config from skill_seekers.cli.conflict_detector import ConflictDetector - from skill_seekers.cli.merge_sources import ClaudeEnhancedMerger, RuleBasedMerger + from skill_seekers.cli.merge_sources import AIEnhancedMerger, RuleBasedMerger from skill_seekers.cli.unified_skill_builder import UnifiedSkillBuilder from skill_seekers.cli.utils import setup_logging except ImportError as e: @@ -45,7 +46,7 @@ class UnifiedScraper: 1. Load and validate unified config 2. Scrape all sources (docs, GitHub, PDF) 3. Detect conflicts between sources - 4. Merge intelligently (rule-based or Claude-enhanced) + 4. Merge intelligently (rule-based or AI-enhanced) 5. Build unified skill """ @@ -64,8 +65,9 @@ class UnifiedScraper: self.validator = validate_config(config_path) self.config = self.validator.config - # Determine merge mode - self.merge_mode = merge_mode or self.config.get("merge_mode", "rule-based") + # Determine merge mode (normalize claude-enhanced โ†’ ai-enhanced for backward compat) + raw_mode = merge_mode or self.config.get("merge_mode", "rule-based") + self.merge_mode = "ai-enhanced" if raw_mode == "claude-enhanced" else raw_mode logger.info(f"Merge mode: {self.merge_mode}") # Storage for scraped data - use lists to support multiple sources of same type @@ -225,7 +227,7 @@ class UnifiedScraper: "url_patterns": source.get("url_patterns", {}), "categories": source.get("categories", {}), "rate_limit": source.get("rate_limit", 0.5), - "max_pages": source.get("max_pages", 100), + "max_pages": source.get("max_pages", 500), } # Pass through llms.txt settings (so unified configs behave the same as doc_scraper configs) @@ -239,9 +241,21 @@ class UnifiedScraper: if "start_urls" in source: doc_source["start_urls"] = source["start_urls"] + # Pass through browser rendering settings + if source.get("browser"): + doc_source["browser"] = True + if "browser_wait_until" in source: + doc_source["browser_wait_until"] = source["browser_wait_until"] + if "browser_extra_wait" in source: + doc_source["browser_extra_wait"] = source["browser_extra_wait"] + doc_config = { "name": f"{self.name}_docs", "description": f"Documentation for {self.name}", + "base_url": source["base_url"], + "browser": source.get("browser", False), + "browser_wait_until": source.get("browser_wait_until", "domcontentloaded"), + "browser_extra_wait": source.get("browser_extra_wait", 0), "sources": [doc_source], } @@ -256,7 +270,29 @@ class UnifiedScraper: doc_scraper_path = Path(__file__).parent / "doc_scraper.py" cmd = [sys.executable, str(doc_scraper_path), "--config", temp_config_path, "--fresh"] - result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) + # Forward agent-related CLI args so doc scraper enhancement respects + # the user's chosen agent instead of defaulting to claude. + cli_args = getattr(self, "_cli_args", None) + if cli_args is not None: + if getattr(cli_args, "agent", None): + cmd.extend(["--agent", cli_args.agent]) + if getattr(cli_args, "agent_cmd", None): + cmd.extend(["--agent-cmd", cli_args.agent_cmd]) + if getattr(cli_args, "api_key", None): + cmd.extend(["--api-key", cli_args.api_key]) + + # Support "browser": true in source config for JavaScript SPA sites + if source.get("browser", False): + cmd.append("--browser") + logger.info(" ๐ŸŒ Browser mode enabled (JavaScript rendering via Playwright)") + + try: + result = subprocess.run( + cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=3600 + ) + except subprocess.TimeoutExpired: + logger.error("Documentation scraping timed out after 60 minutes") + return if result.returncode != 0: logger.error(f"Documentation scraping failed with return code {result.returncode}") @@ -318,6 +354,11 @@ class UnifiedScraper: shutil.move(docs_data_dir, cache_data_dir) logger.info(f"๐Ÿ“ฆ Moved docs data to cache: {cache_data_dir}") + # Update data_file path to point to cache location + if self.scraped_data["documentation"]: + cached_data_file = os.path.join(cache_data_dir, "summary.json") + self.scraped_data["documentation"][-1]["data_file"] = cached_data_file + def _clone_github_repo(self, repo_name: str, idx: int = 0) -> str | None: """ Clone GitHub repository to cache directory for C3.x analysis. @@ -353,7 +394,7 @@ class UnifiedScraper: ["git", "clone", repo_url, clone_path], capture_output=True, text=True, - timeout=600, # 10 minute timeout for full clone + timeout=get_default_timeout(), # default 45 min, configurable via SKILL_SEEKER_ENHANCE_TIMEOUT ) if result.returncode == 0: @@ -422,6 +463,7 @@ class UnifiedScraper: "include_code": source.get("include_code", True), "code_analysis_depth": source.get("code_analysis_depth", "surface"), "file_patterns": source.get("file_patterns", []), + "language": source.get("language", ""), "local_repo_path": local_repo_path, # Use cloned path if available } @@ -776,25 +818,38 @@ class UnifiedScraper: enhance_level=enhance_level, ) - # Load analysis outputs into memory + # Load analysis outputs into memory. + # _generate_references() moves dirs into references/ and deletes originals. + refs = temp_output / "references" local_data = { "source_id": f"{self.name}_local_{idx}_{path_id}", "path": local_path, "name": source_name, "description": source.get("description", f"Local analysis of {path_id}"), "weight": source.get("weight", 1.0), - "patterns": self._load_json(temp_output / "patterns" / "detected_patterns.json"), - "test_examples": self._load_json( - temp_output / "test_examples" / "test_examples.json" + "patterns": self._load_json_fallback( + refs / "patterns" / "all_patterns.json", + temp_output / "patterns" / "all_patterns.json", ), - "how_to_guides": self._load_guide_collection(temp_output / "tutorials"), - "config_patterns": self._load_json( - temp_output / "config_patterns" / "config_patterns.json" + "test_examples": self._load_json_fallback( + refs / "test_examples" / "test_examples.json", + temp_output / "test_examples" / "test_examples.json", ), - "architecture": self._load_json(temp_output / "ARCHITECTURE.json"), - "api_reference": self._load_api_reference(temp_output / "api_reference"), - "dependency_graph": self._load_json( - temp_output / "dependencies" / "dependency_graph.json" + "how_to_guides": self._load_guide_collection(refs / "tutorials") + or self._load_guide_collection(temp_output / "tutorials"), + "config_patterns": self._load_json_fallback( + refs / "config_patterns" / "config_patterns.json", + temp_output / "config_patterns" / "config_patterns.json", + ), + "architecture": self._load_json_fallback( + refs / "architecture" / "architectural_patterns.json", + temp_output / "architecture" / "architectural_patterns.json", + ), + "api_reference": self._load_api_reference(refs / "api_reference") + or self._load_api_reference(temp_output / "api_reference"), + "dependency_graph": self._load_json_fallback( + refs / "dependencies" / "dependency_graph.json", + temp_output / "dependencies" / "dependency_graph.json", ), } @@ -1425,6 +1480,12 @@ class UnifiedScraper: logger.info(f"โœ… Chat: {len(chat_data.get('messages', []))} messages extracted") + def _load_json_fallback(self, primary: Path, fallback: Path) -> dict: + """Load JSON from primary path, falling back to secondary if not found.""" + if primary.exists(): + return self._load_json(primary) + return self._load_json(fallback) + def _load_json(self, file_path: Path) -> dict: """ Load JSON file safely. @@ -1525,6 +1586,11 @@ class UnifiedScraper: logger.info(f" Analyzing codebase: {local_repo_path}") try: + # Resolve agent from CLI args for C3.x analysis + cli_args = getattr(self, "_cli_args", None) + agent = getattr(cli_args, "agent", None) if cli_args else None + agent_cmd = getattr(cli_args, "agent_cmd", None) if cli_args else None + # Run full C3.x analysis _results = analyze_codebase( directory=Path(local_repo_path), @@ -1541,25 +1607,39 @@ class UnifiedScraper: extract_config_patterns=True, # C3.4: Config patterns extract_docs=True, enhance_level=0 if source.get("ai_mode", "auto") == "none" else 2, + agent=agent, + agent_cmd=agent_cmd, ) - # Load C3.x outputs into memory + # Load C3.x outputs into memory. + # _generate_references() inside analyze_codebase() moves analysis dirs + # into references/ and deletes the originals, so we check both locations. + refs = temp_output / "references" c3_data = { - "patterns": self._load_json(temp_output / "patterns" / "detected_patterns.json"), - "test_examples": self._load_json( - temp_output / "test_examples" / "test_examples.json" + "patterns": self._load_json_fallback( + refs / "patterns" / "all_patterns.json", + temp_output / "patterns" / "all_patterns.json", ), - "how_to_guides": self._load_guide_collection(temp_output / "tutorials"), - "config_patterns": self._load_json( - temp_output / "config_patterns" / "config_patterns.json" + "test_examples": self._load_json_fallback( + refs / "test_examples" / "test_examples.json", + temp_output / "test_examples" / "test_examples.json", ), - "architecture": self._load_json( - temp_output / "architecture" / "architectural_patterns.json" + "how_to_guides": self._load_guide_collection(refs / "tutorials") + or self._load_guide_collection(temp_output / "tutorials"), + "config_patterns": self._load_json_fallback( + refs / "config_patterns" / "config_patterns.json", + temp_output / "config_patterns" / "config_patterns.json", + ), + "architecture": self._load_json_fallback( + refs / "architecture" / "architectural_patterns.json", + temp_output / "architecture" / "architectural_patterns.json", + ), + "api_reference": self._load_api_reference(refs / "api_reference") + or self._load_api_reference(temp_output / "api_reference"), + "dependency_graph": self._load_json_fallback( + refs / "dependencies" / "dependency_graph.json", + temp_output / "dependencies" / "dependency_graph.json", ), - "api_reference": self._load_api_reference(temp_output / "api_reference"), # C2.5 - "dependency_graph": self._load_json( - temp_output / "dependencies" / "dependency_graph.json" - ), # C2.6 } # Log summary @@ -1607,16 +1687,23 @@ class UnifiedScraper: if not self.validator.needs_api_merge(): logger.info("No API merge needed (only one API source)") + logger.info("\n" + "=" * 60) + logger.info("PHASE 3: Merging sources (skipped - no conflicts detected)") + logger.info("=" * 60) return [] - # Get documentation and GitHub data - docs_data = self.scraped_data.get("documentation", {}) - github_data = self.scraped_data.get("github", {}) + # Get documentation and GitHub data (scraped_data stores lists of sources) + docs_list = self.scraped_data.get("documentation", []) + github_list = self.scraped_data.get("github", []) - if not docs_data or not github_data: + if not docs_list or not github_list: logger.warning("Missing documentation or GitHub data for conflict detection") return [] + # Use the first source from each list + docs_data = docs_list[0] + github_data = github_list[0] + # Load data files with open(docs_data["data_file"], encoding="utf-8") as f: docs_json = json.load(f) @@ -1663,9 +1750,16 @@ class UnifiedScraper: logger.info("No conflicts to merge") return None - # Get data files - docs_data = self.scraped_data.get("documentation", {}) - github_data = self.scraped_data.get("github", {}) + # Get data files (scraped_data stores lists of sources) + docs_list = self.scraped_data.get("documentation", []) + github_list = self.scraped_data.get("github", []) + + if not docs_list or not github_list: + logger.warning("Missing documentation or GitHub data for merging") + return None + + docs_data = docs_list[0] + github_data = github_list[0] # Load data with open(docs_data["data_file"], encoding="utf-8") as f: @@ -1675,8 +1769,8 @@ class UnifiedScraper: github_json = json.load(f) # Choose merger - if self.merge_mode == "claude-enhanced": - merger = ClaudeEnhancedMerger(docs_json, github_json, conflicts) + if self.merge_mode in ("ai-enhanced", "claude-enhanced"): + merger = AIEnhancedMerger(docs_json, github_json, conflicts) else: merger = RuleBasedMerger(docs_json, github_json, conflicts) @@ -1797,6 +1891,124 @@ class UnifiedScraper: } run_workflows(effective_args, context=unified_context) + # Phase 6: AI Enhancement of SKILL.md + # Triggered by config "enhancement" block or CLI --enhance-level + enhancement_config = self.config.get("enhancement", {}) + enhancement_enabled = enhancement_config.get("enabled", False) + enhancement_level = enhancement_config.get("level", 0) + enhancement_mode = enhancement_config.get("mode", "AUTO").upper() + + # CLI --enhance-level overrides config + cli_enhance_level = getattr(args, "enhance_level", None) if args is not None else None + if cli_enhance_level is not None: + enhancement_enabled = cli_enhance_level > 0 + enhancement_level = cli_enhance_level + + if enhancement_enabled and enhancement_level > 0: + logger.info("\n" + "=" * 60) + logger.info( + f"PHASE 6: Enhancing SKILL.md (level {enhancement_level}, mode {enhancement_mode})" + ) + logger.info("=" * 60) + + skill_md_path = os.path.join(self.output_dir, "SKILL.md") + if not os.path.exists(skill_md_path): + logger.warning("โš ๏ธ SKILL.md not found, skipping enhancement") + elif enhancement_mode == "LOCAL": + try: + from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer + + # Get agent from CLI args, config enhancement block, or env var + agent = None + agent_cmd = None + if args is not None: + agent = getattr(args, "agent", None) + agent_cmd = getattr(args, "agent_cmd", None) + if not agent: + agent = enhancement_config.get("agent", None) + if not agent: + agent = os.environ.get("SKILL_SEEKER_AGENT", "").strip() or None + + # Read timeout from config enhancement block + timeout_val = enhancement_config.get("timeout") + if timeout_val is not None: + if isinstance(timeout_val, str) and timeout_val.lower() in ( + "unlimited", + "none", + ): + timeout_val = 86400 # 24 hours + else: + try: + timeout_val = int(timeout_val) + if timeout_val <= 0: + timeout_val = 86400 + except (ValueError, TypeError): + timeout_val = 2700 + else: + timeout_val = 2700 + + enhancer = LocalSkillEnhancer( + self.output_dir, force=True, agent=agent, agent_cmd=agent_cmd + ) + success = enhancer.run(headless=True, timeout=timeout_val) + agent_name = agent or "claude" + if success: + logger.info(f"โœ… SKILL.md enhanced (LOCAL mode via {agent_name})") + else: + logger.warning( + f"โš ๏ธ SKILL.md enhancement returned false (LOCAL mode via {agent_name}). " + "Check logs above for the exact error." + ) + except Exception as e: + logger.warning(f"โš ๏ธ LOCAL enhancement failed: {e}") + logger.info( + " Try manually: skill-seekers enhance " + + self.output_dir + + " --agent kimi" + ) + else: + # API mode โ€” use AgentClient for multi-provider support + try: + from skill_seekers.cli.agent_client import AgentClient + + client = AgentClient(mode="api") + if client.client: + # Read references and current SKILL.md + references = "" + refs_dir = Path(self.output_dir) / "references" + if refs_dir.exists(): + for md_file in sorted(refs_dir.rglob("*.md")): + content = md_file.read_text(encoding="utf-8", errors="ignore") + references += f"\n\n## {md_file.name}\n\n{content}" + current_skill = Path(skill_md_path).read_text(encoding="utf-8") + + # Build enhancement prompt + prompt = ( + f"Enhance this SKILL.md using the reference documentation.\n\n" + f"CURRENT SKILL.MD:\n{current_skill}\n\n" + f"REFERENCES:\n{references}\n\n" + f"Return ONLY the complete enhanced SKILL.md content, " + f"starting with the frontmatter (---)." + ) + enhanced = client.call(prompt, max_tokens=8192) + if enhanced: + shutil.copy2(skill_md_path, skill_md_path + ".backup") + Path(skill_md_path).write_text(enhanced, encoding="utf-8") + logger.info( + f"โœ… SKILL.md enhanced (API mode via {client.provider})" + ) + else: + logger.warning("โš ๏ธ API enhancement returned empty result") + else: + logger.warning("โš ๏ธ No API key found, skipping API enhancement") + logger.info(' Set an API key or use "mode": "LOCAL" in config') + except Exception as e: + logger.warning(f"โš ๏ธ API enhancement failed: {e}") + else: + logger.info("\n" + "=" * 60) + logger.info("PHASE 6: Enhancement (skipped - not enabled in config)") + logger.info("=" * 60) + logger.info("\n" + "โœ… " * 20) logger.info("Unified scraping complete!") logger.info("โœ… " * 20 + "\n") @@ -1826,7 +2038,7 @@ Examples: skill-seekers unified --config configs/godot_unified.json # Override merge mode - skill-seekers unified --config configs/react_unified.json --merge-mode claude-enhanced + skill-seekers unified --config configs/react_unified.json --merge-mode ai-enhanced # Backward compatible with legacy configs skill-seekers unified --config configs/react.json @@ -1837,8 +2049,8 @@ Examples: parser.add_argument( "--merge-mode", "-m", - choices=["rule-based", "claude-enhanced"], - help="Override config merge mode", + choices=["rule-based", "ai-enhanced", "claude-enhanced"], + help="Override config merge mode (ai-enhanced or rule-based). 'claude-enhanced' accepted as alias.", ) parser.add_argument( "--skip-codebase-analysis", @@ -1901,6 +2113,19 @@ Examples: "Overrides per-source enhance_level in config." ), ) + parser.add_argument( + "--agent", + type=str, + choices=["claude", "codex", "copilot", "opencode", "kimi", "custom"], + metavar="AGENT", + help="Local coding agent for enhancement (default: AI agent from SKILL_SEEKER_AGENT env var)", + ) + parser.add_argument( + "--agent-cmd", + type=str, + metavar="CMD", + help="Override agent command template (advanced)", + ) args = parser.parse_args() setup_logging() diff --git a/src/skill_seekers/cli/upload_skill.py b/src/skill_seekers/cli/upload_skill.py index 5ade937..df53168 100755 --- a/src/skill_seekers/cli/upload_skill.py +++ b/src/skill_seekers/cli/upload_skill.py @@ -4,7 +4,7 @@ Automatic Skill Uploader Uploads a skill package to LLM platforms (Claude, Gemini, OpenAI, etc.) Usage: - # Claude (default) + # Anthropic (default) export ANTHROPIC_API_KEY=sk-ant-... skill-seekers upload output/react.zip @@ -117,7 +117,7 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Setup: - Claude: + Anthropic (Claude): export ANTHROPIC_API_KEY=sk-ant-... Gemini: @@ -135,7 +135,7 @@ Setup: docker run -p 8080:8080 semitechnologies/weaviate:latest Examples: - # Upload to Claude (default) + # Upload to default platform skill-seekers upload output/react.zip # Upload to Gemini @@ -162,9 +162,9 @@ Examples: parser.add_argument( "--target", - choices=["claude", "gemini", "openai", "chroma", "weaviate"], - default="claude", - help="Target platform (default: claude)", + choices=["claude", "gemini", "openai", "kimi", "chroma", "weaviate"], + default=None, + help="Target platform (auto-detected from API keys, or 'claude' if none set)", ) parser.add_argument("--api-key", help="Platform API key (or set environment variable)") @@ -209,6 +209,12 @@ Examples: args = parser.parse_args() + # Auto-detect target platform if not specified + if args.target is None: + from skill_seekers.cli.agent_client import AgentClient + + args.target = AgentClient.detect_default_target() + # Build kwargs for vector DB upload upload_kwargs = {} diff --git a/src/skill_seekers/cli/utils.py b/src/skill_seekers/cli/utils.py index 077eaaf..6425603 100755 --- a/src/skill_seekers/cli/utils.py +++ b/src/skill_seekers/cli/utils.py @@ -76,32 +76,48 @@ def open_folder(folder_path: str | Path) -> bool: def has_api_key() -> bool: """ - Check if ANTHROPIC_API_KEY is set in environment + Check if any AI API key is set in environment. + + Checks: ANTHROPIC_API_KEY, MOONSHOT_API_KEY, GOOGLE_API_KEY, OPENAI_API_KEY Returns: - bool: True if API key is set, False otherwise + bool: True if any API key is set, False otherwise """ - api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() - return len(api_key) > 0 + for env_var in ("ANTHROPIC_API_KEY", "MOONSHOT_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY"): + if os.environ.get(env_var, "").strip(): + return True + return False def get_api_key() -> str | None: """ - Get ANTHROPIC_API_KEY from environment + Get the first available AI API key from environment. + + Checks: ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, MOONSHOT_API_KEY, + GOOGLE_API_KEY, OPENAI_API_KEY Returns: str: API key or None if not set """ - api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() - return api_key if api_key else None + for env_var in ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "MOONSHOT_API_KEY", + "GOOGLE_API_KEY", + "OPENAI_API_KEY", + ): + key = os.environ.get(env_var, "").strip() + if key: + return key + return None def get_upload_url() -> str: """ - Get the Claude skills upload URL + Get the skills upload URL Returns: - str: Claude skills upload URL + str: Skills upload URL """ return "https://claude.ai/skills" @@ -120,7 +136,7 @@ def print_upload_instructions(zip_path: str | Path) -> None: print("โ•‘ NEXT STEP โ•‘") print("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") print() - print(f"๐Ÿ“ค Upload to Claude: {get_upload_url()}") + print(f"๐Ÿ“ค Upload to platform: {get_upload_url()}") print() print(f"1. Go to {get_upload_url()}") print('2. Click "Upload Skill"') diff --git a/src/skill_seekers/cli/video_scraper.py b/src/skill_seekers/cli/video_scraper.py index 0536575..6dd4956 100644 --- a/src/skill_seekers/cli/video_scraper.py +++ b/src/skill_seekers/cli/video_scraper.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ -Video to Claude Skill Converter +Video to AI Skill Converter Extracts transcripts, metadata, and visual content from videos -and converts them into Claude AI skills. +and converts them into AI skills. Supports YouTube videos/playlists, Vimeo, and local video files. @@ -264,7 +264,7 @@ def _is_likely_code(text: str) -> bool: def _ai_clean_reference(ref_path: str, content: str, api_key: str | None = None) -> None: """Use AI to clean Code Timeline section in a reference file. - Sends the reference file content to Claude with a focused prompt + Sends the reference file content to the AI with a focused prompt to reconstruct the Code Timeline from noisy OCR + transcript context. """ try: @@ -300,7 +300,7 @@ def _ai_clean_reference(ref_path: str, content: str, api_key: str | None = None) try: client = anthropic.Anthropic(**client_kwargs) response = client.messages.create( - model="claude-sonnet-4-20250514", + model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"), max_tokens=8000, messages=[{"role": "user", "content": prompt}], ) @@ -319,7 +319,7 @@ def _ai_clean_reference(ref_path: str, content: str, api_key: str | None = None) class VideoToSkillConverter: - """Convert video content to Claude skill.""" + """Convert video content to AI skill.""" def __init__(self, config: dict): """Initialize converter. @@ -865,9 +865,12 @@ class VideoToSkillConverter: """First-pass: AI-clean reference files before SKILL.md enhancement. When enhance_level >= 2 and an API key is available, sends each - reference file to Claude to reconstruct noisy Code Timeline + reference file to the AI to reconstruct noisy Code Timeline sections using transcript context. """ + # Note: Middle-layer AI cleaning currently only supports Anthropic API + # For other agents (kimi, etc.), this step is skipped and enhancement + # happens at the SKILL.md level instead of per-reference-file has_api_key = bool( os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN") @@ -1203,9 +1206,12 @@ def _run_video_enhancement(skill_dir: str, enhance_level: int, args) -> None: os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN") or getattr(args, "api_key", None) + or os.environ.get("MOONSHOT_API_KEY") ) - if not has_api_key: + agent = getattr(args, "agent", None) + + if not has_api_key and not agent: logger.info("\n๐Ÿ’ก Enhance your video skill with AI:") logger.info(f" export ANTHROPIC_API_KEY=sk-ant-...") logger.info(f" skill-seekers enhance {skill_dir} --enhance-level {enhance_level}") @@ -1215,10 +1221,11 @@ def _run_video_enhancement(skill_dir: str, enhance_level: int, args) -> None: try: enhance_cmd = ["skill-seekers-enhance", skill_dir] - enhance_cmd.extend(["--enhance-level", str(enhance_level)]) api_key = getattr(args, "api_key", None) if api_key: enhance_cmd.extend(["--api-key", api_key]) + if agent: + enhance_cmd.extend(["--agent", agent]) logger.info( "Starting video skill enhancement (this may take 10+ minutes " diff --git a/src/skill_seekers/cli/video_visual.py b/src/skill_seekers/cli/video_visual.py index 86e670a..b4b6111 100644 --- a/src/skill_seekers/cli/video_visual.py +++ b/src/skill_seekers/cli/video_visual.py @@ -590,7 +590,7 @@ def _ocr_with_claude_vision(frame_path: str, frame_type: FrameType) -> tuple[str client = anthropic.Anthropic(api_key=api_key) response = client.messages.create( - model="claude-haiku-4-5-20251001", + model=os.environ.get("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001"), max_tokens=4096, messages=[ { diff --git a/src/skill_seekers/cli/word_scraper.py b/src/skill_seekers/cli/word_scraper.py index 1cd3375..23866c2 100644 --- a/src/skill_seekers/cli/word_scraper.py +++ b/src/skill_seekers/cli/word_scraper.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -Word Document (.docx) to Claude Skill Converter (Task B2) +Word Document (.docx) to AI Skill Converter (Task B2) -Converts Word documents into Claude AI skills. +Converts Word documents into AI skills. Uses mammoth for HTML conversion and python-docx for metadata/tables. Usage: @@ -73,7 +73,7 @@ def infer_description_from_word(metadata: dict = None, name: str = "") -> str: class WordToSkillConverter: - """Convert Word document (.docx) to Claude skill.""" + """Convert Word document (.docx) to AI skill.""" def __init__(self, config): self.config = config @@ -924,7 +924,7 @@ def main(): from .arguments.word import add_word_arguments parser = argparse.ArgumentParser( - description="Convert Word document (.docx) to Claude skill", + description="Convert Word document (.docx) to AI skill", formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -1031,14 +1031,18 @@ def main(): from pathlib import Path from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") else: from pathlib import Path from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer - enhancer = LocalSkillEnhancer(Path(skill_dir)) + agent = getattr(args, "agent", None) if args else None + agent_cmd = getattr(args, "agent_cmd", None) if args else None + enhancer = LocalSkillEnhancer(Path(skill_dir), agent=agent, agent_cmd=agent_cmd) enhancer.run(headless=True) print("โœ… Local enhancement complete!") diff --git a/src/skill_seekers/cli/workflow_runner.py b/src/skill_seekers/cli/workflow_runner.py index 6554426..e58326b 100644 --- a/src/skill_seekers/cli/workflow_runner.py +++ b/src/skill_seekers/cli/workflow_runner.py @@ -43,6 +43,7 @@ def _build_inline_engine(args: argparse.Namespace): """Build a WorkflowEngine from --enhance-stage flags.""" from skill_seekers.cli.enhancement_workflow import WorkflowEngine + agent = getattr(args, "agent", None) stages = [] for i, spec in enumerate(args.enhance_stage, 1): if ":" in spec: @@ -63,7 +64,7 @@ def _build_inline_engine(args: argparse.Namespace): "description": "Custom inline workflow from --enhance-stage arguments", "stages": stages, } - return WorkflowEngine(workflow_data=inline_def) + return WorkflowEngine(workflow_data=inline_def, agent=agent) def run_workflows( @@ -107,6 +108,7 @@ def run_workflows( logger.info(f" {k} = {v}") executed: list[str] = [] + agent = getattr(args, "agent", None) # โ”€โ”€ Named workflows โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ total = len(named_workflows) + (1 if inline_stages else 0) @@ -118,7 +120,7 @@ def run_workflows( logger.info(header) try: - engine = WorkflowEngine(workflow_name) + engine = WorkflowEngine(workflow_name, agent=agent) except Exception as exc: logger.error(f"โŒ Failed to load workflow '{workflow_name}': {exc}") logger.info(" Skipping this workflow and continuing...") diff --git a/src/skill_seekers/mcp/README.md b/src/skill_seekers/mcp/README.md index 22d8b6f..b1da955 100644 --- a/src/skill_seekers/mcp/README.md +++ b/src/skill_seekers/mcp/README.md @@ -1,10 +1,13 @@ # Skill Seeker MCP Server -Model Context Protocol (MCP) server for Skill Seeker - enables Claude Code to generate documentation skills directly. +> Works with **Claude Code**, **Cursor**, **Windsurf**, **VS Code + Cline**, and **IntelliJ IDEA**. +> Supports API mode (Anthropic, Moonshot/Kimi, Google Gemini, OpenAI) and LOCAL mode (any AI coding agent). + +Model Context Protocol (MCP) server for Skill Seeker - enables AI coding agents to generate documentation skills directly. ## What is This? -This MCP server allows Claude Code to use Skill Seeker's tools directly through natural language commands. Instead of running CLI commands manually, you can ask Claude Code to: +This MCP server allows your AI coding agent to use Skill Seeker's tools directly through natural language commands. Instead of running CLI commands manually, you can ask your agent to: - Generate config files for any documentation site - Estimate page counts before scraping @@ -36,12 +39,12 @@ pip3 install -e ".[mcp]" # - Install dependencies # - Test the server # - Generate configuration -# - Guide you through Claude Code setup +# - Guide you through agent setup ``` ### 3. Manual Setup -Add to `~/.claude.json`: +**For Claude Code** - add to `~/.claude.json`: ```json { @@ -62,13 +65,15 @@ Add to `~/.claude.json`: **Replace `/path/to/Skill_Seekers`** with your actual repository path! -### 4. Restart Claude Code +**For Cursor/Windsurf** - use HTTP transport mode. See your editor's MCP documentation for configuration details. -Quit and reopen Claude Code (don't just close the window). +### 4. Restart Your Agent + +Quit and reopen your AI coding agent (don't just close the window). ### 5. Test -In Claude Code, type: +In your AI coding agent, type: ``` List all available configs ``` @@ -107,7 +112,7 @@ Estimate pages for configs/react.json ``` ### 3. `scrape_docs` -Scrape documentation and build Claude skill. +Scrape documentation and build LLM skill. **Parameters:** - `config_path` (required): Path to config file @@ -125,16 +130,17 @@ Package skill directory into platform-specific format. Automatically uploads if **Parameters:** - `skill_dir` (required): Path to skill directory (e.g., "output/react/") -- `target` (optional): Target platform - "claude", "gemini", "openai", "markdown" (default: "claude") +- `target` (optional): Target platform - "claude", "gemini", "openai", "markdown", and more (default: auto-detected from environment) - `auto_upload` (optional): Try to upload automatically if API key is available (default: true) **Platform-specific outputs:** -- Claude/OpenAI/Markdown: `.zip` file +- Claude/OpenAI/Markdown/Kimi/DeepSeek/Qwen: `.zip` file - Gemini: `.tar.gz` file **Examples:** ``` -Package skill for Claude (default): output/react/ +Package skill (auto-detected platform): output/react/ +Package skill for Claude: output/react/ with target claude Package skill for Gemini: output/react/ with target gemini Package skill for OpenAI: output/react/ with target openai Package skill for Markdown: output/react/ with target markdown @@ -145,7 +151,7 @@ Upload skill package to target LLM platform (requires platform-specific API key) **Parameters:** - `skill_zip` (required): Path to skill package (`.zip` or `.tar.gz`) -- `target` (optional): Target platform - "claude", "gemini", "openai" (default: "claude") +- `target` (optional): Target platform - "claude", "gemini", "openai" (default: auto-detected from environment) **Examples:** ``` @@ -161,8 +167,8 @@ Enhance SKILL.md with AI using target platform's model. Transforms basic templat **Parameters:** - `skill_dir` (required): Path to skill directory (e.g., "output/react/") -- `target` (optional): Target platform - "claude", "gemini", "openai" (default: "claude") -- `mode` (optional): "local" (Claude Code Max, no API key) or "api" (requires API key) (default: "local") +- `target` (optional): Target platform - "claude", "gemini", "openai" (default: auto-detected from environment) +- `mode` (optional): "local" (AI coding agent, no API key) or "api" (requires API key) (default: "local") - `api_key` (optional): Platform API key (uses env var if not provided) **What it does:** @@ -173,12 +179,12 @@ Enhance SKILL.md with AI using target platform's model. Transforms basic templat **Examples:** ``` -Enhance with Claude locally (no API key): output/react/ +Enhance locally (no API key): output/react/ Enhance with Gemini API: output/react/ with target gemini and mode api Enhance with OpenAI API: output/react/ with target openai and mode api ``` -**Note:** Local mode uses Claude Code Max (requires Claude Code but no API key). API mode requires platform-specific API key. +**Note:** Local mode uses your AI coding agent (no API key needed). API mode requires a platform-specific API key. ### 7. `list_configs` List all available preset configurations. @@ -240,7 +246,7 @@ Generate router for configs/godot-*.json - Users can ask questions naturally, router directs to appropriate sub-skill ### 11. `scrape_pdf` -Scrape PDF documentation and build Claude skill. Extracts text, code blocks, images, and tables from PDF files with advanced features. +Scrape PDF documentation and build LLM skill. Extracts text, code blocks, images, and tables from PDF files with advanced features. **Parameters:** - `config_path` (optional): Path to PDF config JSON file (e.g., "configs/manual_pdf.json") @@ -293,20 +299,20 @@ Fast parallel processing: --pdf docs/large.pdf --parallel --workers 8 ``` User: Generate config for Svelte at https://svelte.dev/docs -Claude: โœ… Config created: configs/svelte.json +Agent: โœ… Config created: configs/svelte.json User: Estimate pages for configs/svelte.json -Claude: ๐Ÿ“Š Estimated pages: 150 +Agent: ๐Ÿ“Š Estimated pages: 150 User: Scrape docs using configs/svelte.json -Claude: โœ… Skill created at output/svelte/ +Agent: โœ… Skill created at output/svelte/ User: Package skill at output/svelte/ -Claude: โœ… Created: output/svelte.zip - Ready to upload to Claude! +Agent: โœ… Created: output/svelte.zip + Ready to upload! ``` ### Use Existing Preset @@ -314,15 +320,15 @@ Claude: โœ… Created: output/svelte.zip ``` User: List all available configs -Claude: [Shows all configs: godot, react, vue, django, fastapi, etc.] +Agent: [Shows all configs: godot, react, vue, django, fastapi, etc.] User: Scrape docs using configs/react.json -Claude: โœ… Skill created at output/react/ +Agent: โœ… Skill created at output/react/ User: Package skill at output/react/ -Claude: โœ… Created: output/react.zip +Agent: โœ… Created: output/react.zip ``` ### Validate Before Scraping @@ -330,7 +336,7 @@ Claude: โœ… Created: output/react.zip ``` User: Validate configs/godot.json -Claude: โœ… Config is valid! +Agent: โœ… Config is valid! Name: godot Base URL: https://docs.godotengine.org/en/stable/ Max pages: 500 @@ -338,7 +344,7 @@ Claude: โœ… Config is valid! User: Scrape docs using configs/godot.json -Claude: [Starts scraping...] +Agent: [Starts scraping...] ``` ### PDF Documentation - NEW @@ -346,17 +352,17 @@ Claude: [Starts scraping...] ``` User: Scrape PDF at docs/api-manual.pdf and create skill named api-docs -Claude: ๐Ÿ“„ Scraping PDF documentation... - โœ… Extracted 120 pages - โœ… Found 45 code blocks (Python, JavaScript, C++) - โœ… Extracted 12 images - โœ… Created skill at output/api-docs/ - ๐Ÿ“ฆ Package with: python3 cli/package_skill.py output/api-docs/ +Agent: ๐Ÿ“„ Scraping PDF documentation... + โœ… Extracted 120 pages + โœ… Found 45 code blocks (Python, JavaScript, C++) + โœ… Extracted 12 images + โœ… Created skill at output/api-docs/ + ๐Ÿ“ฆ Package with: python3 cli/package_skill.py output/api-docs/ User: Package skill at output/api-docs/ -Claude: โœ… Created: output/api-docs.zip - Ready to upload to Claude! +Agent: โœ… Created: output/api-docs.zip + Ready to upload! ``` ### Large Documentation (40K Pages) @@ -364,13 +370,13 @@ Claude: โœ… Created: output/api-docs.zip ``` User: Estimate pages for configs/godot.json -Claude: ๐Ÿ“Š Estimated pages: 40,000 - โš ๏ธ Large documentation detected! - ๐Ÿ’ก Recommend splitting into multiple skills +Agent: ๐Ÿ“Š Estimated pages: 40,000 + โš ๏ธ Large documentation detected! + ๐Ÿ’ก Recommend splitting into multiple skills User: Split configs/godot.json using router strategy -Claude: โœ… Split complete! +Agent: โœ… Split complete! Created 5 sub-skills: - godot-scripting.json (5,000 pages) - godot-2d.json (8,000 pages) @@ -380,12 +386,12 @@ Claude: โœ… Split complete! User: Scrape all godot sub-skills in parallel -Claude: [Starts scraping all 5 configs in parallel...] - โœ… All skills created in 4-8 hours instead of 20-40! +Agent: [Starts scraping all 5 configs in parallel...] + โœ… All skills created in 4-8 hours instead of 20-40! User: Generate router for configs/godot-*.json -Claude: โœ… Router skill created at output/godot/ +Agent: โœ… Router skill created at output/godot/ Routing logic: - "scripting", "gdscript" โ†’ godot-scripting - "2d", "sprites", "tilemap" โ†’ godot-2d @@ -395,16 +401,16 @@ Claude: โœ… Router skill created at output/godot/ User: Package all godot skills -Claude: โœ… 6 skills packaged: - - godot.zip (router) - - godot-scripting.zip - - godot-2d.zip - - godot-3d.zip - - godot-physics.zip - - godot-shaders.zip +Agent: โœ… 6 skills packaged: + - godot.zip (router) + - godot-scripting.zip + - godot-2d.zip + - godot-3d.zip + - godot-physics.zip + - godot-shaders.zip - Upload all to Claude! - Users just ask questions naturally - router handles routing! + Upload all to your LLM platform! + Users just ask questions naturally - router handles routing! ``` ## Architecture @@ -420,11 +426,11 @@ mcp/ ### How It Works -1. **Claude Code** sends MCP requests to the server +1. **AI coding agent** (Claude Code, Cursor, Windsurf, etc.) sends MCP requests to the server 2. **Server** routes requests to appropriate tool functions 3. **Tools** call CLI scripts (`doc_scraper.py`, `estimate_pages.py`, etc.) 4. **CLI scripts** perform actual work (scraping, packaging, etc.) -5. **Results** returned to Claude Code via MCP protocol +5. **Results** returned to the agent via MCP protocol ### Tool Implementation @@ -482,12 +488,12 @@ python3 -m pytest tests/test_mcp_server.py -v ### MCP Server Not Loading **Symptoms:** -- Tools don't appear in Claude Code +- Tools don't appear in your AI coding agent - No response to skill-seeker commands **Solutions:** -1. Check configuration: +1. Check configuration (for Claude Code): ```bash cat ~/.config/claude-code/mcp.json ``` @@ -503,11 +509,12 @@ python3 -m pytest tests/test_mcp_server.py -v pip3 install -r mcp/requirements.txt ``` -4. Completely restart Claude Code (quit and reopen) +4. Completely restart your AI coding agent (quit and reopen) -5. Check Claude Code logs: - - macOS: `~/Library/Logs/Claude Code/` - - Linux: `~/.config/claude-code/logs/` +5. Check agent logs: + - Claude Code (macOS): `~/Library/Logs/Claude Code/` + - Claude Code (Linux): `~/.config/claude-code/logs/` + - Cursor/Windsurf: Check your editor's output panel for MCP errors ### "ModuleNotFoundError: No module named 'mcp'" @@ -551,7 +558,7 @@ pip install requests beautifulsoup4 which python3 # Copy this path ``` -Configure Claude Code to use venv Python: +Configure your AI coding agent to use venv Python (example for Claude Code): ```json { diff --git a/src/skill_seekers/mcp/config_publisher.py b/src/skill_seekers/mcp/config_publisher.py new file mode 100644 index 0000000..2c6d33b --- /dev/null +++ b/src/skill_seekers/mcp/config_publisher.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Config Publisher +Pushes validated config files to registered config source repositories. + +Follows the same pattern as MarketplacePublisher but for configs. +""" + +import json +import logging +import os +import shutil +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Category keywords for auto-detection +CATEGORY_KEYWORDS = { + "game-engines": ["unity", "godot", "unreal", "gamemaker", "spine", "dotween", "addressable"], + "web-frameworks": [ + "react", + "vue", + "angular", + "next", + "nuxt", + "svelte", + "django", + "flask", + "fastapi", + "express", + ], + "ai-ml": ["tensorflow", "pytorch", "langchain", "llama", "openai", "anthropic", "huggingface"], + "databases": ["postgres", "mysql", "mongo", "redis", "sqlite", "prisma", "drizzle"], + "devops": ["docker", "kubernetes", "terraform", "ansible", "jenkins", "github-actions"], + "cloud": ["aws", "gcp", "azure", "vercel", "netlify", "cloudflare"], + "mobile": ["flutter", "react-native", "swift", "kotlin", "expo"], + "testing": ["jest", "pytest", "cypress", "playwright", "vitest"], + "build-tools": ["webpack", "vite", "esbuild", "turbo", "nx"], + "css-frameworks": ["tailwind", "bootstrap", "material-ui", "chakra"], + "security": ["oauth", "jwt", "auth0", "keycloak"], + "development-tools": ["git", "vscode", "neovim", "cursor"], + "messaging": ["kafka", "rabbitmq", "nats", "redis-streams"], +} + + +def detect_category(config: dict) -> str: + """Auto-detect category from config name and description. + + Args: + config: Parsed config dictionary + + Returns: + Category string (e.g., "game-engines") or "custom" if undetected + """ + name = config.get("name", "").lower() + description = config.get("description", "").lower() + text = f"{name} {description}" + + best_category = "custom" + best_score = 0 + + for category, keywords in CATEGORY_KEYWORDS.items(): + score = sum(1 for kw in keywords if kw in text) + if score > best_score: + best_score = score + best_category = category + + return best_category + + +class ConfigPublisher: + """Pushes validated configs to registered config source repositories.""" + + def __init__(self, cache_dir: str | None = None): + """Initialize publisher. + + Args: + cache_dir: Base cache directory. Defaults to ~/.skill-seekers/cache/ + """ + from skill_seekers.mcp.git_repo import GitConfigRepo + + self.git_repo = GitConfigRepo(cache_dir) + + def publish( + self, + config_path: str | Path, + source_name: str, + category: str = "auto", + create_branch: bool = False, + force: bool = False, + ) -> dict: + """Publish a config to a registered config source repository. + + Args: + config_path: Path to config JSON file + source_name: Registered source name (e.g., "spyke") + category: Category directory (e.g., "game-engines") or "auto" to detect + create_branch: Create feature branch instead of committing to main + force: Overwrite existing config if it exists + + Returns: + Dict with success, config_path, commit_sha, branch, message + """ + import git + + config_path = Path(config_path) + + # 1. Validate config file exists + if not config_path.exists(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + # 2. Load and validate config + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + config_name = config.get("name") + if not config_name: + raise ValueError("Config JSON must have a 'name' field") + + # Validate config_name to prevent path traversal + if "/" in config_name or "\\" in config_name or ".." in config_name: + raise ValueError( + f"Invalid config name '{config_name}'. " + "Path separators (/, \\) and traversal sequences (..) are not allowed." + ) + + try: + from skill_seekers.cli.config_validator import validate_config + + validate_config(str(config_path)) + logger.info(f"โœ… Config validated: {config_name}") + except ValueError as e: + logger.warning(f"โš ๏ธ Config validation warning: {e}") + # Continue โ€” validation warnings shouldn't block push + + # 3. Resolve source from registry + from skill_seekers.mcp.source_manager import SourceManager + + manager = SourceManager() + source = manager.get_source(source_name) + if not source: + available = [s["name"] for s in manager.list_sources()] + raise ValueError(f"Source '{source_name}' not found. Available sources: {available}") + + git_url = source["git_url"] + branch = source.get("branch", "main") + token_env = source.get("token_env") + + # 4. Get token + token = os.environ.get(token_env) if token_env else None + if not token: + raise RuntimeError( + f"Token not found. Set {token_env} environment variable for source '{source_name}'" + ) + + # 5. Clone/pull source repo (full clone for push support) + cache_name = f"source_{source_name}" + repo_path = self.git_repo.cache_dir / cache_name + clone_url = self.git_repo.inject_token(git_url, token) if token else git_url + + try: + if repo_path.exists() and (repo_path / ".git").exists(): + repo_obj = git.Repo(repo_path) + repo_obj.remotes.origin.pull(branch) + logger.info(f"๐Ÿ“ฅ Pulled latest from {source_name}/{branch}") + else: + repo_obj = git.Repo.clone_from(clone_url, repo_path, branch=branch) + logger.info(f"๐Ÿ“ฅ Cloned {source_name} repo") + # Clear token from cached .git/config by resetting to non-token URL + repo_obj.remotes.origin.set_url(git_url) + except git.GitCommandError as e: + raise RuntimeError(f"Failed to clone/pull source repo: {e}") from e + + # 6. Auto-detect category + if category == "auto": + category = detect_category(config) + logger.info(f"๐Ÿ“‚ Auto-detected category: {category}") + + # 7. Check if config already exists + target_dir = repo_path / "configs" / category + target_file = target_dir / f"{config_name}.json" + + if target_file.exists() and not force: + raise ValueError( + f"Config '{config_name}' already exists in {source_name}/configs/{category}/. " + "Use force=True to overwrite." + ) + + # 8. Copy config to target directory + target_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(config_path, target_file) + logger.info(f"๐Ÿ“„ Placed config at configs/{category}/{config_name}.json") + + # 9. Git commit and push + repo = git.Repo(repo_path) + + target_branch = branch + if create_branch: + target_branch = f"config/{config_name}" + repo.git.checkout("-b", target_branch) + + repo.index.add([str(target_file.relative_to(repo_path))]) + + action = "update" if target_file.exists() and force else "add" + commit_msg = f"feat: {action} {config_name} config in {category}" + commit = repo.index.commit(commit_msg) + + # Push + try: + repo.remotes.origin.push(target_branch) + logger.info(f"๐Ÿš€ Pushed to {source_name}/{target_branch}") + except git.GitCommandError as e: + raise RuntimeError( + f"Failed to push to {source_name}. Check permissions for {token_env}. Error: {e}" + ) from e + + # Switch back to main if we created a branch + if create_branch: + repo.git.checkout(branch) + + return { + "success": True, + "config_name": config_name, + "config_path": f"configs/{category}/{config_name}.json", + "source": source_name, + "category": category, + "commit_sha": str(commit.hexsha)[:8], + "branch": target_branch, + "message": commit_msg, + } diff --git a/src/skill_seekers/mcp/marketplace_manager.py b/src/skill_seekers/mcp/marketplace_manager.py new file mode 100644 index 0000000..d0e546f --- /dev/null +++ b/src/skill_seekers/mcp/marketplace_manager.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Marketplace Manager +Manages registry of plugin marketplace repositories for skill publishing. +""" + +import json +from datetime import datetime, timezone +from pathlib import Path + + +class MarketplaceManager: + """Manages marketplace registry at ~/.skill-seekers/marketplaces.json""" + + def __init__(self, config_dir: str | None = None): + """ + Initialize marketplace manager. + + Args: + config_dir: Base config directory. Defaults to ~/.skill-seekers/ + """ + if config_dir: + self.config_dir = Path(config_dir) + else: + self.config_dir = Path.home() / ".skill-seekers" + + self.config_dir.mkdir(parents=True, exist_ok=True) + self.registry_file = self.config_dir / "marketplaces.json" + + if not self.registry_file.exists(): + self._write_registry({"version": "1.0", "marketplaces": []}) + + def add_marketplace( + self, + name: str, + git_url: str, + token_env: str | None = None, + branch: str = "main", + author: dict | None = None, + enabled: bool = True, + ) -> dict: + """ + Add or update a marketplace repository. + + Args: + name: Marketplace identifier (lowercase, alphanumeric + hyphens/underscores) + git_url: Git repository URL + token_env: Environment variable name for auth token + branch: Git branch to use (default: main) + author: Default author for plugin.json ({"name": str, "email": str}) + enabled: Whether marketplace is enabled (default: True) + + Returns: + Marketplace dictionary + + Raises: + ValueError: If name is invalid or git_url is empty + """ + if not name or not name.replace("-", "").replace("_", "").isalnum(): + raise ValueError( + f"Invalid marketplace name '{name}'. " + "Must be alphanumeric with optional hyphens/underscores." + ) + + if not git_url or not git_url.strip(): + raise ValueError("git_url cannot be empty") + + if token_env is None: + token_env = self._default_token_env(git_url) + + now = datetime.now(timezone.utc).isoformat() + marketplace = { + "name": name.lower(), + "git_url": git_url.strip(), + "token_env": token_env, + "branch": branch, + "author": author or {"name": "", "email": ""}, + "enabled": enabled, + "added_at": now, + "updated_at": now, + } + + registry = self._read_registry() + + existing_index = None + for i, existing in enumerate(registry["marketplaces"]): + if existing["name"] == marketplace["name"]: + existing_index = i + marketplace["added_at"] = existing.get("added_at", marketplace["added_at"]) + break + + if existing_index is not None: + registry["marketplaces"][existing_index] = marketplace + else: + registry["marketplaces"].append(marketplace) + + self._write_registry(registry) + return marketplace + + def get_marketplace(self, name: str) -> dict: + """ + Get marketplace by name. + + Raises: + KeyError: If marketplace not found + """ + registry = self._read_registry() + name_lower = name.lower() + for marketplace in registry["marketplaces"]: + if marketplace["name"] == name_lower: + return marketplace + + available = [m["name"] for m in registry["marketplaces"]] + raise KeyError( + f"Marketplace '{name}' not found. " + f"Available marketplaces: {', '.join(available) if available else 'none'}" + ) + + def list_marketplaces(self, enabled_only: bool = False) -> list[dict]: + """List all marketplaces.""" + registry = self._read_registry() + if enabled_only: + return [m for m in registry["marketplaces"] if m.get("enabled", True)] + return registry["marketplaces"] + + def remove_marketplace(self, name: str) -> bool: + """Remove marketplace by name. Returns True if removed.""" + registry = self._read_registry() + name_lower = name.lower() + for i, marketplace in enumerate(registry["marketplaces"]): + if marketplace["name"] == name_lower: + del registry["marketplaces"][i] + self._write_registry(registry) + return True + return False + + def update_marketplace(self, name: str, **kwargs) -> dict: + """ + Update specific fields of an existing marketplace. + + Raises: + KeyError: If marketplace not found + """ + marketplace = self.get_marketplace(name) + allowed_fields = {"git_url", "token_env", "branch", "author", "enabled"} + for field, value in kwargs.items(): + if field in allowed_fields: + marketplace[field] = value + + marketplace["updated_at"] = datetime.now(timezone.utc).isoformat() + + registry = self._read_registry() + for i, m in enumerate(registry["marketplaces"]): + if m["name"] == marketplace["name"]: + registry["marketplaces"][i] = marketplace + break + + self._write_registry(registry) + return marketplace + + def _read_registry(self) -> dict: + """Read registry from file.""" + try: + with open(self.registry_file, encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Corrupted registry file: {e}") from e + + def _write_registry(self, registry: dict) -> None: + """Write registry to file atomically.""" + if "version" not in registry or "marketplaces" not in registry: + raise ValueError("Invalid registry schema") + + temp_file = self.registry_file.with_suffix(".tmp") + try: + with open(temp_file, "w", encoding="utf-8") as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + temp_file.replace(self.registry_file) + except Exception as e: + if temp_file.exists(): + temp_file.unlink() + raise e + + @staticmethod + def _default_token_env(git_url: str) -> str: + """Get default token environment variable name from git URL.""" + url_lower = git_url.lower() + if "github" in url_lower: + return "GITHUB_TOKEN" + elif "gitlab" in url_lower: + return "GITLAB_TOKEN" + elif "bitbucket" in url_lower: + return "BITBUCKET_TOKEN" + elif "gitea" in url_lower: + return "GITEA_TOKEN" + return "GIT_TOKEN" diff --git a/src/skill_seekers/mcp/marketplace_publisher.py b/src/skill_seekers/mcp/marketplace_publisher.py new file mode 100644 index 0000000..fc46d67 --- /dev/null +++ b/src/skill_seekers/mcp/marketplace_publisher.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +Marketplace Publisher +Publishes packaged skills to Claude Code plugin marketplace repositories. +""" + +import json +import logging +import os +import re +import shutil +from pathlib import Path + +import yaml + +from skill_seekers.mcp.marketplace_manager import MarketplaceManager + +logger = logging.getLogger(__name__) + + +class MarketplacePublisher: + """Publishes skills to registered plugin marketplace repositories.""" + + def __init__(self, cache_dir: str | None = None): + """ + Initialize publisher. + + Args: + cache_dir: Base cache directory. Defaults to ~/.skill-seekers/cache/ + """ + from skill_seekers.mcp.git_repo import GitConfigRepo + + self.git_repo = GitConfigRepo(cache_dir) + + def publish( + self, + skill_dir: str | Path, + marketplace_name: str, + category: str = "development", + skill_name: str | None = None, + description: str | None = None, + create_branch: bool = False, + force: bool = False, + ) -> dict: + """ + Publish a skill to a plugin marketplace repository. + + Args: + skill_dir: Path to skill directory (contains SKILL.md + references/) + marketplace_name: Registered marketplace name + category: Plugin category in marketplace (default: "development") + skill_name: Override skill name (defaults to SKILL.md frontmatter or dir name) + description: Override description (defaults to SKILL.md frontmatter) + create_branch: Create feature branch instead of committing to main + force: Overwrite existing plugin if it exists + + Returns: + Dict with success, plugin_path, commit_sha, branch, message + """ + import git + + skill_dir = Path(skill_dir) + + # 1. Validate skill directory + skill_md_path = skill_dir / "SKILL.md" + if not skill_md_path.exists(): + raise FileNotFoundError(f"SKILL.md not found in {skill_dir}") + + # 2. Read frontmatter for metadata + frontmatter = self._read_frontmatter(skill_md_path) + if not skill_name: + skill_name = frontmatter.get("name") or skill_dir.name + if not description: + description = frontmatter.get("description", f"Skill for {skill_name}") + + # 2b. Validate skill_name to prevent path traversal + skill_name = self._validate_skill_name(skill_name) + + # 3. Resolve marketplace + manager = MarketplaceManager() + marketplace = manager.get_marketplace(marketplace_name) + git_url = marketplace["git_url"] + branch = marketplace["branch"] + token_env = marketplace["token_env"] + author = marketplace.get("author", {"name": "", "email": ""}) + + # 4. Get token + token = os.environ.get(token_env) if token_env else None + if not token: + raise RuntimeError( + f"Token not found. Set {token_env} environment variable " + f"for marketplace '{marketplace_name}'" + ) + + # 5. Clone/pull marketplace repo (full clone, not shallow โ€” needed for push) + cache_name = f"marketplace_{marketplace_name}" + repo_path = self.git_repo.cache_dir / cache_name + clone_url = self.git_repo.inject_token(git_url, token) if token else git_url + try: + if repo_path.exists() and (repo_path / ".git").exists(): + repo_obj = git.Repo(repo_path) + repo_obj.remotes.origin.pull(branch) + else: + repo_obj = git.Repo.clone_from(clone_url, repo_path, branch=branch) + # Clear token from cached .git/config by resetting to non-token URL + repo_obj.remotes.origin.set_url(git_url) + except git.GitCommandError as e: + raise RuntimeError(f"Failed to clone/pull marketplace repo: {e}") from e + + # 6. Check for existing plugin + plugin_dir = repo_path / "plugins" / skill_name + if plugin_dir.exists() and not force: + raise ValueError( + f"Plugin '{skill_name}' already exists in marketplace '{marketplace_name}'. " + "Use force=True to overwrite." + ) + + # 7. Create plugin directory structure + commit + push + # Wrap in try/finally to clean up partial plugin dir on failure + plugin_created = False + try: + self._copy_skill_to_plugin(skill_dir, plugin_dir, skill_name) + plugin_created = True + + # 8. Generate plugin.json + plugin_json = self._generate_plugin_json(skill_name, description, author) + plugin_json_dir = plugin_dir / ".claude-plugin" + plugin_json_dir.mkdir(parents=True, exist_ok=True) + with open(plugin_json_dir / "plugin.json", "w", encoding="utf-8") as f: + json.dump(plugin_json, f, indent=2, ensure_ascii=False) + + # 9. Update marketplace.json + self._update_marketplace_json(repo_path, skill_name, description, author, category) + + # 10. Git commit and push + repo = git.Repo(repo_path) + + target_branch = branch + if create_branch: + target_branch = f"skill/{skill_name}" + repo.git.checkout("-b", target_branch) + + # Only stage the specific files we wrote (not the entire repo) + files_to_stage = [] + # Stage the plugin directory (skill files + plugin.json) + plugin_rel = str(plugin_dir.relative_to(repo_path)) + files_to_stage.append(plugin_rel) + # Stage marketplace.json + marketplace_json_rel = str( + (repo_path / ".claude-plugin" / "marketplace.json").relative_to(repo_path) + ) + files_to_stage.append(marketplace_json_rel) + repo.index.add(files_to_stage) + + action = "update" if force else "add" + commit_msg = f"feat: {action} {skill_name} skill plugin" + repo.index.commit(commit_msg) + commit_sha = repo.head.commit.hexsha[:7] + + push_url = self.git_repo.inject_token(git_url, token) + repo.git.push(push_url, target_branch) + + return { + "success": True, + "plugin_path": f"plugins/{skill_name}", + "commit_sha": commit_sha, + "branch": target_branch, + "message": f"Published '{skill_name}' to marketplace '{marketplace_name}'", + } + + except git.GitCommandError as e: + # Reset git state on push/commit failure + try: + repo = git.Repo(repo_path) + repo.git.checkout("--", ".") + repo.git.clean("-fd", "plugins/" + skill_name) + except Exception: + pass + raise RuntimeError(f"Git operation failed: {e}") from e + except Exception: + # Clean up partial plugin directory on non-git failure + if plugin_created and plugin_dir.exists(): + shutil.rmtree(plugin_dir, ignore_errors=True) + raise + + @staticmethod + def _validate_skill_name(name: str) -> str: + """ + Validate skill name to prevent path traversal and injection. + + Args: + name: Skill name to validate + + Returns: + Validated skill name + + Raises: + ValueError: If name contains invalid characters + """ + if not name or not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", name): + raise ValueError( + f"Invalid skill name '{name}'. " + "Must start with alphanumeric and contain only alphanumeric, hyphens, underscores, or dots." + ) + if ".." in name or "/" in name or "\\" in name: + raise ValueError(f"Invalid skill name '{name}'. Path traversal characters not allowed.") + return name + + def _read_frontmatter(self, skill_md_path: Path) -> dict: + """Parse YAML frontmatter from SKILL.md.""" + content = skill_md_path.read_text(encoding="utf-8") + if not content.startswith("---"): + return {} + + parts = content.split("---", 2) + if len(parts) < 3: + return {} + + try: + return yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + return {} + + def _copy_skill_to_plugin(self, skill_dir: Path, plugin_dir: Path, skill_name: str) -> None: + """Copy skill files into plugin directory structure.""" + skills_dest = plugin_dir / "skills" / skill_name + + if skills_dest.exists(): + shutil.rmtree(skills_dest) + + skills_dest.mkdir(parents=True, exist_ok=True) + shutil.copy2(skill_dir / "SKILL.md", skills_dest / "SKILL.md") + + refs_src = skill_dir / "references" + if refs_src.exists() and refs_src.is_dir(): + shutil.copytree(refs_src, skills_dest / "references", dirs_exist_ok=True) + + def _generate_plugin_json(self, skill_name: str, description: str, author: dict) -> dict: + """Generate plugin.json content.""" + return { + "name": skill_name, + "description": description, + "author": author, + } + + def _update_marketplace_json( + self, + repo_path: Path, + skill_name: str, + description: str, + author: dict, + category: str, + ) -> None: + """Update root .claude-plugin/marketplace.json with new plugin entry.""" + marketplace_json_path = repo_path / ".claude-plugin" / "marketplace.json" + + if marketplace_json_path.exists(): + with open(marketplace_json_path, encoding="utf-8") as f: + data = json.load(f) + else: + marketplace_json_path.parent.mkdir(parents=True, exist_ok=True) + data = { + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": repo_path.name, + "description": "", + "owner": author, + "plugins": [], + } + + entry = { + "name": skill_name, + "description": description, + "author": author, + "source": f"./plugins/{skill_name}", + "category": category, + } + + updated = False + for i, plugin in enumerate(data["plugins"]): + if plugin["name"] == skill_name: + data["plugins"][i] = entry + updated = True + break + + if not updated: + data["plugins"].append(entry) + + data["plugins"].sort(key=lambda p: p["name"]) + + with open(marketplace_json_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) diff --git a/src/skill_seekers/mcp/server_fastmcp.py b/src/skill_seekers/mcp/server_fastmcp.py index 6d8bf3e..bbd93fb 100644 --- a/src/skill_seekers/mcp/server_fastmcp.py +++ b/src/skill_seekers/mcp/server_fastmcp.py @@ -3,7 +3,7 @@ Skill Seeker MCP Server (FastMCP Implementation) Modern, decorator-based MCP server using FastMCP for simplified tool registration. -Provides 34 tools for generating Claude AI skills from documentation. +Provides 34 tools for generating LLM skills from documentation. This is a streamlined alternative to server.py (2200 lines โ†’ 708 lines, 68% reduction). All tool implementations are delegated to modular tool files in tools/ directory. @@ -15,7 +15,8 @@ All tool implementations are delegated to modular tool files in tools/ directory * Scraping tools (11): estimate_pages, scrape_docs, scrape_github, scrape_pdf, scrape_video, scrape_codebase, detect_patterns, extract_test_examples, build_how_to_guides, extract_config_patterns, scrape_generic * Packaging tools (4): package_skill, upload_skill, enhance_skill, install_skill * Splitting tools (2): split_config, generate_router - * Source tools (5): fetch_config, submit_config, add_config_source, list_config_sources, remove_config_source + * Source tools (6): fetch_config, submit_config, push_config, add_config_source, list_config_sources, remove_config_source + * Marketplace tools (4): add_marketplace, list_marketplaces, remove_marketplace, publish_to_marketplace * Vector Database tools (4): export_to_weaviate, export_to_chroma, export_to_faiss, export_to_qdrant * Workflow tools (5): list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow @@ -86,6 +87,11 @@ try: extract_test_examples_impl, # Source tools fetch_config_impl, + # Marketplace tools + add_marketplace_impl, + list_marketplaces_impl, + remove_marketplace_impl, + publish_to_marketplace_impl, # Config tools generate_config_impl, generate_router_impl, @@ -133,6 +139,10 @@ except ImportError: extract_config_patterns_impl, extract_test_examples_impl, fetch_config_impl, + add_marketplace_impl, + list_marketplaces_impl, + remove_marketplace_impl, + publish_to_marketplace_impl, generate_config_impl, generate_router_impl, install_skill_impl, @@ -163,7 +173,7 @@ mcp = None if MCP_AVAILABLE and FastMCP is not None: mcp = FastMCP( name="skill-seeker", - instructions="Skill Seeker MCP Server - Generate Claude AI skills from documentation", + instructions="Skill Seeker MCP Server - Generate LLM skills from documentation", ) @@ -338,7 +348,7 @@ async def estimate_pages( @safe_tool_decorator( - description="Scrape documentation and build Claude skill. Supports both single-source (legacy) and unified multi-source configs. Creates SKILL.md and reference files. Automatically detects llms.txt files for 10x faster processing. Falls back to HTML scraping if not available." + description="Scrape documentation and build LLM skill. Supports both single-source (legacy) and unified multi-source configs. Creates SKILL.md and reference files. Automatically detects llms.txt files for 10x faster processing. Falls back to HTML scraping if not available." ) async def scrape_docs( config_path: str, @@ -349,12 +359,12 @@ async def scrape_docs( merge_mode: str | None = None, ) -> str: """ - Scrape documentation and build Claude skill. + Scrape documentation and build LLM skill. Args: config_path: Path to config JSON file (e.g., configs/react.json or configs/godot_unified.json) unlimited: Remove page limit - scrape all pages (default: false). Overrides max_pages in config. - enhance_local: Open terminal for local enhancement with Claude Code (default: false) + enhance_local: Open terminal for local enhancement with AI coding agent (default: false) skip_scrape: Skip scraping, use cached data (default: false) dry_run: Preview what will be scraped without saving (default: false) merge_mode: Override merge mode for unified configs: 'rule-based' or 'claude-enhanced' (default: from config) @@ -879,7 +889,7 @@ async def scrape_generic( ) async def package_skill( skill_dir: str, - target: str = "claude", + target: str = "auto", auto_upload: bool = True, ) -> str: """ @@ -887,12 +897,16 @@ async def package_skill( Args: skill_dir: Path to skill directory to package (e.g., output/react/) - target: Target platform (default: 'claude'). Options: claude, gemini, openai, markdown + target: Target platform (default: 'auto'). Options: auto, claude, gemini, openai, markdown auto_upload: Auto-upload after packaging if API key is available (default: true). Requires platform-specific API key: ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY. Returns: Packaging results with file path and platform info. """ + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() args = { "skill_dir": skill_dir, "target": target, @@ -909,7 +923,7 @@ async def package_skill( ) async def upload_skill( skill_zip: str, - target: str = "claude", + target: str = "auto", api_key: str | None = None, ) -> str: """ @@ -917,12 +931,16 @@ async def upload_skill( Args: skill_zip: Path to skill package (.zip or .tar.gz, e.g., output/react.zip) - target: Target platform (default: 'claude'). Options: claude, gemini, openai + target: Target platform (default: 'auto'). Options: auto, claude, gemini, openai api_key: Optional API key (uses env var if not provided: ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY) Returns: Upload results with skill ID and platform URL. """ + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() args = { "skill_zip": skill_zip, "target": target, @@ -937,11 +955,11 @@ async def upload_skill( @safe_tool_decorator( - description="Enhance SKILL.md with AI using target platform's model. Local mode uses Claude Code Max (no API key). API mode uses platform API (requires key). Transforms basic templates into comprehensive 500+ line guides with examples." + description="Enhance SKILL.md with AI using target platform's model. Local mode uses AI coding agent (no API key). API mode uses platform API (requires key). Transforms basic templates into comprehensive 500+ line guides with examples." ) async def enhance_skill( skill_dir: str, - target: str = "claude", + target: str = "auto", mode: str = "local", api_key: str | None = None, ) -> str: @@ -950,13 +968,17 @@ async def enhance_skill( Args: skill_dir: Path to skill directory containing SKILL.md (e.g., output/react/) - target: Target platform (default: 'claude'). Options: claude, gemini, openai - mode: Enhancement mode (default: 'local'). Options: local (Claude Code, no API), api (uses platform API) + target: Target platform (default: 'auto'). Options: auto, claude, gemini, openai + mode: Enhancement mode (default: 'local'). Options: local (AI coding agent, no API), api (uses platform API) api_key: Optional API key for 'api' mode (uses env var if not provided: ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY) Returns: Enhancement results with backup location. """ + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() args = { "skill_dir": skill_dir, "target": target, @@ -972,7 +994,7 @@ async def enhance_skill( @safe_tool_decorator( - 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. Supports multiple LLM platforms: claude (default), gemini, openai, markdown. Auto-uploads if platform API key is set." + 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. Supports multiple LLM platforms: auto (detects from environment), claude, gemini, openai, markdown. Auto-uploads if platform API key is set." ) async def install_skill( config_name: str | None = None, @@ -981,7 +1003,7 @@ async def install_skill( auto_upload: bool = True, unlimited: bool = False, dry_run: bool = False, - target: str = "claude", + target: str = "auto", ) -> str: """ Complete one-command workflow to install a skill. @@ -993,11 +1015,15 @@ async def install_skill( auto_upload: Auto-upload after packaging (requires platform API key). Default: true. Set to false to skip upload. unlimited: Remove page limits during scraping (default: false). WARNING: Can take hours for large sites. dry_run: Preview workflow without executing (default: false). Shows all phases that would run. - target: Target LLM platform (default: 'claude'). Options: claude, gemini, openai, markdown. Requires corresponding API key: ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY. + target: Target LLM platform (default: 'auto'). Options: auto, claude, gemini, openai, markdown. Requires corresponding API key: ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY. Returns: Workflow results with all phase statuses. """ + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() args = { "destination": destination, "auto_upload": auto_upload, @@ -1263,6 +1289,150 @@ async def remove_config_source(name: str) -> str: return str(result) +@safe_tool_decorator( + description="Push a config to a registered config source repository. Validates, places in category directory, commits, and pushes." +) +async def push_config( + config_path: str, + source_name: str, + category: str = "auto", + create_branch: bool = False, + force: bool = False, +) -> str: + """ + Push a config to a registered config source repository. + + Args: + config_path: Path to config JSON file. Example: 'configs/unity-spine.json' + source_name: Registered source name. Example: 'spyke' + category: Category directory (e.g., 'game-engines'). Auto-detected if 'auto'. + create_branch: Create feature branch instead of pushing to main. Default: false + force: Overwrite existing config if it exists. Default: false + + Returns: + Push results with commit SHA and config location. + """ + from skill_seekers.mcp.tools.source_tools import push_config_tool + + result = await push_config_tool( + { + "config_path": config_path, + "source_name": source_name, + "category": category, + "create_branch": create_branch, + "force": force, + } + ) + if isinstance(result, list) and result: + return result[0].text if hasattr(result[0], "text") else str(result[0]) + return str(result) + + +# ============================================================================ +# MARKETPLACE TOOLS (4 tools) +# ============================================================================ + + +@safe_tool_decorator( + description="Register a plugin marketplace repository. Allows publishing skills to private/team plugin repos. Supports GitHub, GitLab, Bitbucket with per-repo authentication." +) +async def add_marketplace( + name: str, + git_url: str, + token_env: str = None, + branch: str = "main", + author_name: str = "", + author_email: str = "", + enabled: bool = True, +) -> str: + """ + Register a plugin marketplace repository. + + Args: + name: Marketplace identifier (lowercase, alphanumeric + hyphens/underscores). Example: 'spyke' + git_url: Git repository URL. Example: 'https://github.com/myorg/plugins.git' + token_env: Environment variable name for auth token (auto-detected from URL). Example: 'GITHUB_TOKEN' + branch: Git branch to use (default: "main") + author_name: Default author name for generated plugin.json files + author_email: Default author email for generated plugin.json files + enabled: Whether marketplace is enabled (default: true) + """ + result = await add_marketplace_impl( + { + "name": name, + "git_url": git_url, + "token_env": token_env, + "branch": branch, + "author_name": author_name, + "author_email": author_email, + "enabled": enabled, + } + ) + if isinstance(result, list) and result: + return result[0].text if hasattr(result[0], "text") else str(result[0]) + return str(result) + + +@safe_tool_decorator(description="List all registered plugin marketplace repositories.") +async def list_marketplaces(enabled_only: bool = False) -> str: + """List all registered plugin marketplace repositories.""" + result = await list_marketplaces_impl({"enabled_only": enabled_only}) + if isinstance(result, list) and result: + return result[0].text if hasattr(result[0], "text") else str(result[0]) + return str(result) + + +@safe_tool_decorator( + description="Remove a registered plugin marketplace. Deletes from registry but not cached data." +) +async def remove_marketplace(name: str) -> str: + """Remove a registered plugin marketplace.""" + result = await remove_marketplace_impl({"name": name}) + if isinstance(result, list) and result: + return result[0].text if hasattr(result[0], "text") else str(result[0]) + return str(result) + + +@safe_tool_decorator( + description="Publish a packaged skill to a plugin marketplace repository. Creates a Claude Code plugin in the target marketplace repo." +) +async def publish_to_marketplace( + skill_dir: str, + marketplace: str, + category: str = "development", + skill_name: str = None, + description: str = None, + create_branch: bool = False, + force: bool = False, +) -> str: + """ + Publish a skill to a plugin marketplace repository. + + Args: + skill_dir: Path to skill directory containing SKILL.md. Example: 'output/react/' + marketplace: Registered marketplace name. Example: 'spyke' + category: Plugin category (default: "development") + skill_name: Override skill name (optional) + description: Override description (optional) + create_branch: Create feature branch instead of committing to main (default: false) + force: Overwrite existing plugin (default: false) + """ + result = await publish_to_marketplace_impl( + { + "skill_dir": skill_dir, + "marketplace": marketplace, + "category": category, + "skill_name": skill_name, + "description": description, + "create_branch": create_branch, + "force": force, + } + ) + if isinstance(result, list) and result: + return result[0].text if hasattr(result[0], "text") else str(result[0]) + return str(result) + + # ============================================================================ # VECTOR DATABASE TOOLS (4 tools) # ============================================================================ diff --git a/src/skill_seekers/mcp/server_legacy.py b/src/skill_seekers/mcp/server_legacy.py index c63dd40..0205b67 100644 --- a/src/skill_seekers/mcp/server_legacy.py +++ b/src/skill_seekers/mcp/server_legacy.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Skill Seeker MCP Server -Model Context Protocol server for generating Claude AI skills from documentation +Model Context Protocol server for generating LLM skills from documentation """ import asyncio @@ -200,7 +200,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="scrape_docs", - description="Scrape documentation and build Claude skill. Supports both single-source (legacy) and unified multi-source configs. Creates SKILL.md and reference files. Automatically detects llms.txt files for 10x faster processing. Falls back to HTML scraping if not available.", + description="Scrape documentation and build LLM skill. Supports both single-source (legacy) and unified multi-source configs. Creates SKILL.md and reference files. Automatically detects llms.txt files for 10x faster processing. Falls back to HTML scraping if not available.", inputSchema={ "type": "object", "properties": { @@ -215,7 +215,7 @@ async def list_tools() -> list[Tool]: }, "enhance_local": { "type": "boolean", - "description": "Open terminal for local enhancement with Claude Code (default: false)", + "description": "Open terminal for local enhancement with AI coding agent (default: false)", "default": False, }, "skip_scrape": { @@ -230,7 +230,7 @@ async def list_tools() -> list[Tool]: }, "merge_mode": { "type": "string", - "description": "Override merge mode for unified configs: 'rule-based' or 'claude-enhanced' (default: from config)", + "description": "Override merge mode for unified configs: 'rule-based' or 'ai-enhanced' (default: from config)", }, }, "required": ["config_path"], @@ -238,7 +238,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="package_skill", - description="Package a skill directory into a .zip file ready for Claude upload. Automatically uploads if ANTHROPIC_API_KEY is set.", + description="Package a skill directory into a .zip file ready for upload. Automatically uploads if ANTHROPIC_API_KEY is set.", inputSchema={ "type": "object", "properties": { @@ -257,7 +257,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="upload_skill", - description="Upload a skill .zip file to Claude automatically (requires ANTHROPIC_API_KEY)", + description="Upload a skill .zip file automatically (requires ANTHROPIC_API_KEY)", inputSchema={ "type": "object", "properties": { @@ -340,7 +340,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="scrape_pdf", - description="Scrape PDF documentation and build Claude skill. Extracts text, code, and images from PDF files.", + description="Scrape PDF documentation and build LLM skill. Extracts text, code, and images from PDF files.", inputSchema={ "type": "object", "properties": { @@ -370,7 +370,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="scrape_github", - description="Scrape GitHub repository and build Claude skill. Extracts README, Issues, Changelog, Releases, and code structure.", + description="Scrape GitHub repository and build LLM skill. Extracts README, Issues, Changelog, Releases, and code structure.", inputSchema={ "type": "object", "properties": { @@ -425,7 +425,7 @@ async def list_tools() -> list[Tool]: ), 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.", + 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 if ANTHROPIC_API_KEY is set.", inputSchema={ "type": "object", "properties": { @@ -444,7 +444,7 @@ async def list_tools() -> list[Tool]: }, "auto_upload": { "type": "boolean", - "description": "Auto-upload to Claude after packaging (requires ANTHROPIC_API_KEY). Default: true. Set to false to skip upload.", + "description": "Auto-upload after packaging (requires ANTHROPIC_API_KEY). Default: true. Set to false to skip upload.", "default": True, }, "unlimited": { @@ -898,7 +898,7 @@ async def package_skill_tool(args: dict) -> list[TextContent]: if should_upload: # Upload succeeded output += "\n\nโœ… Skill packaged and uploaded automatically!" - output += "\n Your skill is now available in Claude!" + output += "\n Your skill has been uploaded successfully!" elif auto_upload and not has_api_key: # User wanted upload but no API key output += "\n\n๐Ÿ“ Skill packaged successfully!" @@ -922,7 +922,7 @@ async def package_skill_tool(args: dict) -> list[TextContent]: async def upload_skill_tool(args: dict) -> list[TextContent]: - """Upload skill .zip to Claude""" + """Upload skill .zip file""" skill_zip = args["skill_zip"] # Run upload_skill.py @@ -931,7 +931,7 @@ async def upload_skill_tool(args: dict) -> list[TextContent]: # Timeout: 5 minutes for upload timeout = 300 - progress_msg = "๐Ÿ“ค Uploading skill to Claude...\n" + progress_msg = "๐Ÿ“ค Uploading skill...\n" progress_msg += f"โฑ๏ธ Maximum time: {timeout // 60} minutes\n\n" stdout, stderr, returncode = run_subprocess_with_streaming(cmd, timeout=timeout) @@ -1194,7 +1194,7 @@ async def scrape_pdf_tool(args: dict) -> list[TextContent]: async def scrape_github_tool(args: dict) -> list[TextContent]: - """Scrape GitHub repository to Claude skill (C1.11)""" + """Scrape GitHub repository to LLM skill (C1.11)""" repo = args.get("repo") config_path = args.get("config_path") name = args.get("name") @@ -1550,7 +1550,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: 2. Scrape documentation 3. AI Enhancement (MANDATORY - no skip option) 4. Package to .zip - 5. Upload to Claude (optional) + 5. Upload (optional) Args: config_name: Config to fetch from API (mutually exclusive with config_path) @@ -1743,7 +1743,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: 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(" [DRY RUN] Would enhance SKILL.md with AI agent") output_lines.append("") @@ -1786,7 +1786,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: # ===== 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(f"๐Ÿ“ค PHASE {phase_num}: Upload Skill") output_lines.append("-" * 70) output_lines.append(f"Zip file: {workflow_state['zip_path']}") output_lines.append("") @@ -1817,7 +1817,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: 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(" [DRY RUN] Would upload skill (if API key set)") output_lines.append("") @@ -1840,7 +1840,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: output_lines.append("") if auto_upload and has_api_key: - output_lines.append("๐ŸŽ‰ Your skill is now available in Claude!") + output_lines.append("๐ŸŽ‰ Your skill has been uploaded successfully!") output_lines.append(" Go to https://claude.ai/skills to use it") elif auto_upload: output_lines.append("๐Ÿ“ Manual upload required (see instructions above)") diff --git a/src/skill_seekers/mcp/tools/__init__.py b/src/skill_seekers/mcp/tools/__init__.py index 0d7c5a9..b8c0f78 100644 --- a/src/skill_seekers/mcp/tools/__init__.py +++ b/src/skill_seekers/mcp/tools/__init__.py @@ -9,6 +9,7 @@ Tools are organized by functionality: - packaging_tools: Skill packaging and upload - splitting_tools: Config splitting and router generation - source_tools: Config source management (fetch, submit, add/remove sources) +- marketplace_tools: Marketplace management (add, list, remove, publish) - vector_db_tools: Vector database export (Weaviate, Chroma, FAISS, Qdrant) """ @@ -84,6 +85,18 @@ from .source_tools import ( from .source_tools import ( submit_config_tool as submit_config_impl, ) +from .marketplace_tools import ( + add_marketplace_tool as add_marketplace_impl, +) +from .marketplace_tools import ( + list_marketplaces_tool as list_marketplaces_impl, +) +from .marketplace_tools import ( + publish_to_marketplace_tool as publish_to_marketplace_impl, +) +from .marketplace_tools import ( + remove_marketplace_tool as remove_marketplace_impl, +) from .splitting_tools import ( generate_router as generate_router_impl, ) @@ -147,6 +160,11 @@ __all__ = [ # Splitting tools "split_config_impl", "generate_router_impl", + # Marketplace tools + "add_marketplace_impl", + "list_marketplaces_impl", + "remove_marketplace_impl", + "publish_to_marketplace_impl", # Source tools "fetch_config_impl", "submit_config_impl", diff --git a/src/skill_seekers/mcp/tools/marketplace_tools.py b/src/skill_seekers/mcp/tools/marketplace_tools.py new file mode 100644 index 0000000..0f21a57 --- /dev/null +++ b/src/skill_seekers/mcp/tools/marketplace_tools.py @@ -0,0 +1,226 @@ +""" +Marketplace management tools for MCP server. + +This module contains tools for managing plugin marketplace repositories: +- add_marketplace: Register a plugin marketplace repository +- list_marketplaces: List all registered marketplace repositories +- remove_marketplace: Remove a registered marketplace +- publish_to_marketplace: Publish a packaged skill to a marketplace +""" + +# MCP types (imported conditionally) +try: + from mcp.types import TextContent + + MCP_AVAILABLE = True +except ImportError: + + class TextContent: + """Fallback TextContent for when MCP is not installed""" + + def __init__(self, type: str, text: str): + self.type = type + self.text = text + + MCP_AVAILABLE = False + + +async def add_marketplace_tool(args: dict) -> list[TextContent]: + """Register a plugin marketplace repository.""" + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + name = args.get("name") + git_url = args.get("git_url") + token_env = args.get("token_env") + branch = args.get("branch", "main") + author_name = args.get("author_name", "") + author_email = args.get("author_email", "") + enabled = args.get("enabled", True) + + try: + if not name: + return [TextContent(type="text", text="โŒ Error: 'name' parameter is required")] + if not git_url: + return [TextContent(type="text", text="โŒ Error: 'git_url' parameter is required")] + + author = {"name": author_name, "email": author_email} + + manager = MarketplaceManager() + marketplace = manager.add_marketplace( + name=name, + git_url=git_url, + token_env=token_env, + branch=branch, + author=author, + enabled=enabled, + ) + + is_update = marketplace["added_at"] != marketplace["updated_at"] + + result = f"""โœ… Marketplace {"updated" if is_update else "registered"} successfully! + +๐Ÿ“› Name: {marketplace["name"]} +๐Ÿ“ Repository: {marketplace["git_url"]} +๐ŸŒฟ Branch: {marketplace["branch"]} +๐Ÿ”‘ Token env: {marketplace["token_env"]} +๐Ÿ‘ค Author: {marketplace["author"]["name"]} <{marketplace["author"]["email"]}> +โœ“ Enabled: {marketplace["enabled"]} +๐Ÿ•’ Added: {marketplace["added_at"][:19]} + +Usage: + # Publish a skill to this marketplace + publish_to_marketplace(skill_dir="output/my-skill", marketplace="{marketplace["name"]}") + + # List all marketplaces + list_marketplaces() + +๐Ÿ’ก Set {marketplace["token_env"]} environment variable for private repos +""" + return [TextContent(type="text", text=result)] + + except ValueError as e: + return [TextContent(type="text", text=f"โŒ Validation Error: {str(e)}")] + except Exception as e: + return [TextContent(type="text", text=f"โŒ Error: {str(e)}")] + + +async def list_marketplaces_tool(args: dict) -> list[TextContent]: + """List all registered plugin marketplace repositories.""" + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + enabled_only = args.get("enabled_only", False) + + try: + manager = MarketplaceManager() + marketplaces = manager.list_marketplaces(enabled_only=enabled_only) + + if not marketplaces: + result = """๐Ÿ“‹ No marketplaces registered + +To add a marketplace: + add_marketplace( + name="my-plugins", + git_url="https://github.com/myorg/plugins.git", + author_name="My Team", + author_email="team@example.com" + ) + +๐Ÿ’ก Once added, use: publish_to_marketplace(skill_dir="...", marketplace="my-plugins") +""" + return [TextContent(type="text", text=result)] + + result = f"๐Ÿ“‹ Plugin Marketplaces ({len(marketplaces)} total" + if enabled_only: + result += ", enabled only" + result += ")\n\n" + + for mp in marketplaces: + status_icon = "โœ“" if mp.get("enabled", True) else "โœ—" + author = mp.get("author", {}) + author_str = f"{author.get('name', '')} <{author.get('email', '')}>" + result += f"{status_icon} **{mp['name']}**\n" + result += f" ๐Ÿ“ {mp['git_url']}\n" + result += f" ๐ŸŒฟ Branch: {mp['branch']} | ๐Ÿ”‘ Token: {mp['token_env']}\n" + result += f" ๐Ÿ‘ค Author: {author_str}\n" + result += f" ๐Ÿ•’ Added: {mp['added_at'][:19]}\n" + result += "\n" + + result += """Usage: + # Publish skill to a marketplace + publish_to_marketplace(skill_dir="output/my-skill", marketplace="MARKETPLACE_NAME") + + # Add new marketplace + add_marketplace(name="...", git_url="...") + + # Remove marketplace + remove_marketplace(name="MARKETPLACE_NAME") +""" + return [TextContent(type="text", text=result)] + + except Exception as e: + return [TextContent(type="text", text=f"โŒ Error: {str(e)}")] + + +async def remove_marketplace_tool(args: dict) -> list[TextContent]: + """Remove a registered plugin marketplace.""" + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + name = args.get("name") + + try: + if not name: + return [TextContent(type="text", text="โŒ Error: 'name' parameter is required")] + + manager = MarketplaceManager() + removed = manager.remove_marketplace(name) + + if removed: + result = f"""โœ… Marketplace removed successfully! + +๐Ÿ“› Removed: {name} + +โš ๏ธ Note: Cached repository data is NOT deleted +To free disk space, manually delete: ~/.skill-seekers/cache/marketplace_{name}/ +""" + return [TextContent(type="text", text=result)] + else: + sources = manager.list_marketplaces() + available = [m["name"] for m in sources] + result = f"""โŒ Marketplace '{name}' not found + +Available marketplaces: {", ".join(available) if available else "none"} +""" + return [TextContent(type="text", text=result)] + + except Exception as e: + return [TextContent(type="text", text=f"โŒ Error: {str(e)}")] + + +async def publish_to_marketplace_tool(args: dict) -> list[TextContent]: + """Publish a packaged skill to a plugin marketplace repository.""" + from skill_seekers.mcp.marketplace_publisher import MarketplacePublisher + + skill_dir = args.get("skill_dir") + marketplace = args.get("marketplace") + category = args.get("category", "development") + skill_name = args.get("skill_name") + description = args.get("description") + create_branch = args.get("create_branch", False) + force = args.get("force", False) + + try: + if not skill_dir: + return [TextContent(type="text", text="โŒ Error: 'skill_dir' parameter is required")] + if not marketplace: + return [TextContent(type="text", text="โŒ Error: 'marketplace' parameter is required")] + + publisher = MarketplacePublisher() + result = publisher.publish( + skill_dir=skill_dir, + marketplace_name=marketplace, + category=category, + skill_name=skill_name, + description=description, + create_branch=create_branch, + force=force, + ) + + if result["success"]: + output = f"""โœ… Skill published to marketplace successfully! + +๐Ÿ“ฆ Plugin: {result["plugin_path"]} +๐Ÿช Marketplace: {marketplace} +๐Ÿท๏ธ Category: {category} +๐ŸŒฟ Branch: {result["branch"]} +๐Ÿ“ Commit: {result["commit_sha"]} + +{result["message"]} +""" + return [TextContent(type="text", text=output)] + else: + return [TextContent(type="text", text=f"โŒ Publish failed: {result['message']}")] + + except (FileNotFoundError, KeyError, ValueError, RuntimeError) as e: + return [TextContent(type="text", text=f"โŒ {str(e)}")] + except Exception as e: + return [TextContent(type="text", text=f"โŒ Error: {str(e)}")] diff --git a/src/skill_seekers/mcp/tools/packaging_tools.py b/src/skill_seekers/mcp/tools/packaging_tools.py index e16d8f2..6ab8356 100644 --- a/src/skill_seekers/mcp/tools/packaging_tools.py +++ b/src/skill_seekers/mcp/tools/packaging_tools.py @@ -113,8 +113,8 @@ async def package_skill_tool(args: dict) -> list[TextContent]: args: Dictionary with: - skill_dir (str): Path to skill directory (e.g., output/react/) - auto_upload (bool): Try to upload automatically if API key is available (default: True) - - target (str): Target platform (default: 'claude') - Options: 'claude', 'gemini', 'openai', 'markdown' + - target (str): Target platform (default: 'auto') + Options: 'auto', 'claude', 'gemini', 'openai', 'markdown' Returns: List of TextContent with packaging results @@ -123,7 +123,11 @@ async def package_skill_tool(args: dict) -> list[TextContent]: skill_dir = args["skill_dir"] auto_upload = args.get("auto_upload", True) - target = args.get("target", "claude") + target = args.get("target", "auto") + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() # Get platform adaptor try: @@ -232,8 +236,8 @@ async def upload_skill_tool(args: dict) -> list[TextContent]: Args: args: Dictionary with: - skill_zip (str): Path to skill package (.zip or .tar.gz) - - target (str): Target platform (default: 'claude') - Options: 'claude', 'gemini', 'openai' + - target (str): Target platform (default: 'auto') + Options: 'auto', 'claude', 'gemini', 'openai' Note: 'markdown' does not support upload - api_key (str, optional): API key (uses env var if not provided) @@ -243,7 +247,11 @@ async def upload_skill_tool(args: dict) -> list[TextContent]: from skill_seekers.cli.adaptors import get_adaptor skill_zip = args["skill_zip"] - target = args.get("target", "claude") + target = args.get("target", "auto") + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() api_key = args.get("api_key") # Get platform adaptor @@ -296,11 +304,11 @@ async def enhance_skill_tool(args: dict) -> list[TextContent]: Args: args: Dictionary with: - skill_dir (str): Path to skill directory - - target (str): Target platform (default: 'claude') - Options: 'claude', 'gemini', 'openai' + - target (str): Target platform (default: 'auto') + Options: 'auto', 'claude', 'gemini', 'openai' Note: 'markdown' does not support enhancement - mode (str): Enhancement mode (default: 'local') - 'local': Uses Claude Code Max (no API key) + 'local': Uses AI coding agent (no API key) 'api': Uses platform API (requires API key) - api_key (str, optional): API key for 'api' mode @@ -310,7 +318,11 @@ async def enhance_skill_tool(args: dict) -> list[TextContent]: from skill_seekers.cli.adaptors import get_adaptor skill_dir = Path(args.get("skill_dir")) - target = args.get("target", "claude") + target = args.get("target", "auto") + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() mode = args.get("mode", "local") api_key = args.get("api_key") @@ -348,8 +360,8 @@ async def enhance_skill_tool(args: dict) -> list[TextContent]: output_lines.append("") if mode == "local": - # Use local enhancement (Claude Code) - output_lines.append("Using Claude Code Max (local, no API key required)") + # Use local enhancement (AI coding agent) + output_lines.append("Using AI coding agent (local, no API key required)") output_lines.append("Running enhancement in headless mode...") output_lines.append("") @@ -437,7 +449,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: - auto_upload (bool): Upload after packaging (default: True) - unlimited (bool): Remove page limits (default: False) - dry_run (bool): Preview only (default: False) - - target (str): Target LLM platform (default: "claude") + - target (str): Target LLM platform (default: "auto") Returns: List of TextContent with workflow progress and results @@ -455,7 +467,14 @@ async def install_skill_tool(args: dict) -> list[TextContent]: auto_upload = args.get("auto_upload", True) unlimited = args.get("unlimited", False) dry_run = args.get("dry_run", False) - target = args.get("target", "claude") + target = args.get("target", "auto") + marketplace_arg = args.get("marketplace") + marketplace_category = args.get("marketplace_category", "development") + create_branch = args.get("create_branch", False) + if target == "auto": + from skill_seekers.cli.agent_client import AgentClient + + target = AgentClient.detect_default_target() # Get platform adaptor try: @@ -559,6 +578,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: with open(workflow_state["config_path"]) as f: config = json.load(f) workflow_state["skill_name"] = config.get("name", "unknown") + workflow_state["config_data"] = config except Exception as e: return [ TextContent( @@ -637,7 +657,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: 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(" [DRY RUN] Would enhance SKILL.md with AI agent") output_lines.append("") @@ -702,6 +722,7 @@ async def install_skill_tool(args: dict) -> list[TextContent]: output_lines.append("") # ===== PHASE 5: Upload (Optional) ===== + has_api_key = False # Initialize before conditional block if auto_upload: phase_num = "5/5" if config_name else "4/4" output_lines.append(f"๐Ÿ“ค PHASE {phase_num}: Upload to {adaptor.PLATFORM_NAME}") @@ -767,6 +788,62 @@ async def install_skill_tool(args: dict) -> list[TextContent]: output_lines.append("") + # ===== PHASE 6: Publish to Marketplace (Optional) ===== + marketplace_targets = [] + if marketplace_arg: + marketplace_targets.append( + {"marketplace": marketplace_arg, "category": marketplace_category} + ) + else: + cd = workflow_state.get("config_data", {}) + if isinstance(cd, dict): + marketplace_targets = cd.get("marketplace_targets", []) + + if marketplace_targets: + phase_num = len(workflow_state["phases_completed"]) + 1 + output_lines.append(f"{'=' * 70}") + output_lines.append( + f"PHASE {phase_num}: Publish to Marketplace" + f" ({len(marketplace_targets)} target{'s' if len(marketplace_targets) > 1 else ''})" + ) + output_lines.append(f"{'=' * 70}") + output_lines.append("") + + if not dry_run: + from .marketplace_tools import publish_to_marketplace_tool + + for mp_target in marketplace_targets: + mp_name = mp_target.get("marketplace", "") + mp_cat = mp_target.get("category", "development") + output_lines.append(f"Publishing to marketplace '{mp_name}'...") + + try: + pub_result = await publish_to_marketplace_tool( + { + "skill_dir": workflow_state["skill_dir"], + "marketplace": mp_name, + "category": mp_cat, + "create_branch": create_branch, + "force": True, + } + ) + pub_output = pub_result[0].text if pub_result else "No output" + output_lines.append(pub_output) + workflow_state["phases_completed"].append( + f"publish_to_marketplace({mp_name})" + ) + except Exception as e: + output_lines.append(f"Failed to publish to '{mp_name}': {str(e)}") + output_lines.append("") + else: + for mp_target in marketplace_targets: + mp_name = mp_target.get("marketplace", "") + mp_cat = mp_target.get("category", "development") + output_lines.append( + f" [DRY RUN] Would publish to marketplace '{mp_name}' (category: {mp_cat})" + ) + output_lines.append("") + # ===== WORKFLOW SUMMARY ===== output_lines.append("=" * 70) output_lines.append("โœ… WORKFLOW COMPLETE") diff --git a/src/skill_seekers/mcp/tools/source_tools.py b/src/skill_seekers/mcp/tools/source_tools.py index 9ba3457..f45cb03 100644 --- a/src/skill_seekers/mcp/tools/source_tools.py +++ b/src/skill_seekers/mcp/tools/source_tools.py @@ -826,3 +826,63 @@ To see all sources: except Exception as e: return [TextContent(type="text", text=f"โŒ Error: {str(e)}")] + + +async def push_config_tool(args: dict) -> list[TextContent]: + """ + Push a config to a registered config source repository. + + Validates the config, places it in the correct category directory, + commits, and pushes to the source repo. + + Args: + args: Dictionary containing: + - config_path: Path to config JSON file (required) + - source_name: Registered source name, e.g., "spyke" (required) + - category: Category directory (e.g., "game-engines"). Auto-detected if omitted. + - create_branch: Create feature branch instead of pushing to main (default: false) + - force: Overwrite existing config (default: false) + + Returns: + List of TextContent with push results + """ + config_path = args.get("config_path") + source_name = args.get("source_name") + category = args.get("category", "auto") + create_branch = args.get("create_branch", False) + force = args.get("force", False) + + if not config_path: + return [TextContent(type="text", text="โŒ Missing required parameter: config_path")] + if not source_name: + return [TextContent(type="text", text="โŒ Missing required parameter: source_name")] + + try: + from skill_seekers.mcp.config_publisher import ConfigPublisher + + publisher = ConfigPublisher() + result = publisher.publish( + config_path=config_path, + source_name=source_name, + category=category, + create_branch=create_branch, + force=force, + ) + + output = f"""โœ… Config pushed successfully! + +๐Ÿ“„ Config: {result["config_name"]} +๐Ÿ“‚ Path: {result["config_path"]} +๐Ÿท๏ธ Category: {result["category"]} +๐Ÿ“ฆ Source: {result["source"]} +๐Ÿ”€ Branch: {result["branch"]} +๐Ÿ“ Commit: {result["commit_sha"]} +๐Ÿ’ฌ Message: {result["message"]} + +To fetch this config: + fetch_config(source="{result["source"]}", config_name="{result["config_name"]}") +""" + return [TextContent(type="text", text=output)] + + except Exception as e: + return [TextContent(type="text", text=f"โŒ Push failed: {str(e)}")] diff --git a/src/skill_seekers/workflows/ai-assistant-internals.yaml b/src/skill_seekers/workflows/ai-assistant-internals.yaml new file mode 100644 index 0000000..d39cd5f --- /dev/null +++ b/src/skill_seekers/workflows/ai-assistant-internals.yaml @@ -0,0 +1,131 @@ +name: ai-assistant-internals +description: Deep analysis of AI assistant CLI internals including tool registry, context pipeline, MCP integration, sub-agent coordination, and cost optimization +version: "1.0" +applies_to: + - codebase_analysis + - github_analysis +variables: + depth: comprehensive +stages: + - name: tool_registry_analysis + type: custom + target: tools + uses_history: false + enabled: true + prompt: > + Analyze the tool registry and dispatch system in this AI assistant codebase. + + For each tool/agent capability: + 1. Tool name and purpose + 2. Input schema (parameters, types, required vs optional) + 3. Output schema (return types, error conditions) + 4. Dispatch mechanism (how the LLM selects and invokes it) + 5. Related tools that are commonly chained together + + Output JSON with: + - "tools": array of {name, purpose, input_schema, output_schema, dispatch_mechanism, related_tools[]} + - "tool_categories": grouping by function (file_ops, web_search, agent_mgmt, shell_exec, etc.) + - "dispatch_pattern": how tool calls are parsed, validated, and routed + + - name: context_pipeline + type: custom + target: context + uses_history: true + enabled: true + prompt: > + Document how user queries, system context, conversation history, and file context are assembled into LLM prompts. + + Cover: + 1. System prompt construction (static vs dynamic parts) + 2. User context collection (current directory, git state, open files) + 3. Conversation history management (truncation, summarization, checkpointing) + 4. File context injection (how code is read and formatted for the model) + 5. Context window optimization strategies + + Output JSON with: + - "system_prompt_structure": sections and their purposes + - "context_sources": array of {source, description, priority, fallback_behavior} + - "history_management": truncation/summary strategy + - "file_context_format": how files are represented in prompts + - "optimization_strategies": techniques to stay within token limits + + - name: mcp_patterns + type: custom + target: mcp + uses_history: true + enabled: true + prompt: > + Extract Model Context Protocol (MCP) integration patterns from this codebase. + + Document: + 1. MCP client initialization and configuration + 2. Server discovery and connection lifecycle + 3. Tool/resource exposure via MCP + 4. Authentication and security boundaries + 5. Error handling for MCP server failures + + Output JSON with: + - "mcp_client": initialization and config patterns + - "server_management": discovery, connect, disconnect, health checks + - "exposed_capabilities": tools/resources surfaced to MCP + - "security_model": auth, sandboxing, permission boundaries + - "failure_modes": retry, fallback, and user notification strategies + + - name: agent_coordination + type: custom + target: agents + uses_history: true + enabled: true + prompt: > + Analyze sub-agent creation, task delegation, and multi-agent coordination patterns. + + Identify: + 1. Agent lifecycle (create, run, pause, resume, terminate) + 2. Task delegation strategies (how work is split across agents) + 3. Inter-agent communication (message passing, shared state) + 4. Result aggregation and merging from multiple agents + 5. Parent-child relationship management and oversight + + Output JSON with: + - "agent_lifecycle": states and transitions + - "delegation_patterns": strategies for splitting work + - "communication_model": how agents share data + - "result_aggregation": merging outputs from parallel agents + - "oversight_mechanisms": how parent agents monitor children + + - name: cost_optimization + type: custom + target: cost + uses_history: true + enabled: true + prompt: > + Review token usage tracking, cost estimation, and optimization strategies. + + Document: + 1. How input/output tokens are measured per request + 2. Cost estimation methodology (pricing models, caching) + 3. Optimization techniques (prompt compression, selective context, model tiering) + 4. Budget controls and user-facing cost feedback + 5. Trade-offs between cost and response quality + + Output JSON with: + - "token_measurement": how tokens are counted and attributed + - "cost_estimation": pricing logic and display + - "optimization_techniques": array of {name, description, impact, implementation_location} + - "budget_controls": limits, warnings, and enforcement + - "quality_tradeoffs": when and how cost is prioritized over quality + +post_process: + reorder_sections: + - overview + - tool_registry + - context_pipeline + - mcp_integration + - agent_coordination + - cost_optimization + - examples + - advanced_topics + add_metadata: + enhanced: true + workflow: ai-assistant-internals + deep_analysis: true diff --git a/src/skill_seekers/workflows/unity-game-dev.yaml b/src/skill_seekers/workflows/unity-game-dev.yaml new file mode 100644 index 0000000..6e23a6c --- /dev/null +++ b/src/skill_seekers/workflows/unity-game-dev.yaml @@ -0,0 +1,214 @@ +name: unity-game-dev +description: Unity game development patterns, MonoBehaviour lifecycle, component architecture, memory management, and C# best practices for Unity projects +version: "1.0" +applies_to: + - codebase_analysis + - github_analysis + - documentation +variables: + depth: comprehensive + +stages: + - name: monobehaviour_lifecycle + type: custom + target: lifecycle + uses_history: false + enabled: true + prompt: > + Document all MonoBehaviour lifecycle patterns used in this codebase/library. + + Cover: + 1. Initialization order (Awake vs Start vs OnEnable) + 2. Update loops (Update vs FixedUpdate vs LateUpdate) and when to use each + 3. Destruction and cleanup (OnDestroy, OnDisable, OnApplicationQuit) + 4. Coroutine lifecycle and cancellation patterns + 5. Script execution order dependencies + + For each pattern found: + - When to use it vs alternatives + - Common mistakes and gotchas + - Code example showing correct usage + + Output JSON with "lifecycle_patterns" array of: + {name, phase, use_case, gotchas[], code_example, execution_order} + + - name: component_architecture + type: custom + target: components + uses_history: true + enabled: true + prompt: > + Document the component-based architecture patterns. + + Cover: + 1. Component composition patterns (prefer composition over inheritance) + 2. GetComponent usage and caching strategies + 3. RequireComponent and dependency management + 4. Prefab architecture and nested prefab patterns + 5. ScriptableObject as data containers and event channels + 6. Inspector serialization ([SerializeField], [Header], [Tooltip], custom editors) + + For each pattern: + - Setup steps in Unity Editor + - C# implementation + - When to use vs alternatives + + Output JSON with: + - "component_patterns": array of architecture patterns + - "scriptable_objects": data-driven design patterns + - "serialization": Inspector setup patterns + + - name: dependency_injection + type: custom + target: di_patterns + uses_history: true + enabled: true + prompt: > + Document dependency injection patterns for Unity. + + Cover: + 1. Zenject/Extenject bindings (BindInterfacesAndSelfTo, FromComponentInHierarchy, AsSingle, AsCached) + 2. VContainer registration patterns (if used) + 3. Installer organization (MonoInstaller, ScriptableObjectInstaller, ProjectInstaller) + 4. Factory patterns with DI (PlaceholderFactory, IFactory) + 5. Signal/event bus patterns via DI + 6. Scene-scoped vs project-scoped containers + + For each pattern: + - Binding configuration + - Injection usage ([Inject], constructor injection) + - Lifecycle management + - Testing with mock bindings + + Output JSON with: + - "di_bindings": binding patterns and when to use each + - "installers": installer organization + - "factories": factory patterns + - "testing": how to mock dependencies + + - name: async_patterns + type: custom + target: async + uses_history: true + enabled: true + prompt: > + Document async/concurrent programming patterns for Unity. + + Cover: + 1. Coroutines (IEnumerator, yield return, StartCoroutine/StopCoroutine) + 2. UniTask patterns (async/await, UniTask vs Task, cancellation tokens) + 3. Async asset loading (Addressables, Resources.LoadAsync) + 4. Threading considerations (main thread requirement, UnitySynchronizationContext) + 5. DOTween async integration (AwaitForCompletion, ToUniTask) + 6. Progress reporting and loading screens + + For each pattern: + - When to use it vs alternatives + - Cancellation and cleanup + - Error handling + - Performance implications + + Output JSON with: + - "coroutine_patterns": coroutine usage and gotchas + - "unitask_patterns": modern async patterns + - "loading_patterns": async loading strategies + - "thread_safety": main thread considerations + + - name: memory_and_performance + type: custom + target: unity_performance + uses_history: true + enabled: true + prompt: > + Document Unity-specific memory management and performance patterns. + + Cover: + 1. Object pooling (instantiate/destroy avoidance, pool managers) + 2. GC pressure reduction (avoid allocations in Update, struct vs class, NativeArray) + 3. Asset memory lifecycle (loading, reference counting, unloading, Resources.UnloadUnusedAssets) + 4. Texture/mesh memory optimization (atlasing, LOD, compression) + 5. Draw call batching (static/dynamic batching, SRP Batcher, GPU instancing) + 6. Profiler-guided optimization (Unity Profiler, Memory Profiler, Frame Debugger) + + For each optimization: + - Problem it solves + - Implementation approach + - Measurable impact + - Common mistakes + + Output JSON with: + - "pooling": object pool patterns + - "gc_optimization": allocation reduction techniques + - "asset_memory": asset lifecycle management + - "rendering": draw call and GPU optimizations + - "profiling": how to measure and verify + + - name: unity_events + type: custom + target: events + uses_history: true + enabled: true + prompt: > + Document event and messaging patterns for Unity. + + Cover: + 1. C# events and delegates (event Action, EventHandler) + 2. UnityEvent and Inspector-wired callbacks + 3. ScriptableObject event channels (SOA event pattern) + 4. Signal bus patterns (Zenject Signals, custom implementations) + 5. Observer pattern implementations + 6. Message broker / pub-sub patterns + + For each pattern: + - When to use it (tight coupling vs loose coupling trade-offs) + - Memory leak prevention (unsubscribe in OnDestroy/OnDisable) + - Thread safety considerations + - Code example with subscribe/unsubscribe + + Output JSON with: + - "event_patterns": array of event/messaging approaches + - "best_practices": subscription lifecycle management + - "comparisons": when to use which pattern + + - name: editor_integration + type: custom + target: editor + uses_history: true + enabled: true + prompt: > + Document Unity Editor integration patterns. + + Cover: + 1. Custom Inspector/Editor scripts ([CustomEditor], EditorGUILayout) + 2. PropertyDrawer and DecoratorDrawer patterns + 3. EditorWindow for tool development + 4. Gizmos and Handles for scene visualization + 5. Menu items and context menus ([MenuItem], [ContextMenu]) + 6. Build pipeline hooks (IPreprocessBuildWithReport, IPostprocessBuildWithReport) + + For each pattern: + - Use case and setup + - Code example + - Editor vs runtime separation (#if UNITY_EDITOR) + + Output JSON with: + - "custom_inspectors": inspector customization patterns + - "editor_tools": tool development patterns + - "build_pipeline": build automation patterns + +post_process: + reorder_sections: + - monobehaviour_lifecycle + - component_architecture + - dependency_injection + - async_patterns + - memory_and_performance + - unity_events + - editor_integration + add_metadata: + enhanced: true + workflow: unity-game-dev + framework: unity + has_lifecycle_docs: true + has_di_patterns: true + has_performance_guide: true diff --git a/tests/test_agent_client.py b/tests/test_agent_client.py new file mode 100644 index 0000000..3f79c89 --- /dev/null +++ b/tests/test_agent_client.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Tests for the AgentClient unified AI client.""" + +import os +import subprocess +from unittest.mock import MagicMock, patch + + +from skill_seekers.cli.agent_client import ( + DEFAULT_ENHANCE_TIMEOUT, + DEFAULT_MODELS, + UNLIMITED_TIMEOUT, + AgentClient, + get_default_timeout, + normalize_agent_name, +) + + +class TestNormalizeAgentName: + """Test normalize_agent_name() alias resolution.""" + + def test_claude_aliases(self): + assert normalize_agent_name("claude-code") == "claude" + assert normalize_agent_name("claude_code") == "claude" + assert normalize_agent_name("claude") == "claude" + + def test_kimi_aliases(self): + assert normalize_agent_name("kimi") == "kimi" + assert normalize_agent_name("kimi-cli") == "kimi" + assert normalize_agent_name("kimi_code") == "kimi" + assert normalize_agent_name("kimi-code") == "kimi" + + def test_codex_aliases(self): + assert normalize_agent_name("codex") == "codex" + assert normalize_agent_name("codex-cli") == "codex" + + def test_copilot_aliases(self): + assert normalize_agent_name("copilot") == "copilot" + assert normalize_agent_name("copilot-cli") == "copilot" + + def test_opencode_aliases(self): + assert normalize_agent_name("opencode") == "opencode" + assert normalize_agent_name("open-code") == "opencode" + assert normalize_agent_name("open_code") == "opencode" + + def test_custom_passthrough(self): + assert normalize_agent_name("custom") == "custom" + + def test_unknown_name_passthrough(self): + assert normalize_agent_name("some-unknown-agent") == "some-unknown-agent" + + def test_empty_string_defaults_to_claude(self): + assert normalize_agent_name("") == "claude" + + def test_none_defaults_to_claude(self): + # The docstring says "if not agent_name" which covers None too, + # but the type hint says str. If called with empty string, it returns "claude". + assert normalize_agent_name("") == "claude" + + def test_case_insensitive(self): + assert normalize_agent_name("Claude-Code") == "claude" + assert normalize_agent_name("KIMI-CLI") == "kimi" + assert normalize_agent_name("Codex") == "codex" + + def test_whitespace_stripped(self): + assert normalize_agent_name(" claude ") == "claude" + assert normalize_agent_name(" kimi-cli ") == "kimi" + + +class TestDetectApiKey: + """Test AgentClient.detect_api_key() static method.""" + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test123"}, clear=True) + def test_detects_anthropic_key(self): + key, provider = AgentClient.detect_api_key() + assert key == "sk-ant-test123" + assert provider == "anthropic" + + @patch.dict(os.environ, {"MOONSHOT_API_KEY": "moonshot-key-abc"}, clear=True) + def test_detects_moonshot_key(self): + key, provider = AgentClient.detect_api_key() + assert key == "moonshot-key-abc" + assert provider == "moonshot" + + @patch.dict(os.environ, {"GOOGLE_API_KEY": "AIzaSyTest123"}, clear=True) + def test_detects_google_key(self): + key, provider = AgentClient.detect_api_key() + assert key == "AIzaSyTest123" + assert provider == "google" + + @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-openai"}, clear=True) + def test_detects_openai_key(self): + key, provider = AgentClient.detect_api_key() + assert key == "sk-test-openai" + assert provider == "openai" + + @patch.dict(os.environ, {"ANTHROPIC_AUTH_TOKEN": "sk-ant-auth"}, clear=True) + def test_detects_anthropic_auth_token(self): + key, provider = AgentClient.detect_api_key() + assert key == "sk-ant-auth" + assert provider == "anthropic" + + @patch.dict(os.environ, {}, clear=True) + def test_no_key_returns_none(self): + key, provider = AgentClient.detect_api_key() + assert key is None + assert provider is None + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": " "}, clear=True) + def test_whitespace_only_key_returns_none(self): + key, provider = AgentClient.detect_api_key() + assert key is None + assert provider is None + + @patch.dict( + os.environ, + {"ANTHROPIC_API_KEY": "first-key", "OPENAI_API_KEY": "second-key"}, + clear=True, + ) + def test_priority_order_anthropic_first(self): + """API_KEY_MAP is iterated in order; ANTHROPIC_API_KEY comes first.""" + key, provider = AgentClient.detect_api_key() + assert key == "first-key" + assert provider == "anthropic" + + +class TestAgentClientInit: + """Test AgentClient.__init__() mode auto-detection.""" + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=True) + @patch.object(AgentClient, "_init_api_client", return_value=MagicMock()) + def test_auto_mode_with_api_key_sets_api(self, mock_init): + client = AgentClient(mode="auto") + assert client.mode == "api" + assert client.api_key == "sk-ant-test" + + @patch.dict(os.environ, {}, clear=True) + def test_auto_mode_without_api_key_sets_local(self): + client = AgentClient(mode="auto") + assert client.mode == "local" + assert client.api_key is None + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=True) + def test_explicit_local_mode_overrides_api_key(self): + client = AgentClient(mode="local") + assert client.mode == "local" + + @patch.dict(os.environ, {}, clear=True) + @patch.object(AgentClient, "_init_api_client", return_value=MagicMock()) + def test_explicit_api_mode_with_provided_key(self, mock_init): + client = AgentClient(mode="api", api_key="sk-ant-explicit") + assert client.mode == "api" + assert client.api_key == "sk-ant-explicit" + + @patch.dict(os.environ, {}, clear=True) + def test_default_agent_is_claude(self): + client = AgentClient(mode="local") + assert client.agent == "claude" + assert client.agent_display == "Claude Code" + + @patch.dict(os.environ, {"SKILL_SEEKER_AGENT": "kimi"}, clear=True) + def test_env_agent_override(self): + client = AgentClient(mode="local") + assert client.agent == "kimi" + + @patch.dict(os.environ, {"SKILL_SEEKER_AGENT": "kimi"}, clear=True) + def test_explicit_agent_overrides_env(self): + client = AgentClient(mode="local", agent="codex") + assert client.agent == "codex" + + @patch.dict(os.environ, {}, clear=True) + @patch.object(AgentClient, "_init_api_client", return_value=MagicMock()) + def test_explicit_api_key_detects_provider(self, mock_init): + client = AgentClient(mode="api", api_key="sk-ant-mykey") + assert client.provider == "anthropic" + + @patch.dict(os.environ, {}, clear=True) + @patch.object(AgentClient, "_init_api_client", return_value=MagicMock()) + def test_explicit_openai_key_detects_provider(self, mock_init): + client = AgentClient(mode="api", api_key="sk-openai-key") + assert client.provider == "openai" + + +class TestDetectProviderFromKey: + """Test AgentClient._detect_provider_from_key() static method.""" + + def test_anthropic_prefix(self): + assert AgentClient._detect_provider_from_key("sk-ant-abc123") == "anthropic" + + def test_openai_prefix(self): + assert AgentClient._detect_provider_from_key("sk-abc123") == "openai" + + def test_google_prefix(self): + assert AgentClient._detect_provider_from_key("AIzaSyTest") == "google" + + @patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moonshot-key"}, clear=True) + def test_moonshot_via_env_match(self): + result = AgentClient._detect_provider_from_key("sk-moonshot-key") + assert result == "moonshot" + + @patch.dict(os.environ, {}, clear=True) + def test_sk_prefix_without_moonshot_env_defaults_to_openai(self): + result = AgentClient._detect_provider_from_key("sk-some-key") + assert result == "openai" + + @patch.dict(os.environ, {}, clear=True) + def test_unknown_prefix_defaults_to_anthropic(self): + result = AgentClient._detect_provider_from_key("unknown-prefix-key") + assert result == "anthropic" + + @patch.dict(os.environ, {"GOOGLE_API_KEY": "custom-google-key"}, clear=True) + def test_env_var_match_for_unknown_prefix(self): + result = AgentClient._detect_provider_from_key("custom-google-key") + assert result == "google" + + +class TestGetDefaultTimeout: + """Test get_default_timeout() function.""" + + @patch.dict(os.environ, {}, clear=True) + def test_default_without_env(self): + assert get_default_timeout() == DEFAULT_ENHANCE_TIMEOUT + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "unlimited"}, clear=True) + def test_unlimited_string(self): + assert get_default_timeout() == UNLIMITED_TIMEOUT + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "none"}, clear=True) + def test_none_string(self): + assert get_default_timeout() == UNLIMITED_TIMEOUT + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "0"}, clear=True) + def test_zero_string(self): + assert get_default_timeout() == UNLIMITED_TIMEOUT + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "600"}, clear=True) + def test_valid_int_string(self): + assert get_default_timeout() == 600 + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "-5"}, clear=True) + def test_negative_value_returns_unlimited(self): + assert get_default_timeout() == UNLIMITED_TIMEOUT + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "not_a_number"}, clear=True) + def test_invalid_string_returns_default(self): + assert get_default_timeout() == DEFAULT_ENHANCE_TIMEOUT + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": " UNLIMITED "}, clear=True) + def test_unlimited_with_whitespace_and_case(self): + assert get_default_timeout() == UNLIMITED_TIMEOUT + + @patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": ""}, clear=True) + def test_empty_env_returns_default(self): + assert get_default_timeout() == DEFAULT_ENHANCE_TIMEOUT + + +class TestGetModel: + """Test AgentClient.get_model() static method.""" + + @patch.dict(os.environ, {}, clear=True) + def test_default_anthropic_model(self): + model = AgentClient.get_model("anthropic") + assert model == DEFAULT_MODELS["anthropic"] + + @patch.dict(os.environ, {}, clear=True) + def test_default_openai_model(self): + model = AgentClient.get_model("openai") + assert model == DEFAULT_MODELS["openai"] + + @patch.dict(os.environ, {}, clear=True) + def test_default_google_model(self): + model = AgentClient.get_model("google") + assert model == DEFAULT_MODELS["google"] + + @patch.dict(os.environ, {}, clear=True) + def test_default_moonshot_model(self): + model = AgentClient.get_model("moonshot") + assert model == DEFAULT_MODELS["moonshot"] + + @patch.dict(os.environ, {"SKILL_SEEKER_MODEL": "my-custom-model"}, clear=True) + def test_global_override(self): + model = AgentClient.get_model("anthropic") + assert model == "my-custom-model" + + @patch.dict(os.environ, {"ANTHROPIC_MODEL": "claude-opus-4-20250514"}, clear=True) + def test_provider_specific_env_var(self): + model = AgentClient.get_model("anthropic") + assert model == "claude-opus-4-20250514" + + @patch.dict( + os.environ, + {"SKILL_SEEKER_MODEL": "global-model", "ANTHROPIC_MODEL": "provider-model"}, + clear=True, + ) + def test_global_override_takes_precedence_over_provider(self): + model = AgentClient.get_model("anthropic") + assert model == "global-model" + + @patch.dict(os.environ, {}, clear=True) + def test_unknown_provider_falls_back_to_anthropic_default(self): + model = AgentClient.get_model("unknown-provider") + assert model == "claude-sonnet-4-20250514" + + @patch.dict(os.environ, {"OPENAI_MODEL": "gpt-5"}, clear=True) + def test_openai_model_env_var(self): + model = AgentClient.get_model("openai") + assert model == "gpt-5" + + @patch.dict(os.environ, {"GOOGLE_MODEL": "gemini-ultra"}, clear=True) + def test_google_model_env_var(self): + model = AgentClient.get_model("google") + assert model == "gemini-ultra" + + +class TestParseKimiOutput: + """Test AgentClient._parse_kimi_output() static method.""" + + def test_valid_textpart_output(self): + raw = ( + "TurnBegin(turn_id=1)\n" + "StepBegin(step_id=1)\n" + "TextPart(type='text', text='Hello world')\n" + "ThinkPart(type='think', think='...')\n" + "TextPart(type='text', text='Second line')\n" + ) + result = AgentClient._parse_kimi_output(raw) + assert result == "Hello world\nSecond line" + + def test_single_textpart(self): + raw = "TextPart(type='text', text='Only one part')\n" + result = AgentClient._parse_kimi_output(raw) + assert result == "Only one part" + + def test_no_textpart_falls_back_to_raw(self): + raw = "Some random output without TextPart markers" + result = AgentClient._parse_kimi_output(raw) + assert result == raw + + def test_empty_string_returns_empty(self): + result = AgentClient._parse_kimi_output("") + assert result == "" + + def test_thinkpart_only_falls_back(self): + raw = "ThinkPart(type='think', think='internal thinking')" + result = AgentClient._parse_kimi_output(raw) + assert result == raw + + +class TestIsAvailable: + """Test AgentClient.is_available() method.""" + + @patch.dict(os.environ, {}, clear=True) + def test_api_mode_with_client_is_available(self): + client = AgentClient(mode="local") + # Force to api mode with a client + client.mode = "api" + client.client = MagicMock() + assert client.is_available() is True + + @patch.dict(os.environ, {}, clear=True) + def test_api_mode_without_client_is_not_available(self): + client = AgentClient(mode="local") + client.mode = "api" + client.client = None + assert client.is_available() is False + + @patch.dict(os.environ, {}, clear=True) + @patch("subprocess.run") + def test_local_mode_claude_available(self, mock_run): + mock_run.return_value = MagicMock(returncode=0) + client = AgentClient(mode="local", agent="claude") + assert client.is_available() is True + mock_run.assert_called_once() + + @patch.dict(os.environ, {}, clear=True) + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_local_mode_cli_not_found(self, mock_run): + client = AgentClient(mode="local", agent="claude") + assert client.is_available() is False + + @patch.dict(os.environ, {}, clear=True) + @patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="claude", timeout=5)) + def test_local_mode_timeout(self, mock_run): + client = AgentClient(mode="local", agent="claude") + assert client.is_available() is False + + @patch.dict(os.environ, {}, clear=True) + def test_local_mode_unknown_agent_not_available(self): + client = AgentClient(mode="local") + client.agent = "nonexistent-agent" + assert client.is_available() is False + + @patch.dict(os.environ, {}, clear=True) + @patch("subprocess.run") + def test_local_mode_nonzero_returncode(self, mock_run): + mock_run.return_value = MagicMock(returncode=1) + client = AgentClient(mode="local", agent="codex") + assert client.is_available() is False + + +class TestDetectDefaultTarget: + """Test AgentClient.detect_default_target() static method.""" + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=True) + def test_anthropic_maps_to_claude(self): + assert AgentClient.detect_default_target() == "claude" + + @patch.dict(os.environ, {"MOONSHOT_API_KEY": "moon-key"}, clear=True) + def test_moonshot_maps_to_kimi(self): + assert AgentClient.detect_default_target() == "kimi" + + @patch.dict(os.environ, {"GOOGLE_API_KEY": "AIzaTest"}, clear=True) + def test_google_maps_to_gemini(self): + assert AgentClient.detect_default_target() == "gemini" + + @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}, clear=True) + def test_openai_maps_to_openai(self): + assert AgentClient.detect_default_target() == "openai" + + @patch.dict(os.environ, {}, clear=True) + def test_no_key_defaults_to_markdown(self): + assert AgentClient.detect_default_target() == "markdown" diff --git a/tests/test_config_publisher.py b/tests/test_config_publisher.py new file mode 100644 index 0000000..dca1464 --- /dev/null +++ b/tests/test_config_publisher.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Tests for ConfigPublisher class (config publishing to source repos).""" + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from skill_seekers.mcp.config_publisher import ConfigPublisher, detect_category + + +def _get_default_branch(repo_path): + """Get the default branch name of a git repo (master or main).""" + import git + + repo = git.Repo(repo_path) + return repo.active_branch.name + + +def _init_repo_with_main_branch(path): + """Initialize a git repo ensuring the branch is named 'main'.""" + import git + + repo = git.Repo.init(path) + repo.config_writer().set_value("user", "name", "Test").release() + repo.config_writer().set_value("user", "email", "test@test.com").release() + + # Create initial commit on whatever default branch + (path / "README.md").write_text("# Init\n") + repo.index.add(["README.md"]) + repo.index.commit("Initial commit") + + # Rename branch to 'main' if needed + if repo.active_branch.name != "main": + repo.git.branch("-m", repo.active_branch.name, "main") + + return repo + + +class TestDetectCategory: + """Test detect_category() keyword scoring.""" + + def test_game_engine_detected(self): + config = {"name": "godot-4", "description": "Godot game engine config"} + assert detect_category(config) == "game-engines" + + def test_web_framework_detected(self): + config = {"name": "react-config", "description": "React web framework setup"} + assert detect_category(config) == "web-frameworks" + + def test_ai_ml_detected(self): + config = {"name": "pytorch-training", "description": "PyTorch model training config"} + assert detect_category(config) == "ai-ml" + + def test_database_detected(self): + config = {"name": "postgres-setup", "description": "PostgreSQL database config"} + assert detect_category(config) == "databases" + + def test_devops_detected(self): + config = {"name": "docker-compose", "description": "Docker container orchestration"} + assert detect_category(config) == "devops" + + def test_cloud_detected(self): + config = {"name": "aws-deployment", "description": "AWS cloud deployment config"} + assert detect_category(config) == "cloud" + + def test_mobile_detected(self): + config = {"name": "flutter-app", "description": "Flutter mobile application config"} + assert detect_category(config) == "mobile" + + def test_testing_detected(self): + config = {"name": "pytest-setup", "description": "Pytest testing framework"} + assert detect_category(config) == "testing" + + def test_unknown_returns_custom(self): + config = {"name": "my-random-thing", "description": "Something unrelated"} + assert detect_category(config) == "custom" + + def test_empty_config_returns_custom(self): + config = {} + assert detect_category(config) == "custom" + + def test_name_only_matching(self): + config = {"name": "tailwind-theme"} + assert detect_category(config) == "css-frameworks" + + def test_description_only_matching(self): + config = {"name": "my-config", "description": "Uses kubernetes for orchestration"} + assert detect_category(config) == "devops" + + def test_highest_score_wins(self): + # "react" and "vue" both in web-frameworks, so web-frameworks should score higher + config = {"name": "react-vue-toolkit", "description": "React and Vue comparison"} + assert detect_category(config) == "web-frameworks" + + def test_security_detected(self): + config = {"name": "oauth-setup", "description": "OAuth and JWT authentication"} + assert detect_category(config) == "security" + + def test_messaging_detected(self): + config = {"name": "kafka-config", "description": "Apache Kafka messaging setup"} + assert detect_category(config) == "messaging" + + +class TestPublishErrors: + """Test ConfigPublisher.publish() error cases.""" + + def test_publish_missing_config_file(self, tmp_path): + publisher = ConfigPublisher.__new__(ConfigPublisher) + publisher.git_repo = MagicMock() + with pytest.raises(FileNotFoundError, match="Config file not found"): + publisher.publish( + config_path=tmp_path / "nonexistent.json", + source_name="test-source", + ) + + def test_publish_missing_name_field(self, tmp_path): + config_file = tmp_path / "bad_config.json" + config_file.write_text(json.dumps({"description": "No name field"})) + + publisher = ConfigPublisher.__new__(ConfigPublisher) + publisher.git_repo = MagicMock() + with pytest.raises(ValueError, match="must have a 'name' field"): + publisher.publish( + config_path=config_file, + source_name="test-source", + ) + + @patch.dict(os.environ, {}, clear=True) + def test_publish_missing_token(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"name": "test-config"})) + + # Create a mock source that returns proper data + mock_source = { + "name": "test-source", + "git_url": "https://github.com/test/repo.git", + "branch": "main", + "token_env": "NONEXISTENT_TOKEN", + } + mock_manager = MagicMock() + mock_manager.get_source.return_value = mock_source + mock_manager.list_sources.return_value = [mock_source] + + publisher = ConfigPublisher.__new__(ConfigPublisher) + publisher.git_repo = MagicMock() + + with ( + patch("skill_seekers.mcp.source_manager.SourceManager", return_value=mock_manager), + patch("skill_seekers.cli.config_validator.validate_config", return_value=None), + pytest.raises(RuntimeError, match="NONEXISTENT_TOKEN"), + ): + publisher.publish(config_path=config_file, source_name="test-source") + + def test_publish_source_not_found(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"name": "test-config"})) + + mock_manager = MagicMock() + mock_manager.get_source.return_value = None + mock_manager.list_sources.return_value = [] + + publisher = ConfigPublisher.__new__(ConfigPublisher) + publisher.git_repo = MagicMock() + + with ( + patch("skill_seekers.mcp.source_manager.SourceManager", return_value=mock_manager), + patch("skill_seekers.cli.config_validator.validate_config", return_value=None), + pytest.raises(ValueError, match="not found"), + ): + publisher.publish(config_path=config_file, source_name="nonexistent") + + def test_publish_duplicate_without_force(self, tmp_path): + """Config already exists in target repo and force=False should raise.""" + import git as gitmodule + + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"name": "existing-config"})) + + # Create a working repo with existing config + working_path = tmp_path / "working" + working_path.mkdir() + repo = _init_repo_with_main_branch(working_path) + + # Add existing config + config_dir_in_repo = working_path / "configs" / "custom" + config_dir_in_repo.mkdir(parents=True) + (config_dir_in_repo / "existing-config.json").write_text( + json.dumps({"name": "existing-config"}) + ) + repo.index.add(["configs/custom/existing-config.json"]) + repo.index.commit("Add existing config") + + bare_repo_path = tmp_path / "remote.git" + gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True) + + # Mock source manager + mock_source = { + "name": "test-source", + "git_url": f"file://{bare_repo_path}", + "branch": "main", + "token_env": "DUMMY_TOKEN", + } + mock_manager = MagicMock() + mock_manager.get_source.return_value = mock_source + + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + + publisher = ConfigPublisher.__new__(ConfigPublisher) + from skill_seekers.mcp.git_repo import GitConfigRepo + + publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir)) + + with ( + patch.dict(os.environ, {"DUMMY_TOKEN": "fake-token"}), + patch("skill_seekers.mcp.source_manager.SourceManager", return_value=mock_manager), + patch("skill_seekers.cli.config_validator.validate_config", return_value=None), + pytest.raises(ValueError, match="already exists"), + ): + publisher.publish( + config_path=config_file, + source_name="test-source", + category="custom", + force=False, + ) + + +class TestPublishSuccess: + """Test ConfigPublisher.publish() success path using a local bare git repo.""" + + def test_publish_happy_path(self, tmp_path): + """Full success path: clone -> copy -> commit -> push.""" + import git as gitmodule + + # Create config file to publish + config_file = tmp_path / "my-config.json" + config_data = {"name": "my-config", "description": "A test config for pytest"} + config_file.write_text(json.dumps(config_data)) + + # Create working repo with 'main' branch, then bare-clone as "remote" + working_path = tmp_path / "working" + working_path.mkdir() + _init_repo_with_main_branch(working_path) + + bare_repo_path = tmp_path / "remote.git" + gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True) + + # Mock source manager + mock_source = { + "name": "local-test", + "git_url": f"file://{bare_repo_path}", + "branch": "main", + "token_env": "DUMMY_TOKEN", + } + mock_manager = MagicMock() + mock_manager.get_source.return_value = mock_source + + # Create publisher with custom cache dir + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + publisher = ConfigPublisher.__new__(ConfigPublisher) + from skill_seekers.mcp.git_repo import GitConfigRepo + + publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir)) + + with ( + patch.dict(os.environ, {"DUMMY_TOKEN": "not-needed-for-file-protocol"}), + patch("skill_seekers.mcp.source_manager.SourceManager", return_value=mock_manager), + patch("skill_seekers.cli.config_validator.validate_config", return_value=None), + ): + result = publisher.publish( + config_path=config_file, + source_name="local-test", + category="testing", + ) + + # Verify result + assert result["success"] is True + assert result["config_name"] == "my-config" + assert result["config_path"] == "configs/testing/my-config.json" + assert result["source"] == "local-test" + assert result["category"] == "testing" + assert len(result["commit_sha"]) == 8 + assert result["branch"] == "main" + + # Verify the file exists in the cached clone + cached_repo = cache_dir / "source_local-test" + assert (cached_repo / "configs" / "testing" / "my-config.json").exists() + + # Verify the config content was preserved + with open(cached_repo / "configs" / "testing" / "my-config.json") as f: + saved = json.load(f) + assert saved["name"] == "my-config" + + def test_publish_force_overwrite(self, tmp_path): + """Test that force=True overwrites an existing config.""" + import git as gitmodule + + config_file = tmp_path / "overwrite-config.json" + config_data = {"name": "overwrite-config", "description": "Updated version"} + config_file.write_text(json.dumps(config_data)) + + # Create working repo with existing config + working_path = tmp_path / "working" + working_path.mkdir() + repo = _init_repo_with_main_branch(working_path) + + # Pre-populate with existing config + configs_dir = working_path / "configs" / "custom" + configs_dir.mkdir(parents=True) + (configs_dir / "overwrite-config.json").write_text( + json.dumps({"name": "overwrite-config", "description": "Old version"}) + ) + repo.index.add(["configs/custom/overwrite-config.json"]) + repo.index.commit("Add existing config") + + bare_repo_path = tmp_path / "remote.git" + gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True) + + mock_source = { + "name": "local-test", + "git_url": f"file://{bare_repo_path}", + "branch": "main", + "token_env": "DUMMY_TOKEN", + } + mock_manager = MagicMock() + mock_manager.get_source.return_value = mock_source + + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + publisher = ConfigPublisher.__new__(ConfigPublisher) + from skill_seekers.mcp.git_repo import GitConfigRepo + + publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir)) + + with ( + patch.dict(os.environ, {"DUMMY_TOKEN": "x"}), + patch("skill_seekers.mcp.source_manager.SourceManager", return_value=mock_manager), + patch("skill_seekers.cli.config_validator.validate_config", return_value=None), + ): + result = publisher.publish( + config_path=config_file, + source_name="local-test", + category="custom", + force=True, + ) + + assert result["success"] is True + assert result["config_name"] == "overwrite-config" + + # Verify the file has updated content + cached_repo = cache_dir / "source_local-test" + with open(cached_repo / "configs" / "custom" / "overwrite-config.json") as f: + saved = json.load(f) + assert saved["description"] == "Updated version" + + def test_publish_auto_detect_category(self, tmp_path): + """Test that category='auto' auto-detects from config content.""" + import git as gitmodule + + config_file = tmp_path / "react-config.json" + config_data = {"name": "react-config", "description": "React web framework config"} + config_file.write_text(json.dumps(config_data)) + + working_path = tmp_path / "working" + working_path.mkdir() + _init_repo_with_main_branch(working_path) + + bare_repo_path = tmp_path / "remote.git" + gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True) + + mock_source = { + "name": "local-test", + "git_url": f"file://{bare_repo_path}", + "branch": "main", + "token_env": "DUMMY_TOKEN", + } + mock_manager = MagicMock() + mock_manager.get_source.return_value = mock_source + + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + publisher = ConfigPublisher.__new__(ConfigPublisher) + from skill_seekers.mcp.git_repo import GitConfigRepo + + publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir)) + + with ( + patch.dict(os.environ, {"DUMMY_TOKEN": "x"}), + patch("skill_seekers.mcp.source_manager.SourceManager", return_value=mock_manager), + patch("skill_seekers.cli.config_validator.validate_config", return_value=None), + ): + result = publisher.publish( + config_path=config_file, + source_name="local-test", + category="auto", + ) + + assert result["success"] is True + assert result["category"] == "web-frameworks" diff --git a/tests/test_create_arguments.py b/tests/test_create_arguments.py index fde225f..6ea7ef6 100644 --- a/tests/test_create_arguments.py +++ b/tests/test_create_arguments.py @@ -25,8 +25,8 @@ class TestUniversalArguments: """Test universal argument definitions.""" def test_universal_count(self): - """Should have exactly 19 universal arguments (after Phase 2 workflow integration + local_repo_path + doc_version).""" - assert len(UNIVERSAL_ARGUMENTS) == 19 + """Should have exactly 21 universal arguments.""" + assert len(UNIVERSAL_ARGUMENTS) == 21 def test_universal_argument_names(self): """Universal arguments should have expected names.""" @@ -35,22 +35,23 @@ class TestUniversalArguments: "description", "output", "enhance_level", - "api_key", # Phase 1: consolidated from enhance + enhance_local + "api_key", "dry_run", "verbose", "quiet", "chunk_for_rag", "chunk_tokens", - "chunk_overlap_tokens", # Phase 2: RAG args from common.py + "chunk_overlap_tokens", "preset", "config", - # Phase 2: Workflow arguments (universal workflow support) "enhance_workflow", "enhance_stage", "var", "workflow_dry_run", - "local_repo_path", # GitHub local clone path for unlimited C3.x analysis - "doc_version", # Documentation version tag for RAG metadata + "local_repo_path", + "doc_version", + "agent", + "agent_cmd", } assert set(UNIVERSAL_ARGUMENTS.keys()) == expected_names @@ -132,7 +133,7 @@ class TestArgumentHelpers: names = get_universal_argument_names() assert isinstance(names, set) assert ( - len(names) == 19 + len(names) == 21 ) # Phase 2: added 4 workflow arguments + local_repo_path + doc_version assert "name" in names assert "enhance_level" in names # Phase 1: consolidated flag diff --git a/tests/test_create_integration_basic.py b/tests/test_create_integration_basic.py index 7308666..2678c5f 100644 --- a/tests/test_create_integration_basic.py +++ b/tests/test_create_integration_basic.py @@ -131,17 +131,18 @@ class TestCreateCommandBasic: class TestCreateCommandArgvForwarding: - """Unit tests for _add_common_args argv forwarding.""" + """Unit tests for _build_argv argument forwarding.""" def _make_args(self, **kwargs): import argparse defaults = { + "source": "https://example.com", "enhance_workflow": None, "enhance_stage": None, "var": None, "workflow_dry_run": False, - "enhance_level": 0, + "enhance_level": 2, "output": None, "name": None, "description": None, @@ -157,17 +158,20 @@ class TestCreateCommandArgvForwarding: "no_preserve_code_blocks": False, "no_preserve_paragraphs": False, "interactive_enhancement": False, + "agent": None, + "agent_cmd": None, + "doc_version": "", } defaults.update(kwargs) return argparse.Namespace(**defaults) def _collect_argv(self, args): from skill_seekers.cli.create_command import CreateCommand + from skill_seekers.cli.source_detector import SourceDetector cmd = CreateCommand(args) - argv = [] - cmd._add_common_args(argv) - return argv + cmd.source_info = SourceDetector.detect(args.source) + return cmd._build_argv("test_module", []) def test_single_enhance_workflow_forwarded(self): args = self._make_args(enhance_workflow=["security-focus"]) @@ -259,6 +263,197 @@ class TestCreateCommandArgvForwarding: assert "--var" in argv assert "--workflow-dry-run" in argv + # โ”€โ”€ _SKIP_ARGS exclusion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_source_never_forwarded(self): + """'source' is in _SKIP_ARGS and must never appear in argv.""" + args = self._make_args(source="https://example.com") + argv = self._collect_argv(args) + assert "--source" not in argv + + def test_func_never_forwarded(self): + """'func' is in _SKIP_ARGS and must never appear in argv.""" + args = self._make_args(func=lambda: None) + argv = self._collect_argv(args) + assert "--func" not in argv + + def test_config_never_forwarded_by_build_argv(self): + """'config' is in _SKIP_ARGS; forwarded manually by specific routes.""" + args = self._make_args(config="/path/to/config.json") + argv = self._collect_argv(args) + assert "--config" not in argv + + def test_subcommand_never_forwarded(self): + """'subcommand' is in _SKIP_ARGS.""" + args = self._make_args(subcommand="create") + argv = self._collect_argv(args) + assert "--subcommand" not in argv + + def test_command_never_forwarded(self): + """'command' is in _SKIP_ARGS.""" + args = self._make_args(command="create") + argv = self._collect_argv(args) + assert "--command" not in argv + + # โ”€โ”€ _DEST_TO_FLAG mapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_async_mode_maps_to_async_flag(self): + """async_mode dest should produce --async flag, not --async-mode.""" + args = self._make_args(async_mode=True) + argv = self._collect_argv(args) + assert "--async" in argv + assert "--async-mode" not in argv + + def test_skip_config_maps_to_skip_config_patterns(self): + """skip_config dest should produce --skip-config-patterns flag.""" + args = self._make_args(skip_config=True) + argv = self._collect_argv(args) + assert "--skip-config-patterns" in argv + assert "--skip-config" not in argv + + # โ”€โ”€ Boolean arg forwarding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_boolean_true_appends_flag(self): + args = self._make_args(dry_run=True) + argv = self._collect_argv(args) + assert "--dry-run" in argv + + def test_boolean_false_does_not_append_flag(self): + args = self._make_args(dry_run=False) + argv = self._collect_argv(args) + assert "--dry-run" not in argv + + def test_verbose_true_forwarded(self): + args = self._make_args(verbose=True) + argv = self._collect_argv(args) + assert "--verbose" in argv + + def test_quiet_true_forwarded(self): + args = self._make_args(quiet=True) + argv = self._collect_argv(args) + assert "--quiet" in argv + + # โ”€โ”€ List arg forwarding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_list_arg_each_item_gets_separate_flag(self): + """Each list item gets its own --flag value pair.""" + args = self._make_args(enhance_workflow=["a", "b", "c"]) + argv = self._collect_argv(args) + assert argv.count("--enhance-workflow") == 3 + for item in ["a", "b", "c"]: + idx = argv.index(item) + assert argv[idx - 1] == "--enhance-workflow" + + # โ”€โ”€ _is_explicitly_set โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_is_explicitly_set_none_is_not_set(self): + """None values should NOT be considered explicitly set.""" + from skill_seekers.cli.create_command import CreateCommand + + args = self._make_args() + cmd = CreateCommand(args) + assert cmd._is_explicitly_set("name", None) is False + + def test_is_explicitly_set_bool_true_is_set(self): + from skill_seekers.cli.create_command import CreateCommand + + args = self._make_args() + cmd = CreateCommand(args) + assert cmd._is_explicitly_set("dry_run", True) is True + + def test_is_explicitly_set_bool_false_is_not_set(self): + from skill_seekers.cli.create_command import CreateCommand + + args = self._make_args() + cmd = CreateCommand(args) + assert cmd._is_explicitly_set("dry_run", False) is False + + def test_is_explicitly_set_default_doc_version_empty_not_set(self): + """doc_version defaults to '' which means not explicitly set.""" + from skill_seekers.cli.create_command import CreateCommand + + args = self._make_args() + cmd = CreateCommand(args) + assert cmd._is_explicitly_set("doc_version", "") is False + + def test_is_explicitly_set_nonempty_string_is_set(self): + from skill_seekers.cli.create_command import CreateCommand + + args = self._make_args() + cmd = CreateCommand(args) + assert cmd._is_explicitly_set("name", "my-skill") is True + + def test_is_explicitly_set_non_default_value_is_set(self): + """A value that differs from the known default IS explicitly set.""" + from skill_seekers.cli.create_command import CreateCommand + + args = self._make_args() + cmd = CreateCommand(args) + # max_issues default is 100; setting to 50 means explicitly set + assert cmd._is_explicitly_set("max_issues", 50) is True + # Setting to default value means NOT explicitly set + assert cmd._is_explicitly_set("max_issues", 100) is False + + # โ”€โ”€ Allowlist filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_allowlist_only_forwards_allowed_args(self): + """When allowlist is provided, only those args are forwarded.""" + from skill_seekers.cli.create_command import CreateCommand + from skill_seekers.cli.source_detector import SourceDetector + + args = self._make_args( + dry_run=True, + verbose=True, + name="test-skill", + ) + cmd = CreateCommand(args) + cmd.source_info = SourceDetector.detect(args.source) + + # Only allow dry_run in the allowlist + allowlist = frozenset({"dry_run"}) + argv = cmd._build_argv("test_module", [], allowlist=allowlist) + + assert "--dry-run" in argv + assert "--verbose" not in argv + assert "--name" not in argv + + def test_allowlist_skips_non_allowed_even_if_set(self): + """Args not in the allowlist are excluded even if explicitly set.""" + from skill_seekers.cli.create_command import CreateCommand + from skill_seekers.cli.source_detector import SourceDetector + + args = self._make_args( + enhance_workflow=["security-focus"], + quiet=True, + ) + cmd = CreateCommand(args) + cmd.source_info = SourceDetector.detect(args.source) + + allowlist = frozenset({"quiet"}) + argv = cmd._build_argv("test_module", [], allowlist=allowlist) + + assert "--quiet" in argv + assert "--enhance-workflow" not in argv + + def test_allowlist_empty_forwards_nothing(self): + """Empty allowlist should forward no user args (auto-name may still be added).""" + from skill_seekers.cli.create_command import CreateCommand + from skill_seekers.cli.source_detector import SourceDetector + + args = self._make_args(dry_run=True, verbose=True) + cmd = CreateCommand(args) + cmd.source_info = SourceDetector.detect(args.source) + + allowlist = frozenset() + argv = cmd._build_argv("test_module", ["pos"], allowlist=allowlist) + + # User-set args (dry_run, verbose) should NOT be forwarded + assert "--dry-run" not in argv + assert "--verbose" not in argv + # Only module name, positional, and possibly auto-added --name + assert argv[0] == "test_module" + assert "pos" in argv + class TestBackwardCompatibility: """Test that old commands still work.""" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index e8ac14b..8d50456 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -88,6 +88,7 @@ class TestCheckApiKeys: "GITHUB_TOKEN": "ghp_test123456789", "GOOGLE_API_KEY": "AIza_test123456789", "OPENAI_API_KEY": "sk-test123456789", + "MOONSHOT_API_KEY": "sk-moon-test123456789", } with patch.dict(os.environ, env, clear=True): result = check_api_keys() diff --git a/tests/test_guide_enhancer.py b/tests/test_guide_enhancer.py index 2286cbd..6747ece 100644 --- a/tests/test_guide_enhancer.py +++ b/tests/test_guide_enhancer.py @@ -11,7 +11,7 @@ Tests dual-mode AI enhancement for how-to guides: import json import os -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest @@ -91,7 +91,7 @@ class TestGuideEnhancerStepDescriptions: result = enhancer.enhance_step_descriptions(steps) assert result == [] - @patch.object(GuideEnhancer, "_call_claude_api") + @patch.object(GuideEnhancer, "_call_ai") def test_enhance_step_descriptions_api_mode(self, mock_call): """Test step descriptions with API mode""" mock_call.return_value = json.dumps( @@ -156,7 +156,7 @@ class TestGuideEnhancerTroubleshooting: result = enhancer.enhance_troubleshooting(guide_data) assert result == [] - @patch.object(GuideEnhancer, "_call_claude_api") + @patch.object(GuideEnhancer, "_call_ai") def test_enhance_troubleshooting_api_mode(self, mock_call): """Test troubleshooting with API mode""" mock_call.return_value = json.dumps( @@ -215,7 +215,7 @@ class TestGuideEnhancerPrerequisites: result = enhancer.enhance_prerequisites(prereqs) assert result == [] - @patch.object(GuideEnhancer, "_call_claude_api") + @patch.object(GuideEnhancer, "_call_ai") def test_enhance_prerequisites_api_mode(self, mock_call): """Test prerequisites with API mode""" mock_call.return_value = json.dumps( @@ -267,7 +267,7 @@ class TestGuideEnhancerNextSteps: result = enhancer.enhance_next_steps(guide_data) assert result == [] - @patch.object(GuideEnhancer, "_call_claude_api") + @patch.object(GuideEnhancer, "_call_ai") def test_enhance_next_steps_api_mode(self, mock_call): """Test next steps with API mode""" mock_call.return_value = json.dumps( @@ -313,7 +313,7 @@ class TestGuideEnhancerUseCases: result = enhancer.enhance_use_cases(guide_data) assert result == [] - @patch.object(GuideEnhancer, "_call_claude_api") + @patch.object(GuideEnhancer, "_call_ai") def test_enhance_use_cases_api_mode(self, mock_call): """Test use cases with API mode""" mock_call.return_value = json.dumps( @@ -372,7 +372,7 @@ class TestGuideEnhancerFullWorkflow: assert result["title"] == guide_data["title"] assert len(result["steps"]) == 2 - @patch.object(GuideEnhancer, "_call_claude_api") + @patch.object(GuideEnhancer, "_call_ai") def test_enhance_guide_api_mode_success(self, mock_call): """Test successful full guide enhancement via API""" mock_call.return_value = json.dumps( @@ -467,43 +467,36 @@ class TestGuideEnhancerFullWorkflow: class TestGuideEnhancerLocalMode: """Test LOCAL mode (Claude Code CLI)""" - @patch("subprocess.run") - def test_call_claude_local_success(self, mock_run): - """Test successful LOCAL mode call""" - mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps( - { - "step_descriptions": [], - "troubleshooting": [], - "prerequisites_detailed": [], - "next_steps": [], - "use_cases": [], - } - ), + @patch.object(GuideEnhancer, "_call_ai") + def test_call_ai_local_success(self, mock_call_ai): + """Test successful LOCAL mode call via AgentClient""" + mock_call_ai.return_value = json.dumps( + { + "step_descriptions": [], + "troubleshooting": [], + "prerequisites_detailed": [], + "next_steps": [], + "use_cases": [], + } ) enhancer = GuideEnhancer(mode="local") - if enhancer.mode == "local": - prompt = "Test prompt" - result = enhancer._call_claude_local(prompt) + prompt = "Test prompt" + result = enhancer._call_ai(prompt) - assert result is not None - assert mock_run.called + assert result is not None + assert mock_call_ai.called - @patch("subprocess.run") - def test_call_claude_local_timeout(self, mock_run): - """Test LOCAL mode timeout handling""" - from subprocess import TimeoutExpired - - mock_run.side_effect = TimeoutExpired("claude", 300) + @patch.object(GuideEnhancer, "_call_ai") + def test_call_ai_local_timeout(self, mock_call_ai): + """Test LOCAL mode timeout handling via AgentClient""" + mock_call_ai.return_value = None enhancer = GuideEnhancer(mode="local") - if enhancer.mode == "local": - prompt = "Test prompt" - result = enhancer._call_claude_local(prompt) + prompt = "Test prompt" + result = enhancer._call_ai(prompt) - assert result is None + assert result is None class TestGuideEnhancerPromptGeneration: diff --git a/tests/test_install_skill.py b/tests/test_install_skill.py index 0eee67a..904bf71 100644 --- a/tests/test_install_skill.py +++ b/tests/test_install_skill.py @@ -70,12 +70,11 @@ class TestInstallSkillDryRun: 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 core phases are shown + assert "Fetch Config" in output + assert "Scrape Documentation" in output + assert "AI Enhancement (MANDATORY)" in output + assert "Package Skill" in output # Verify dry run indicators assert "[DRY RUN]" in output @@ -92,11 +91,10 @@ class TestInstallSkillDryRun: # 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 + # Verify core phases are shown (no fetch) + assert "Scrape Documentation" in output + assert "AI Enhancement (MANDATORY)" in output + assert "Package Skill" in output # Should not show fetch phase assert "PHASE 1/5" not in output @@ -243,18 +241,16 @@ class TestInstallSkillPhaseOrchestration: 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 have core phases (no fetch) + assert "Scrape Documentation" in output + assert "AI Enhancement" in output + assert "Package Skill" 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 + assert "Manual upload" in output @pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP package not installed") diff --git a/tests/test_install_skill_e2e.py b/tests/test_install_skill_e2e.py index 1fe5f9f..8b3b36a 100644 --- a/tests/test_install_skill_e2e.py +++ b/tests/test_install_skill_e2e.py @@ -228,7 +228,7 @@ class TestInstallSkillE2E: 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 + assert "PHASE 5/5: Upload to" in output # Verify fetch was called mock_fetch.assert_called_once() diff --git a/tests/test_marketplace_manager.py b/tests/test_marketplace_manager.py new file mode 100644 index 0000000..c9b31b1 --- /dev/null +++ b/tests/test_marketplace_manager.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Tests for MarketplaceManager class (marketplace registry management)""" + +import json +from pathlib import Path + +import pytest + +from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + +@pytest.fixture +def temp_config_dir(tmp_path): + config_dir = tmp_path / "test_config" + config_dir.mkdir() + return config_dir + + +@pytest.fixture +def manager(temp_config_dir): + return MarketplaceManager(config_dir=str(temp_config_dir)) + + +class TestMarketplaceManagerInit: + def test_init_creates_config_dir(self, tmp_path): + config_dir = tmp_path / "new_config" + mgr = MarketplaceManager(config_dir=str(config_dir)) + assert config_dir.exists() + assert mgr.config_dir == config_dir + + def test_init_creates_registry_file(self, temp_config_dir): + _mgr = MarketplaceManager(config_dir=str(temp_config_dir)) + registry_file = temp_config_dir / "marketplaces.json" + assert registry_file.exists() + with open(registry_file) as f: + data = json.load(f) + assert data == {"version": "1.0", "marketplaces": []} + + def test_init_preserves_existing_registry(self, temp_config_dir): + registry_file = temp_config_dir / "marketplaces.json" + existing_data = { + "version": "1.0", + "marketplaces": [{"name": "test", "git_url": "https://example.com/repo.git"}], + } + with open(registry_file, "w") as f: + json.dump(existing_data, f) + _mgr = MarketplaceManager(config_dir=str(temp_config_dir)) + with open(registry_file) as f: + data = json.load(f) + assert len(data["marketplaces"]) == 1 + + def test_init_with_default_config_dir(self): + mgr = MarketplaceManager() + assert mgr.config_dir == Path.home() / ".skill-seekers" + + +class TestAddMarketplace: + def test_add_marketplace_minimal(self, manager): + mp = manager.add_marketplace( + name="spyke", git_url="https://github.com/spykegames/plugins.git" + ) + assert mp["name"] == "spyke" + assert mp["git_url"] == "https://github.com/spykegames/plugins.git" + assert mp["token_env"] == "GITHUB_TOKEN" + assert mp["branch"] == "main" + assert mp["enabled"] is True + assert mp["author"] == {"name": "", "email": ""} + + def test_add_marketplace_full_parameters(self, manager): + author = {"name": "Spyke Team", "email": "team@spyke.com"} + mp = manager.add_marketplace( + name="spyke", + git_url="https://github.com/spykegames/plugins.git", + token_env="SPYKE_TOKEN", + branch="develop", + author=author, + enabled=False, + ) + assert mp["token_env"] == "SPYKE_TOKEN" + assert mp["branch"] == "develop" + assert mp["author"] == author + assert mp["enabled"] is False + + def test_add_marketplace_normalizes_name(self, manager): + mp = manager.add_marketplace(name="MyMarket", git_url="https://github.com/org/repo.git") + assert mp["name"] == "mymarket" + + def test_add_marketplace_invalid_name_empty(self, manager): + with pytest.raises(ValueError, match="Invalid marketplace name"): + manager.add_marketplace(name="", git_url="https://github.com/org/repo.git") + + def test_add_marketplace_invalid_name_special_chars(self, manager): + with pytest.raises(ValueError, match="Invalid marketplace name"): + manager.add_marketplace(name="my@market", git_url="https://github.com/org/repo.git") + + def test_add_marketplace_valid_name_with_hyphens(self, manager): + mp = manager.add_marketplace(name="my-market", git_url="https://github.com/org/repo.git") + assert mp["name"] == "my-market" + + def test_add_marketplace_empty_git_url(self, manager): + with pytest.raises(ValueError, match="git_url cannot be empty"): + manager.add_marketplace(name="spyke", git_url="") + + def test_add_marketplace_strips_git_url(self, manager): + mp = manager.add_marketplace(name="spyke", git_url=" https://github.com/org/repo.git ") + assert mp["git_url"] == "https://github.com/org/repo.git" + + def test_add_marketplace_updates_existing(self, manager): + mp1 = manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo1.git") + mp2 = manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo2.git") + assert mp2["git_url"] == "https://github.com/org/repo2.git" + assert mp2["added_at"] == mp1["added_at"] + assert len(manager.list_marketplaces()) == 1 + + def test_add_marketplace_persists_to_file(self, manager, temp_config_dir): + manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo.git") + registry_file = temp_config_dir / "marketplaces.json" + with open(registry_file) as f: + data = json.load(f) + assert len(data["marketplaces"]) == 1 + assert data["marketplaces"][0]["name"] == "spyke" + + +class TestGetMarketplace: + def test_get_marketplace_exact_match(self, manager): + manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo.git") + mp = manager.get_marketplace("spyke") + assert mp["name"] == "spyke" + + def test_get_marketplace_case_insensitive(self, manager): + manager.add_marketplace(name="Spyke", git_url="https://github.com/org/repo.git") + mp = manager.get_marketplace("spyke") + assert mp["name"] == "spyke" + + def test_get_marketplace_not_found(self, manager): + with pytest.raises(KeyError, match="Marketplace 'nonexistent' not found"): + manager.get_marketplace("nonexistent") + + def test_get_marketplace_not_found_shows_available(self, manager): + manager.add_marketplace(name="mp1", git_url="https://example.com/1.git") + manager.add_marketplace(name="mp2", git_url="https://example.com/2.git") + with pytest.raises(KeyError, match="Available marketplaces: mp1, mp2"): + manager.get_marketplace("mp3") + + def test_get_marketplace_empty_registry(self, manager): + with pytest.raises(KeyError, match="Available marketplaces: none"): + manager.get_marketplace("spyke") + + +class TestListMarketplaces: + def test_list_marketplaces_empty(self, manager): + assert manager.list_marketplaces() == [] + + def test_list_marketplaces_multiple(self, manager): + manager.add_marketplace(name="mp1", git_url="https://example.com/1.git") + manager.add_marketplace(name="mp2", git_url="https://example.com/2.git") + assert len(manager.list_marketplaces()) == 2 + + def test_list_marketplaces_enabled_only(self, manager): + manager.add_marketplace(name="enabled", git_url="https://example.com/1.git", enabled=True) + manager.add_marketplace(name="disabled", git_url="https://example.com/2.git", enabled=False) + marketplaces = manager.list_marketplaces(enabled_only=True) + assert len(marketplaces) == 1 + assert marketplaces[0]["name"] == "enabled" + + +class TestRemoveMarketplace: + def test_remove_marketplace_exists(self, manager): + manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo.git") + assert manager.remove_marketplace("spyke") is True + assert len(manager.list_marketplaces()) == 0 + + def test_remove_marketplace_not_found(self, manager): + assert manager.remove_marketplace("nonexistent") is False + + def test_remove_marketplace_persists_to_file(self, manager, temp_config_dir): + manager.add_marketplace(name="mp1", git_url="https://example.com/1.git") + manager.add_marketplace(name="mp2", git_url="https://example.com/2.git") + manager.remove_marketplace("mp1") + registry_file = temp_config_dir / "marketplaces.json" + with open(registry_file) as f: + data = json.load(f) + assert len(data["marketplaces"]) == 1 + assert data["marketplaces"][0]["name"] == "mp2" + + +class TestUpdateMarketplace: + def test_update_marketplace_git_url(self, manager): + manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo1.git") + updated = manager.update_marketplace( + name="spyke", git_url="https://github.com/org/repo2.git" + ) + assert updated["git_url"] == "https://github.com/org/repo2.git" + + def test_update_marketplace_author(self, manager): + manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo.git") + new_author = {"name": "New Author", "email": "new@example.com"} + updated = manager.update_marketplace(name="spyke", author=new_author) + assert updated["author"] == new_author + + def test_update_marketplace_updates_timestamp(self, manager): + mp = manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo.git") + updated = manager.update_marketplace(name="spyke", branch="develop") + assert updated["updated_at"] > mp["updated_at"] + + def test_update_marketplace_not_found(self, manager): + with pytest.raises(KeyError, match="Marketplace 'nonexistent' not found"): + manager.update_marketplace(name="nonexistent", branch="main") + + +class TestDefaultTokenEnv: + def test_github_url(self, manager): + mp = manager.add_marketplace(name="test", git_url="https://github.com/org/repo.git") + assert mp["token_env"] == "GITHUB_TOKEN" + + def test_gitlab_url(self, manager): + mp = manager.add_marketplace(name="test", git_url="https://gitlab.com/org/repo.git") + assert mp["token_env"] == "GITLAB_TOKEN" + + def test_bitbucket_url(self, manager): + mp = manager.add_marketplace(name="test", git_url="https://bitbucket.org/org/repo.git") + assert mp["token_env"] == "BITBUCKET_TOKEN" + + def test_unknown_url(self, manager): + mp = manager.add_marketplace( + name="test", git_url="https://custom-git.example.com/org/repo.git" + ) + assert mp["token_env"] == "GIT_TOKEN" + + def test_override_token_env(self, manager): + mp = manager.add_marketplace( + name="test", + git_url="https://github.com/org/repo.git", + token_env="MY_CUSTOM_TOKEN", + ) + assert mp["token_env"] == "MY_CUSTOM_TOKEN" + + +class TestRegistryPersistence: + def test_registry_atomic_write(self, manager, temp_config_dir): + manager.add_marketplace(name="spyke", git_url="https://github.com/org/repo.git") + assert len(list(temp_config_dir.glob("*.tmp"))) == 0 + + def test_registry_corrupted_file(self, temp_config_dir): + mgr = MarketplaceManager(config_dir=str(temp_config_dir)) + (temp_config_dir / "marketplaces.json").write_text("{ invalid json }") + with pytest.raises(ValueError, match="Corrupted registry file"): + mgr._read_registry() diff --git a/tests/test_marketplace_publisher.py b/tests/test_marketplace_publisher.py new file mode 100644 index 0000000..f7296fd --- /dev/null +++ b/tests/test_marketplace_publisher.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +"""Tests for MarketplacePublisher class (skill publishing to plugin repos)""" + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from skill_seekers.mcp.marketplace_publisher import MarketplacePublisher + + +@pytest.fixture +def temp_config_dir(tmp_path): + config_dir = tmp_path / "config" + config_dir.mkdir() + return config_dir + + +@pytest.fixture +def skill_dir(tmp_path): + sd = tmp_path / "test-skill" + sd.mkdir() + (sd / "SKILL.md").write_text( + "---\nname: test-skill\ndescription: A test skill for unit testing.\n---\n\n" + "# Test Skill\n\nThis is a test skill.\n" + ) + refs = sd / "references" / "documentation" + refs.mkdir(parents=True) + (refs / "index.md").write_text("# Documentation\n\nTest docs.\n") + return sd + + +@pytest.fixture +def skill_dir_no_frontmatter(tmp_path): + sd = tmp_path / "plain-skill" + sd.mkdir() + (sd / "SKILL.md").write_text("# Plain Skill\n\nNo frontmatter here.\n") + return sd + + +@pytest.fixture +def mock_marketplace_repo(tmp_path): + import git + + repo_path = tmp_path / "marketplace_repo" + repo_path.mkdir() + repo = git.Repo.init(repo_path) + mp_dir = repo_path / ".claude-plugin" + mp_dir.mkdir() + mp_json = { + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "test-marketplace", + "description": "Test marketplace", + "owner": {"name": "Test", "email": "test@example.com"}, + "plugins": [ + { + "name": "existing-plugin", + "description": "An existing plugin", + "author": {"name": "Test", "email": "test@example.com"}, + "source": "./plugins/existing-plugin", + "category": "development", + } + ], + } + with open(mp_dir / "marketplace.json", "w") as f: + json.dump(mp_json, f, indent=2) + (repo_path / "plugins").mkdir() + repo.index.add([".claude-plugin/marketplace.json"]) + repo.index.commit("Initial commit") + return repo_path + + +class TestReadFrontmatter: + def test_read_frontmatter_valid(self, skill_dir): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + fm = publisher._read_frontmatter(skill_dir / "SKILL.md") + assert fm["name"] == "test-skill" + assert fm["description"] == "A test skill for unit testing." + + def test_read_frontmatter_no_frontmatter(self, skill_dir_no_frontmatter): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + fm = publisher._read_frontmatter(skill_dir_no_frontmatter / "SKILL.md") + assert fm == {} + + def test_read_frontmatter_empty_file(self, tmp_path): + (tmp_path / "SKILL.md").write_text("") + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + assert publisher._read_frontmatter(tmp_path / "SKILL.md") == {} + + +class TestCopySkillToPlugin: + def test_copy_creates_correct_structure(self, skill_dir, tmp_path): + plugin_dir = tmp_path / "plugin_output" + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + publisher._copy_skill_to_plugin(skill_dir, plugin_dir, "test-skill") + assert (plugin_dir / "skills" / "test-skill" / "SKILL.md").exists() + assert ( + plugin_dir / "skills" / "test-skill" / "references" / "documentation" / "index.md" + ).exists() + + def test_copy_skill_md_content_preserved(self, skill_dir, tmp_path): + plugin_dir = tmp_path / "plugin_output" + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + publisher._copy_skill_to_plugin(skill_dir, plugin_dir, "test-skill") + original = (skill_dir / "SKILL.md").read_text() + copied = (plugin_dir / "skills" / "test-skill" / "SKILL.md").read_text() + assert original == copied + + def test_copy_without_references(self, tmp_path): + skill_dir = tmp_path / "skill-no-refs" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Skill\n") + plugin_dir = tmp_path / "plugin_output" + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + publisher._copy_skill_to_plugin(skill_dir, plugin_dir, "test-skill") + assert (plugin_dir / "skills" / "test-skill" / "SKILL.md").exists() + assert not (plugin_dir / "skills" / "test-skill" / "references").exists() + + +class TestGeneratePluginJson: + def test_generate_plugin_json(self): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + result = publisher._generate_plugin_json( + "test-skill", "A test skill", {"name": "Test", "email": "test@example.com"} + ) + assert result == { + "name": "test-skill", + "description": "A test skill", + "author": {"name": "Test", "email": "test@example.com"}, + } + + +class TestUpdateMarketplaceJson: + def test_update_appends_new_plugin(self, mock_marketplace_repo): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + author = {"name": "Test", "email": "test@example.com"} + publisher._update_marketplace_json( + mock_marketplace_repo, "new-plugin", "New plugin", author, "development" + ) + with open(mock_marketplace_repo / ".claude-plugin" / "marketplace.json") as f: + data = json.load(f) + assert len(data["plugins"]) == 2 + assert "new-plugin" in [p["name"] for p in data["plugins"]] + + def test_update_existing_plugin(self, mock_marketplace_repo): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + author = {"name": "Test", "email": "test@example.com"} + publisher._update_marketplace_json( + mock_marketplace_repo, "existing-plugin", "Updated", author, "tools" + ) + with open(mock_marketplace_repo / ".claude-plugin" / "marketplace.json") as f: + data = json.load(f) + assert len(data["plugins"]) == 1 + assert data["plugins"][0]["description"] == "Updated" + + def test_update_sorts_plugins_alphabetically(self, mock_marketplace_repo): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + author = {"name": "Test", "email": "test@example.com"} + publisher._update_marketplace_json( + mock_marketplace_repo, "aaa-plugin", "First", author, "dev" + ) + with open(mock_marketplace_repo / ".claude-plugin" / "marketplace.json") as f: + data = json.load(f) + names = [p["name"] for p in data["plugins"]] + assert names == sorted(names) + + def test_update_creates_marketplace_json_if_missing(self, tmp_path): + repo_path = tmp_path / "empty_repo" + repo_path.mkdir() + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + author = {"name": "Test", "email": "test@example.com"} + publisher._update_marketplace_json(repo_path, "new-plugin", "Desc", author, "development") + assert (repo_path / ".claude-plugin" / "marketplace.json").exists() + + +class TestPublishErrors: + def test_publish_missing_skill_md(self, tmp_path): + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + publisher.git_repo = MagicMock() + with pytest.raises(FileNotFoundError, match="SKILL.md not found"): + publisher.publish(skill_dir=empty_dir, marketplace_name="test") + + @patch.dict(os.environ, {}, clear=True) + def test_publish_missing_token(self, skill_dir, temp_config_dir): + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + manager = MarketplaceManager(config_dir=str(temp_config_dir)) + manager.add_marketplace( + name="test", git_url="https://github.com/test/repo.git", token_env="NONEXISTENT_TOKEN" + ) + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + publisher.git_repo = MagicMock() + with ( + patch( + "skill_seekers.mcp.marketplace_publisher.MarketplaceManager", return_value=manager + ), + pytest.raises(RuntimeError, match="Set NONEXISTENT_TOKEN"), + ): + publisher.publish(skill_dir=skill_dir, marketplace_name="test") + + def test_publish_plugin_already_exists(self, skill_dir, tmp_path, temp_config_dir): + import git as gitmodule + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + manager = MarketplaceManager(config_dir=str(temp_config_dir)) + manager.add_marketplace( + name="test", git_url="https://github.com/test/repo.git", token_env="TEST_TOKEN" + ) + # Create a cached repo without .git so publish() takes the clone path + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + publisher.git_repo = MagicMock() + publisher.git_repo.cache_dir = cache_dir + publisher.git_repo.inject_token.return_value = "https://fake@github.com/test/repo.git" + + # Mock clone_from to create the dir with existing plugin + def fake_clone(_url, path, **_kwargs): + from pathlib import Path + + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + (p / "plugins" / "test-skill").mkdir(parents=True) + r = gitmodule.Repo.init(p, initial_branch="main") + r.create_remote("origin", _url) + return r + + with ( + patch.dict(os.environ, {"TEST_TOKEN": "fake-token"}), + patch( + "skill_seekers.mcp.marketplace_publisher.MarketplaceManager", + return_value=manager, + ), + patch.object(gitmodule.Repo, "clone_from", side_effect=fake_clone), + pytest.raises(ValueError, match="already exists"), + ): + publisher.publish(skill_dir=skill_dir, marketplace_name="test") + + def test_publish_marketplace_not_found(self, skill_dir, temp_config_dir): + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + manager = MarketplaceManager(config_dir=str(temp_config_dir)) + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + publisher.git_repo = MagicMock() + with ( + patch( + "skill_seekers.mcp.marketplace_publisher.MarketplaceManager", return_value=manager + ), + pytest.raises(KeyError, match="not found"), + ): + publisher.publish(skill_dir=skill_dir, marketplace_name="nonexistent") + + +class TestValidateSkillName: + """Test skill name validation to prevent path traversal.""" + + def test_valid_names(self): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + assert publisher._validate_skill_name("react") == "react" + assert publisher._validate_skill_name("spine-unity") == "spine-unity" + assert publisher._validate_skill_name("my_skill_v2") == "my_skill_v2" + assert publisher._validate_skill_name("skill.v1") == "skill.v1" + + def test_path_traversal_rejected(self): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + with pytest.raises(ValueError, match="Invalid skill name"): + publisher._validate_skill_name("../../etc/passwd") + with pytest.raises(ValueError, match="Invalid skill name"): + publisher._validate_skill_name("../escape") + + def test_empty_name_rejected(self): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + with pytest.raises(ValueError, match="Invalid skill name"): + publisher._validate_skill_name("") + + def test_slash_rejected(self): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + with pytest.raises(ValueError, match="Invalid skill name"): + publisher._validate_skill_name("path/traversal") + + def test_special_chars_rejected(self): + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + with pytest.raises(ValueError, match="Invalid skill name"): + publisher._validate_skill_name("skill;rm -rf") + + +class TestPublishSuccess: + """Test publish() success path using a local bare git repo.""" + + def test_publish_success_flow(self, skill_dir, tmp_path): + """Full success path: clone โ†’ copy โ†’ commit โ†’ push.""" + import git as gitmodule + + # Create a working repo with initial marketplace structure, then bare-clone it + working_path = tmp_path / "working" + working_path.mkdir() + repo = gitmodule.Repo.init(working_path, initial_branch="main") + repo.config_writer().set_value("user", "name", "Test").release() + repo.config_writer().set_value("user", "email", "test@test.com").release() + + mp_dir = working_path / ".claude-plugin" + mp_dir.mkdir() + mp_json = { + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "test", + "description": "Test", + "owner": {"name": "Test", "email": "test@test.com"}, + "plugins": [], + } + with open(mp_dir / "marketplace.json", "w") as f: + json.dump(mp_json, f) + (working_path / "plugins").mkdir() + repo.index.add([".claude-plugin/marketplace.json"]) + repo.index.commit("Initial commit") + + # Create bare clone as the "remote" + bare_repo_path = tmp_path / "remote.git" + gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True) + + # Register marketplace with file:// URL + config_dir = tmp_path / "config" + config_dir.mkdir() + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + manager = MarketplaceManager(config_dir=str(config_dir)) + manager.add_marketplace( + name="local-test", + git_url=f"file://{bare_repo_path}", + token_env="DUMMY_TOKEN", + branch="main", + author={"name": "Test Author", "email": "test@example.com"}, + ) + + # Create publisher with custom cache dir + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + from skill_seekers.mcp.git_repo import GitConfigRepo + + publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir)) + + # Publish โ€” file:// URLs don't need real tokens but we need the env var set + with ( + patch.dict(os.environ, {"DUMMY_TOKEN": "not-needed-for-file-protocol"}), + patch( + "skill_seekers.mcp.marketplace_publisher.MarketplaceManager", + return_value=manager, + ), + ): + result = publisher.publish( + skill_dir=skill_dir, + marketplace_name="local-test", + category="testing", + ) + + # Verify result + assert result["success"] is True + assert result["plugin_path"] == "plugins/test-skill" + assert result["branch"] == "main" + assert len(result["commit_sha"]) == 7 + + # Verify files in the cached clone + cached_repo = cache_dir / "marketplace_local-test" + assert ( + cached_repo / "plugins" / "test-skill" / "skills" / "test-skill" / "SKILL.md" + ).exists() + assert (cached_repo / "plugins" / "test-skill" / ".claude-plugin" / "plugin.json").exists() + + # Verify marketplace.json was updated + with open(cached_repo / ".claude-plugin" / "marketplace.json") as f: + data = json.load(f) + plugin_names = [p["name"] for p in data["plugins"]] + assert "test-skill" in plugin_names + + def test_publish_with_force_overwrites(self, skill_dir, tmp_path): + """Test that force=True overwrites an existing plugin.""" + import git as gitmodule + + working_path = tmp_path / "working" + working_path.mkdir() + repo = gitmodule.Repo.init(working_path, initial_branch="main") + repo.config_writer().set_value("user", "name", "Test").release() + repo.config_writer().set_value("user", "email", "t@t.com").release() + + mp_dir = working_path / ".claude-plugin" + mp_dir.mkdir() + with open(mp_dir / "marketplace.json", "w") as f: + json.dump( + {"$schema": "", "name": "t", "description": "", "owner": {}, "plugins": []}, f + ) + (working_path / "plugins" / "test-skill" / ".claude-plugin").mkdir(parents=True) + repo.index.add([".claude-plugin/marketplace.json"]) + repo.index.commit("Initial") + + bare_repo_path = tmp_path / "remote.git" + gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True) + + config_dir = tmp_path / "config" + config_dir.mkdir() + from skill_seekers.mcp.marketplace_manager import MarketplaceManager + + manager = MarketplaceManager(config_dir=str(config_dir)) + manager.add_marketplace( + name="local-test", + git_url=f"file://{bare_repo_path}", + token_env="DUMMY_TOKEN", + branch="main", + author={"name": "Test", "email": "t@t.com"}, + ) + + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + publisher = MarketplacePublisher.__new__(MarketplacePublisher) + from skill_seekers.mcp.git_repo import GitConfigRepo + + publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir)) + + with ( + patch.dict(os.environ, {"DUMMY_TOKEN": "x"}), + patch( + "skill_seekers.mcp.marketplace_publisher.MarketplaceManager", + return_value=manager, + ), + ): + result = publisher.publish( + skill_dir=skill_dir, + marketplace_name="local-test", + category="testing", + force=True, + ) + + assert result["success"] is True diff --git a/uv.lock b/uv.lock index 3d687e3..d5e1f2d 100644 --- a/uv.lock +++ b/uv.lock @@ -1675,6 +1675,7 @@ dependencies = [ { name = "griffecli" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906, upload-time = "2026-03-23T21:06:53.402Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, ] @@ -1687,6 +1688,7 @@ dependencies = [ { name = "colorama" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213, upload-time = "2026-03-23T21:06:54.8Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, ] @@ -1695,6 +1697,7 @@ wheels = [ name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -4101,6 +4104,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -4644,6 +4666,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygithub" version = "2.8.1" @@ -5699,7 +5733,7 @@ wheels = [ [[package]] name = "skill-seekers" -version = "3.3.0" +version = "3.4.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -5775,6 +5809,9 @@ asciidoc = [ azure = [ { name = "azure-storage-blob" }, ] +browser = [ + { name = "playwright" }, +] chat = [ { name = "slack-sdk" }, ] @@ -5965,6 +6002,7 @@ requires-dist = [ { name = "pinecone", marker = "extra == 'all'", specifier = ">=5.0.0" }, { name = "pinecone", marker = "extra == 'pinecone'", specifier = ">=5.0.0" }, { name = "pinecone", marker = "extra == 'rag-upload'", specifier = ">=5.0.0" }, + { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40.0" }, { name = "pydantic", specifier = ">=2.12.3" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "pygithub", specifier = ">=2.5.0" }, @@ -6006,7 +6044,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2024.12.0" }, { name = "yt-dlp", marker = "extra == 'video-full'", specifier = ">=2024.12.0" }, ] -provides-extras = ["mcp", "gemini", "openai", "minimax", "kimi", "deepseek", "qwen", "openrouter", "together", "fireworks", "all-llms", "s3", "gcs", "azure", "docx", "epub", "video", "video-full", "chroma", "weaviate", "sentence-transformers", "pinecone", "rag-upload", "all-cloud", "jupyter", "asciidoc", "pptx", "confluence", "notion", "rss", "chat", "embedding", "all"] +provides-extras = ["mcp", "gemini", "openai", "minimax", "kimi", "deepseek", "qwen", "openrouter", "together", "fireworks", "all-llms", "s3", "gcs", "azure", "docx", "epub", "video", "video-full", "chroma", "weaviate", "sentence-transformers", "pinecone", "rag-upload", "all-cloud", "jupyter", "asciidoc", "pptx", "confluence", "notion", "rss", "chat", "browser", "embedding", "all"] [package.metadata.requires-dev] dev = [