From 5e4932e8b1fbc2f6e72581703ca1ad655d5d44f1 Mon Sep 17 00:00:00 2001 From: yusyus Date: Mon, 16 Mar 2026 23:29:50 +0300 Subject: [PATCH 01/21] feat: add distribution files for Smithery, GitHub Action, and Claude Code Plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Claude Code Plugin: plugin.json, .mcp.json, 3 slash commands, skill-builder agent skill - Add GitHub Action: composite action.yml with 6 inputs/2 outputs, comprehensive README - Add Smithery: publishing guide with namespace yusufkaraaslan/skill-seekers created - Add render-mcp.yaml for MCP server deployment on Render - Fix Dockerfile.mcp: --transport flag (nonexistent) → --http, add dynamic PORT support - Update AGENTS.md to v3.3.0 with corrected test count and expanded CI section - Allow distribution/claude-plugin/.mcp.json in .gitignore --- .gitignore | 1 + AGENTS.md | 81 +++++----- Dockerfile.mcp | 13 +- .../claude-plugin/.claude-plugin/plugin.json | 11 ++ distribution/claude-plugin/.mcp.json | 6 + distribution/claude-plugin/README.md | 93 +++++++++++ .../claude-plugin/commands/create-skill.md | 52 +++++++ .../claude-plugin/commands/install-skill.md | 44 ++++++ .../claude-plugin/commands/sync-config.md | 32 ++++ .../skills/skill-builder/SKILL.md | 69 ++++++++ distribution/github-action/README.md | 147 ++++++++++++++++++ distribution/github-action/action.yml | 92 +++++++++++ distribution/smithery/README.md | 107 +++++++++++++ render-mcp.yaml | 17 ++ 14 files changed, 718 insertions(+), 47 deletions(-) create mode 100644 distribution/claude-plugin/.claude-plugin/plugin.json create mode 100644 distribution/claude-plugin/.mcp.json create mode 100644 distribution/claude-plugin/README.md create mode 100644 distribution/claude-plugin/commands/create-skill.md create mode 100644 distribution/claude-plugin/commands/install-skill.md create mode 100644 distribution/claude-plugin/commands/sync-config.md create mode 100644 distribution/claude-plugin/skills/skill-builder/SKILL.md create mode 100644 distribution/github-action/README.md create mode 100644 distribution/github-action/action.yml create mode 100644 distribution/smithery/README.md create mode 100644 render-mcp.yaml diff --git a/.gitignore b/.gitignore index 4450b38..8569091 100644 --- a/.gitignore +++ b/.gitignore @@ -61,5 +61,6 @@ htmlcov/ skill-seekers-configs/ .claude/skills .mcp.json +!distribution/claude-plugin/.mcp.json settings.json USER_GUIDE.md diff --git a/AGENTS.md b/AGENTS.md index d26c952..1afdc9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,20 @@ # AGENTS.md - Skill Seekers -Concise reference for AI coding agents. Skill Seekers is a Python CLI tool (v3.2.0) that converts documentation sites, GitHub repos, PDFs, videos, notebooks, wikis, and more into AI-ready skills for 16+ LLM platforms and RAG pipelines. +Concise reference for AI coding agents. Skill Seekers is a Python CLI tool (v3.3.0) that converts documentation sites, GitHub repos, PDFs, videos, notebooks, wikis, and more into AI-ready skills for 16+ LLM platforms and RAG pipelines. ## Setup ```bash -# REQUIRED before running tests (src/ layout — tests fail without this) +# REQUIRED before running tests (src/ layout — tests hard-exit if package not installed) pip install -e . -# With dev tools +# With dev tools (pytest, ruff, mypy, coverage) pip install -e ".[dev]" # With all optional deps pip install -e ".[all]" ``` +Note: `tests/conftest.py` checks that `skill_seekers` is importable and calls `sys.exit(1)` if not. Always install in editable mode first. + ## Build / Test / Lint Commands ```bash @@ -46,8 +48,10 @@ ruff format src/ tests/ mypy src/skill_seekers --show-error-codes --pretty ``` -**Test markers:** `slow`, `integration`, `e2e`, `venv`, `bootstrap`, `benchmark` -**Async tests:** use `@pytest.mark.asyncio`; asyncio_mode is `auto`. +**Pytest config** (from pyproject.toml): `addopts = "-v --tb=short --strict-markers"`, `asyncio_mode = "auto"`, `asyncio_default_fixture_loop_scope = "function"`. +**Test markers:** `slow`, `integration`, `e2e`, `venv`, `bootstrap`, `benchmark`, `asyncio`. +**Async tests:** use `@pytest.mark.asyncio`; asyncio_mode is `auto` so the decorator is often implicit. +**Test count:** 120 test files (107 in `tests/`, 13 in `tests/test_adaptors/`). ## Code Style @@ -61,61 +65,47 @@ mypy src/skill_seekers --show-error-codes --pretty - Sort with isort (via ruff); `skill_seekers` is first-party - Standard library → third-party → first-party, separated by blank lines - Use `from __future__ import annotations` only if needed for forward refs -- Guard optional imports with try/except ImportError (see `adaptors/__init__.py` pattern) +- Guard optional imports with try/except ImportError (see `adaptors/__init__.py` pattern): + ```python + try: + from .claude import ClaudeAdaptor + except ImportError: + ClaudeAdaptor = None + ``` ### Naming Conventions -- **Files:** `snake_case.py` -- **Classes:** `PascalCase` (e.g., `SkillAdaptor`, `ClaudeAdaptor`) -- **Functions/methods:** `snake_case` -- **Constants:** `UPPER_CASE` (e.g., `ADAPTORS`, `DEFAULT_CHUNK_TOKENS`) -- **Private:** prefix with `_` +- **Files:** `snake_case.py` (e.g., `source_detector.py`, `config_validator.py`) +- **Classes:** `PascalCase` (e.g., `SkillAdaptor`, `ClaudeAdaptor`, `SourceDetector`) +- **Functions/methods:** `snake_case` (e.g., `get_adaptor()`, `detect_language()`) +- **Constants:** `UPPER_CASE` (e.g., `ADAPTORS`, `DEFAULT_CHUNK_TOKENS`, `VALID_SOURCE_TYPES`) +- **Private:** prefix with `_` (e.g., `_read_existing_content()`, `_validate_unified()`) ### Type Hints - Gradual typing — add hints where practical, not enforced everywhere - Use modern syntax: `str | None` not `Optional[str]`, `list[str]` not `List[str]` - MyPy config: `disallow_untyped_defs = false`, `check_untyped_defs = true`, `ignore_missing_imports = true` +- Tests are excluded from strict type checking (`disallow_untyped_defs = false`, `check_untyped_defs = false` for `tests.*`) ### Docstrings - Module-level docstring on every file (triple-quoted, describes purpose) -- Google-style or standard docstrings for public functions/classes +- Google-style docstrings for public functions/classes - Include `Args:`, `Returns:`, `Raises:` sections where useful ### Error Handling - Use specific exceptions, never bare `except:` -- Provide helpful error messages with context (see `get_adaptor()` in `adaptors/__init__.py`) +- Provide helpful error messages with context - Use `raise ValueError(...)` for invalid arguments, `raise RuntimeError(...)` for state errors - Guard optional dependency imports with try/except and give clear install instructions on failure +- Chain exceptions with `raise ... from e` when wrapping ### Suppressing Lint Warnings - Use inline `# noqa: XXXX` comments (e.g., `# noqa: F401` for re-exports, `# noqa: ARG001` for required but unused params) -## Supported Source Types (17) - -| Type | CLI Command | Config Type | Detection | -|------|------------|-------------|-----------| -| Documentation (web) | `scrape` / `create ` | `documentation` | HTTP/HTTPS URLs | -| GitHub repo | `github` / `create owner/repo` | `github` | `owner/repo` or github.com URLs | -| PDF | `pdf` / `create file.pdf` | `pdf` | `.pdf` extension | -| Word (.docx) | `word` / `create file.docx` | `word` | `.docx` extension | -| EPUB | `epub` / `create file.epub` | `epub` | `.epub` extension | -| Video | `video` / `create ` | `video` | YouTube/Vimeo URLs, video extensions | -| Local codebase | `analyze` / `create ./path` | `local` | Directory paths | -| Jupyter Notebook | `jupyter` / `create file.ipynb` | `jupyter` | `.ipynb` extension | -| Local HTML | `html` / `create file.html` | `html` | `.html`/`.htm` extensions | -| OpenAPI/Swagger | `openapi` / `create spec.yaml` | `openapi` | `.yaml`/`.yml` with OpenAPI content | -| AsciiDoc | `asciidoc` / `create file.adoc` | `asciidoc` | `.adoc`/`.asciidoc` extensions | -| PowerPoint | `pptx` / `create file.pptx` | `pptx` | `.pptx` extension | -| RSS/Atom | `rss` / `create feed.rss` | `rss` | `.rss`/`.atom` extensions | -| Man pages | `manpage` / `create cmd.1` | `manpage` | `.1`-`.8`/`.man` extensions | -| Confluence | `confluence` | `confluence` | API or export directory | -| Notion | `notion` | `notion` | API or export directory | -| Slack/Discord | `chat` | `chat` | Export directory or API | - ## Project Layout ``` src/skill_seekers/ # Main package (src/ layout) - cli/ # CLI commands and entry points + cli/ # CLI commands and entry points (96 files) adaptors/ # Platform adaptors (Strategy pattern, inherit SkillAdaptor) arguments/ # CLI argument definitions (one per source type) parsers/ # Subcommand parsers (one per source type) @@ -127,15 +117,15 @@ src/skill_seekers/ # Main package (src/ layout) unified_scraper.py # Multi-source orchestrator (scraped_data + dispatch) unified_skill_builder.py # Pairwise synthesis + generic merge mcp/ # MCP server (FastMCP + legacy) - tools/ # MCP tool implementations by category + tools/ # MCP tool implementations by category (10 files) sync/ # Sync monitoring (Pydantic models) benchmark/ # Benchmarking framework embedding/ # FastAPI embedding server - workflows/ # 67 YAML workflow presets (includes complex-merge.yaml) + workflows/ # 67 YAML workflow presets _version.py # Reads version from pyproject.toml -tests/ # 115+ test files (pytest) +tests/ # 120 test files (pytest) configs/ # Preset JSON scraping configs -docs/ # 80+ markdown doc files +docs/ # Documentation (guides, integrations, architecture) ``` ## Key Patterns @@ -150,6 +140,8 @@ docs/ # 80+ markdown doc files **CLI subcommands** — git-style in `cli/main.py`. Each delegates to a module's `main()` function. +**Supported source types (17):** documentation (web), github, pdf, word, epub, video, local codebase, jupyter, html, openapi, asciidoc, pptx, rss, manpage, confluence, notion, chat (slack/discord). Each detected automatically by `source_detector.py`. + ## Git Workflow - **`main`** — production, protected @@ -168,4 +160,11 @@ Never commit API keys. Use env vars: `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `OPE ## CI -GitHub Actions (`.github/workflows/tests.yml`): ruff + mypy lint job, then pytest matrix (Ubuntu + macOS, Python 3.10-3.12) with Codecov upload. +GitHub Actions (7 workflows in `.github/workflows/`): +- **tests.yml** — ruff + mypy lint job, then pytest matrix (Ubuntu + macOS, Python 3.10-3.12) with Codecov upload +- **release.yml** — tag-triggered: tests → version verification → PyPI publish via `uv build` +- **test-vector-dbs.yml** — tests vector DB adaptors (weaviate, chroma, faiss, qdrant) +- **docker-publish.yml** — multi-platform Docker builds (amd64, arm64) for CLI + MCP images +- **quality-metrics.yml** — quality analysis with configurable threshold +- **scheduled-updates.yml** — weekly skill updates for popular frameworks +- **vector-db-export.yml** — weekly vector DB exports diff --git a/Dockerfile.mcp b/Dockerfile.mcp index 6e7cc3e..7baba55 100644 --- a/Dockerfile.mcp +++ b/Dockerfile.mcp @@ -4,8 +4,8 @@ FROM python:3.12-slim LABEL maintainer="Skill Seekers " -LABEL description="Skill Seekers MCP Server - 25 tools for AI skills generation" -LABEL version="2.9.0" +LABEL description="Skill Seekers MCP Server - 35 tools for AI skills generation" +LABEL version="3.3.0" WORKDIR /app @@ -48,9 +48,10 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ # Volumes VOLUME ["/data", "/configs", "/output"] -# Expose MCP server port -EXPOSE 8765 +# Expose MCP server port (default 8765, overridden by $PORT on cloud platforms) +EXPOSE ${MCP_PORT:-8765} # Start MCP server in HTTP mode by default -# Use --transport stdio for stdio mode -CMD ["python", "-m", "skill_seekers.mcp.server_fastmcp", "--transport", "http", "--port", "8765"] +# Uses shell form so $PORT/$MCP_PORT env vars are expanded at runtime +# Cloud platforms (Render, Railway, etc.) set $PORT automatically +CMD python -m skill_seekers.mcp.server_fastmcp --http --host 0.0.0.0 --port ${PORT:-${MCP_PORT:-8765}} diff --git a/distribution/claude-plugin/.claude-plugin/plugin.json b/distribution/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..1b3ed69 --- /dev/null +++ b/distribution/claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "skill-seekers", + "description": "Transform 17 source types (docs, GitHub, PDFs, videos, Jupyter, Confluence, Notion, Slack, and more) into AI-ready skills and RAG knowledge for 16+ LLM platforms.", + "version": "3.3.0", + "author": { + "name": "Yusuf Karaaslan" + }, + "homepage": "https://github.com/yusufkaraaslan/Skill_Seekers", + "repository": "https://github.com/yusufkaraaslan/Skill_Seekers", + "license": "MIT" +} diff --git a/distribution/claude-plugin/.mcp.json b/distribution/claude-plugin/.mcp.json new file mode 100644 index 0000000..c0fa9c9 --- /dev/null +++ b/distribution/claude-plugin/.mcp.json @@ -0,0 +1,6 @@ +{ + "skill-seekers": { + "command": "python", + "args": ["-m", "skill_seekers.mcp.server_fastmcp"] + } +} diff --git a/distribution/claude-plugin/README.md b/distribution/claude-plugin/README.md new file mode 100644 index 0000000..a433fc1 --- /dev/null +++ b/distribution/claude-plugin/README.md @@ -0,0 +1,93 @@ +# Skill Seekers — Claude Code Plugin + +Transform 17 source types into AI-ready skills and RAG knowledge, directly from Claude Code. + +## Installation + +### From the Official Plugin Directory + +``` +/plugin install skill-seekers@claude-plugin-directory +``` + +Or browse for it in `/plugin > Discover`. + +### Local Installation (for development) + +```bash +claude --plugin-dir ./path/to/skill-seekers-plugin +``` + +### Prerequisites + +The plugin requires `skill-seekers` to be installed: + +```bash +pip install skill-seekers[mcp] +``` + +## What's Included + +### MCP Server (35 tools) + +The plugin bundles the Skill Seekers MCP server providing tools for: +- Scraping documentation, GitHub repos, PDFs, videos, and 13 other source types +- Packaging skills for 16+ LLM platforms +- Exporting to vector databases (Weaviate, Chroma, FAISS, Qdrant) +- Managing configs, workflows, and sources + +### Slash Commands + +| Command | Description | +|---------|-------------| +| `/skill-seekers:create-skill ` | Create a skill from any source (auto-detects type) | +| `/skill-seekers:sync-config ` | Sync config URLs against live docs | +| `/skill-seekers:install-skill ` | End-to-end: fetch, scrape, enhance, package, install | + +### Agent Skill + +The **skill-builder** skill is automatically available to Claude. It detects source types and uses the appropriate MCP tools to build skills autonomously. + +## Usage Examples + +``` +# Create a skill from a documentation site +/skill-seekers:create-skill https://react.dev + +# Create from a GitHub repo, targeting LangChain +/skill-seekers:create-skill pallets/flask --target langchain + +# Full install workflow with AI enhancement +/skill-seekers:install-skill https://fastapi.tiangolo.com --enhance + +# Sync an existing config +/skill-seekers:sync-config react +``` + +Or just ask Claude naturally: +> "Create an AI skill from the React documentation" +> "Scrape the Flask GitHub repo and package it for OpenAI" +> "Export my skill to a Chroma vector database" + +The skill-builder agent skill will automatically detect the intent and use the right tools. + +## Remote MCP Alternative + +By default, the plugin runs the MCP server locally via `python -m skill_seekers.mcp.server_fastmcp`. To use a remote server instead, edit `.mcp.json`: + +```json +{ + "skill-seekers": { + "type": "http", + "url": "https://your-hosted-server.com/mcp" + } +} +``` + +## Supported Source Types + +Documentation (web), GitHub repos, PDFs, Word docs, EPUBs, videos, local codebases, Jupyter notebooks, HTML files, OpenAPI specs, AsciiDoc, PowerPoint, RSS/Atom feeds, man pages, Confluence, Notion, Slack/Discord exports. + +## License + +MIT — https://github.com/yusufkaraaslan/Skill_Seekers diff --git a/distribution/claude-plugin/commands/create-skill.md b/distribution/claude-plugin/commands/create-skill.md new file mode 100644 index 0000000..7c0a584 --- /dev/null +++ b/distribution/claude-plugin/commands/create-skill.md @@ -0,0 +1,52 @@ +--- +description: Create an AI skill from any source (URL, repo, PDF, video, notebook, etc.) +--- + +# Create Skill + +Create an AI-ready skill from a source. The source type is auto-detected. + +## Usage + +``` +/skill-seekers:create-skill [--target ] [--output ] +``` + +## Instructions + +When the user provides a source via `$ARGUMENTS`, run the `skill-seekers create` command to generate a skill. + +1. Parse the arguments: extract the source (first argument) and any flags. +2. If no `--target` is specified, default to `claude`. +3. If no `--output` is specified, default to `./output`. +4. Run the command: + ```bash + skill-seekers create "$SOURCE" --target "$TARGET" --output "$OUTPUT" + ``` +5. After completion, read the generated `SKILL.md` and summarize what was created. + +## Source Types (auto-detected) + +- **URL** (https://...) → Documentation scraping +- **owner/repo** or github.com URL → GitHub repo analysis +- **file.pdf** → PDF extraction +- **file.ipynb** → Jupyter notebook +- **file.docx** → Word document +- **file.epub** → EPUB book +- **YouTube/Vimeo URL** → Video transcript +- **./directory** → Local codebase analysis +- **file.yaml** with OpenAPI → API spec +- **file.pptx** → PowerPoint +- **file.adoc** → AsciiDoc +- **file.html** → HTML page +- **file.rss** → RSS/Atom feed +- **cmd.1** → Man page + +## Examples + +``` +/skill-seekers:create-skill https://react.dev +/skill-seekers:create-skill pallets/flask --target langchain +/skill-seekers:create-skill ./docs/api.pdf --target openai +/skill-seekers:create-skill https://youtube.com/watch?v=abc123 +``` diff --git a/distribution/claude-plugin/commands/install-skill.md b/distribution/claude-plugin/commands/install-skill.md new file mode 100644 index 0000000..63595fa --- /dev/null +++ b/distribution/claude-plugin/commands/install-skill.md @@ -0,0 +1,44 @@ +--- +description: One-command skill installation — fetch config, scrape, enhance, package, and install +--- + +# Install Skill + +Complete end-to-end workflow: fetch a config (from preset or URL), scrape the source, optionally enhance with AI, package for the target platform, and install. + +## Usage + +``` +/skill-seekers:install-skill [--target ] [--enhance] +``` + +## Instructions + +When the user provides a source or config via `$ARGUMENTS`: + +1. Determine if the argument is a config preset name, config file path, or a direct source. +2. Use the `install_skill` MCP tool if available, or run the equivalent CLI commands: + ```bash + # For preset configs + skill-seekers install --config "$CONFIG" --target "$TARGET" + + # For direct sources + skill-seekers create "$SOURCE" --target "$TARGET" + ``` +3. If `--enhance` is specified, run enhancement after initial scraping: + ```bash + skill-seekers enhance "$SKILL_DIR" --target "$TARGET" + ``` +4. Report the final skill location and how to use it. + +## Target Platforms + +`claude`, `openai`, `gemini`, `langchain`, `llamaindex`, `haystack`, `cursor`, `windsurf`, `continue`, `cline`, `markdown` + +## Examples + +``` +/skill-seekers:install-skill react --target claude +/skill-seekers:install-skill https://fastapi.tiangolo.com --target langchain --enhance +/skill-seekers:install-skill pallets/flask +``` diff --git a/distribution/claude-plugin/commands/sync-config.md b/distribution/claude-plugin/commands/sync-config.md new file mode 100644 index 0000000..273bd15 --- /dev/null +++ b/distribution/claude-plugin/commands/sync-config.md @@ -0,0 +1,32 @@ +--- +description: Sync a scraping config's URLs against the live documentation site +--- + +# Sync Config + +Synchronize a Skill Seekers config file with the current state of a documentation site. Detects new pages, removed pages, and URL changes. + +## Usage + +``` +/skill-seekers:sync-config +``` + +## Instructions + +When the user provides a config path or preset name via `$ARGUMENTS`: + +1. If it's a preset name (e.g., `react`, `godot`), look for it in the `configs/` directory or fetch from the API. +2. Run the sync command: + ```bash + skill-seekers sync-config "$CONFIG" + ``` +3. Report what changed: new URLs found, removed URLs, and any conflicts. +4. Ask the user if they want to update the config and re-scrape. + +## Examples + +``` +/skill-seekers:sync-config configs/react.json +/skill-seekers:sync-config react +``` diff --git a/distribution/claude-plugin/skills/skill-builder/SKILL.md b/distribution/claude-plugin/skills/skill-builder/SKILL.md new file mode 100644 index 0000000..c0d8b40 --- /dev/null +++ b/distribution/claude-plugin/skills/skill-builder/SKILL.md @@ -0,0 +1,69 @@ +--- +name: skill-builder +description: Automatically detect source types and build AI skills using Skill Seekers. Use when the user wants to create skills from documentation, repos, PDFs, videos, or other knowledge sources. +--- + +# Skill Builder + +You have access to the Skill Seekers MCP server which provides 35 tools for converting knowledge sources into AI-ready skills. + +## When to Use This Skill + +Use this skill when the user: +- Wants to create an AI skill from a documentation site, GitHub repo, PDF, video, or other source +- Needs to convert documentation into a format suitable for LLM consumption +- Wants to update or sync existing skills with their source documentation +- Needs to export skills to vector databases (Weaviate, Chroma, FAISS, Qdrant) +- Asks about scraping, converting, or packaging documentation for AI + +## Source Type Detection + +Automatically detect the source type from user input: + +| Input Pattern | Source Type | Tool to Use | +|---------------|-------------|-------------| +| `https://...` (not GitHub/YouTube) | Documentation | `scrape_docs` | +| `owner/repo` or `github.com/...` | GitHub | `scrape_github` | +| `*.pdf` | PDF | `scrape_pdf` | +| YouTube/Vimeo URL or video file | Video | `scrape_video` | +| Local directory path | Codebase | `scrape_codebase` | +| `*.ipynb`, `*.html`, `*.yaml` (OpenAPI), `*.adoc`, `*.pptx`, `*.rss`, `*.1`-`.8` | Various | `scrape_generic` | +| JSON config file | Unified | Use config with `scrape_docs` | + +## Recommended Workflow + +1. **Detect source type** from the user's input +2. **Generate or fetch config** using `generate_config` or `fetch_config` if needed +3. **Estimate scope** with `estimate_pages` for documentation sites +4. **Scrape the source** using the appropriate scraping tool +5. **Enhance** with `enhance_skill` if the user wants AI-powered improvements +6. **Package** with `package_skill` for the target platform +7. **Export to vector DB** if requested using `export_to_*` tools + +## Available MCP Tools + +### Config Management +- `generate_config` — Generate a scraping config from a URL +- `list_configs` — List available preset configs +- `validate_config` — Validate a config file + +### Scraping (use based on source type) +- `scrape_docs` — Documentation sites +- `scrape_github` — GitHub repositories +- `scrape_pdf` — PDF files +- `scrape_video` — Video transcripts +- `scrape_codebase` — Local code analysis +- `scrape_generic` — Jupyter, HTML, OpenAPI, AsciiDoc, PPTX, RSS, manpage, Confluence, Notion, chat + +### Post-processing +- `enhance_skill` — AI-powered skill enhancement +- `package_skill` — Package for target platform +- `upload_skill` — Upload to platform API +- `install_skill` — End-to-end install workflow + +### Advanced +- `detect_patterns` — Design pattern detection in code +- `extract_test_examples` — Extract usage examples from tests +- `build_how_to_guides` — Generate how-to guides from tests +- `split_config` — Split large configs into focused skills +- `export_to_weaviate`, `export_to_chroma`, `export_to_faiss`, `export_to_qdrant` — Vector DB export diff --git a/distribution/github-action/README.md b/distribution/github-action/README.md new file mode 100644 index 0000000..dd9f46e --- /dev/null +++ b/distribution/github-action/README.md @@ -0,0 +1,147 @@ +# Skill Seekers GitHub Action + +Transform documentation, GitHub repos, PDFs, videos, and 13 other source types into AI-ready skills and RAG knowledge — directly in your CI/CD pipeline. + +## Quick Start + +```yaml +- uses: yusufkaraaslan/skill-seekers-action@v3 + with: + source: 'https://react.dev' +``` + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `source` | Yes | — | Source URL, file path, or `owner/repo` | +| `command` | No | `create` | Command: `create`, `scrape`, `github`, `pdf`, `video`, `analyze`, `unified` | +| `target` | No | `claude` | Target platform: `claude`, `openai`, `gemini`, `langchain`, `llamaindex`, `markdown` | +| `config` | No | — | Path to JSON config file | +| `output-dir` | No | `output` | Output directory | +| `extra-args` | No | — | Additional CLI arguments | + +## Outputs + +| Output | Description | +|--------|-------------| +| `skill-dir` | Path to the generated skill directory | +| `skill-name` | Name of the generated skill | + +## Examples + +### Auto-update documentation skill weekly + +```yaml +name: Update AI Skills +on: + schedule: + - cron: '0 6 * * 1' # Every Monday 6am UTC + workflow_dispatch: + +jobs: + update-skills: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: yusufkaraaslan/skill-seekers-action@v3 + with: + source: 'https://react.dev' + target: 'langchain' + + - uses: actions/upload-artifact@v4 + with: + name: react-skill + path: output/ +``` + +### Generate skill from GitHub repo + +```yaml +- uses: yusufkaraaslan/skill-seekers-action@v3 + with: + source: 'pallets/flask' + command: 'github' + target: 'claude' +``` + +### Process PDF documentation + +```yaml +- uses: actions/checkout@v4 + +- uses: yusufkaraaslan/skill-seekers-action@v3 + with: + source: 'docs/api-reference.pdf' + command: 'pdf' +``` + +### Unified multi-source build with config + +```yaml +- uses: actions/checkout@v4 + +- uses: yusufkaraaslan/skill-seekers-action@v3 + with: + config: 'configs/my-project.json' + command: 'unified' + target: 'openai' +``` + +### Commit generated skill back to repo + +```yaml +- uses: actions/checkout@v4 + +- uses: yusufkaraaslan/skill-seekers-action@v3 + id: generate + with: + source: 'https://fastapi.tiangolo.com' + +- name: Commit skill + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add output/ + git diff --staged --quiet || git commit -m "Update AI skill: ${{ steps.generate.outputs.skill-name }}" + git push +``` + +## Environment Variables + +Pass API keys as environment variables for AI-enhanced skills: + +```yaml +env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +## Supported Source Types + +| Type | Example Source | +|------|---------------| +| Documentation (web) | `https://react.dev` | +| GitHub repo | `pallets/flask` or `https://github.com/pallets/flask` | +| PDF | `docs/manual.pdf` | +| Video | `https://youtube.com/watch?v=...` | +| Local codebase | `./src` | +| Jupyter Notebook | `analysis.ipynb` | +| OpenAPI/Swagger | `openapi.yaml` | +| Word (.docx) | `docs/guide.docx` | +| EPUB | `book.epub` | +| PowerPoint | `slides.pptx` | +| AsciiDoc | `docs/guide.adoc` | +| HTML | `page.html` | +| RSS/Atom | `feed.rss` | +| Man pages | `tool.1` | +| Confluence | Via config file | +| Notion | Via config file | +| Chat (Slack/Discord) | Via config file | + +## License + +MIT diff --git a/distribution/github-action/action.yml b/distribution/github-action/action.yml new file mode 100644 index 0000000..17b9977 --- /dev/null +++ b/distribution/github-action/action.yml @@ -0,0 +1,92 @@ +name: 'Skill Seekers - AI Knowledge Builder' +description: 'Transform documentation, repos, PDFs, videos, and 13 other source types into AI skills and RAG knowledge' +author: 'Yusuf Karaaslan' + +branding: + icon: 'book-open' + color: 'blue' + +inputs: + source: + description: 'Source URL, file path, or owner/repo for GitHub repos' + required: true + command: + description: 'Command to run: create (auto-detect), scrape, github, pdf, video, analyze, unified' + required: false + default: 'create' + target: + description: 'Output target platform: claude, openai, gemini, langchain, llamaindex, markdown, cursor, windsurf' + required: false + default: 'claude' + config: + description: 'Path to JSON config file (for unified/advanced scraping)' + required: false + output-dir: + description: 'Output directory for generated skills' + required: false + default: 'output' + extra-args: + description: 'Additional CLI arguments to pass to skill-seekers' + required: false + default: '' + +outputs: + skill-dir: + description: 'Path to the generated skill directory' + value: ${{ steps.run.outputs.skill-dir }} + skill-name: + description: 'Name of the generated skill' + value: ${{ steps.run.outputs.skill-name }} + +runs: + using: 'composite' + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Skill Seekers + shell: bash + run: pip install skill-seekers + + - name: Run Skill Seekers + id: run + shell: bash + env: + ANTHROPIC_API_KEY: ${{ env.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ env.OPENAI_API_KEY }} + GOOGLE_API_KEY: ${{ env.GOOGLE_API_KEY }} + GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} + run: | + set -euo pipefail + + OUTPUT_DIR="${{ inputs.output-dir }}" + mkdir -p "$OUTPUT_DIR" + + CMD="${{ inputs.command }}" + SOURCE="${{ inputs.source }}" + TARGET="${{ inputs.target }}" + CONFIG="${{ inputs.config }}" + EXTRA="${{ inputs.extra-args }}" + + # Build the command + if [ "$CMD" = "create" ]; then + skill-seekers create "$SOURCE" --target "$TARGET" --output "$OUTPUT_DIR" $EXTRA + elif [ -n "$CONFIG" ]; then + skill-seekers "$CMD" --config "$CONFIG" --target "$TARGET" --output "$OUTPUT_DIR" $EXTRA + else + skill-seekers "$CMD" "$SOURCE" --target "$TARGET" --output "$OUTPUT_DIR" $EXTRA + fi + + # Find the generated skill directory + SKILL_DIR=$(find "$OUTPUT_DIR" -name "SKILL.md" -exec dirname {} \; | head -1) + SKILL_NAME=$(basename "$SKILL_DIR" 2>/dev/null || echo "unknown") + + echo "skill-dir=$SKILL_DIR" >> "$GITHUB_OUTPUT" + echo "skill-name=$SKILL_NAME" >> "$GITHUB_OUTPUT" + + echo "### Skill Generated" >> "$GITHUB_STEP_SUMMARY" + echo "- **Name:** $SKILL_NAME" >> "$GITHUB_STEP_SUMMARY" + echo "- **Directory:** $SKILL_DIR" >> "$GITHUB_STEP_SUMMARY" + echo "- **Target:** $TARGET" >> "$GITHUB_STEP_SUMMARY" diff --git a/distribution/smithery/README.md b/distribution/smithery/README.md new file mode 100644 index 0000000..714cff4 --- /dev/null +++ b/distribution/smithery/README.md @@ -0,0 +1,107 @@ +# Skill Seekers — Smithery MCP Registry + +Publishing guide for the Skill Seekers MCP server on [Smithery](https://smithery.ai). + +## Status + +- **Namespace created:** `yusufkaraaslan` +- **Server created:** `yusufkaraaslan/skill-seekers` +- **Server page:** https://smithery.ai/servers/yusufkaraaslan/skill-seekers +- **Release status:** Needs re-publish (initial release failed — Smithery couldn't scan GitHub URL as MCP endpoint) + +## Publishing + +Smithery requires a live, scannable MCP HTTP endpoint for URL-based publishing. Two options: + +### Option A: Publish via Web UI (Recommended) + +1. Go to https://smithery.ai/servers/yusufkaraaslan/skill-seekers/releases +2. The server already exists — create a new release +3. For the "Local" tab: follow the prompts to publish as a stdio server +4. For the "URL" tab: provide a hosted HTTP endpoint URL + +### Option B: Deploy HTTP endpoint first, then publish via CLI + +1. Deploy the MCP server on Render/Railway/Fly.io: + ```bash + # Using existing Dockerfile.mcp + docker build -f Dockerfile.mcp -t skill-seekers-mcp . + # Deploy to your hosting provider + ``` +2. Publish the live URL: + ```bash + npx @smithery/cli@latest auth login + npx @smithery/cli@latest mcp publish "https://your-deployed-url/mcp" \ + -n yusufkaraaslan/skill-seekers + ``` + +### CLI Authentication (already done) + +```bash +# Install via npx (no global install needed) +npx @smithery/cli@latest auth login +npx @smithery/cli@latest namespace show # Should show: yusufkaraaslan +``` + +### After Publishing + +Update the server page with metadata: + +**Display name:** Skill Seekers — AI Skill & RAG Toolkit + +**Description:** +> Transform 17 source types into AI-ready skills and RAG knowledge. Ingest documentation sites, GitHub repos, PDFs, Jupyter notebooks, videos, Confluence, Notion, Slack/Discord exports, and more. Package for 16+ LLM platforms including Claude, GPT, Gemini, LangChain, LlamaIndex, and vector databases. + +**Tags:** `ai`, `rag`, `documentation`, `skills`, `preprocessing`, `mcp`, `knowledge-base`, `vector-database` + +## User Installation + +Once published, users can add the server to their MCP client: + +```bash +# Via Smithery CLI (adds to Claude Desktop, Cursor, etc.) +smithery mcp add yusufkaraaslan/skill-seekers --client claude + +# Or configure manually — users need skill-seekers installed: +pip install skill-seekers[mcp] +``` + +### Manual MCP Configuration + +For clients that use JSON config (Claude Desktop, Claude Code, Cursor): + +```json +{ + "mcpServers": { + "skill-seekers": { + "command": "python", + "args": ["-m", "skill_seekers.mcp.server_fastmcp"] + } + } +} +``` + +## Available Tools (35) + +| Category | Tools | Description | +|----------|-------|-------------| +| Config | 3 | Generate, list, validate scraping configs | +| Sync | 1 | Sync config URLs against live docs | +| Scraping | 11 | Scrape docs, GitHub, PDF, video, codebase, generic (10 types) | +| Packaging | 4 | Package, upload, enhance, install skills | +| Splitting | 2 | Split large configs, generate routers | +| Sources | 5 | Fetch, submit, manage config sources | +| Vector DB | 4 | Export to Weaviate, Chroma, FAISS, Qdrant | +| Workflows | 5 | List, get, create, update, delete workflows | + +## Maintenance + +- Update description/tags on major releases +- No code changes needed — users always get the latest via `pip install` + +## Notes + +- Smithery CLI v4.7.0 removed the `--transport stdio` flag from the docs +- The CLI `publish` command only supports URL-based (external) publishing +- For local/stdio servers, use the web UI at smithery.ai/servers/new +- The namespace and server entity are already created; only the release needs to succeed diff --git a/render-mcp.yaml b/render-mcp.yaml new file mode 100644 index 0000000..29fd36b --- /dev/null +++ b/render-mcp.yaml @@ -0,0 +1,17 @@ +services: + # MCP Server Service (HTTP mode) + - type: web + name: skill-seekers-mcp + runtime: docker + plan: free + dockerfilePath: ./Dockerfile.mcp + envVars: + - key: MCP_PORT + value: "8765" + - key: PORT + fromService: + type: web + name: skill-seekers-mcp + property: port + healthCheckPath: /health + autoDeploy: true From 26c2d0bd5c399f9084c5bb267eb8e412c79ed869 Mon Sep 17 00:00:00 2001 From: yusyus Date: Tue, 17 Mar 2026 22:03:20 +0300 Subject: [PATCH 02/21] fix: correct CLI flags in plugin slash commands (create uses --preset, package uses --target) --- .../claude-plugin/commands/create-skill.md | 50 +++++++++++-------- .../claude-plugin/commands/install-skill.md | 33 ++++++------ 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/distribution/claude-plugin/commands/create-skill.md b/distribution/claude-plugin/commands/create-skill.md index 7c0a584..6130871 100644 --- a/distribution/claude-plugin/commands/create-skill.md +++ b/distribution/claude-plugin/commands/create-skill.md @@ -9,7 +9,7 @@ Create an AI-ready skill from a source. The source type is auto-detected. ## Usage ``` -/skill-seekers:create-skill [--target ] [--output ] +/skill-seekers:create-skill [--preset ] [--output ] ``` ## Instructions @@ -17,36 +17,46 @@ Create an AI-ready skill from a source. The source type is auto-detected. When the user provides a source via `$ARGUMENTS`, run the `skill-seekers create` command to generate a skill. 1. Parse the arguments: extract the source (first argument) and any flags. -2. If no `--target` is specified, default to `claude`. +2. If no `--preset` is specified, default to `quick` for fast results. 3. If no `--output` is specified, default to `./output`. -4. Run the command: +4. Run the create command: ```bash - skill-seekers create "$SOURCE" --target "$TARGET" --output "$OUTPUT" + skill-seekers create "$SOURCE" --preset quick --output "$OUTPUT" ``` 5. After completion, read the generated `SKILL.md` and summarize what was created. +6. If the user wants to target a specific platform (e.g., Claude, OpenAI, LangChain), run the package command after: + ```bash + skill-seekers package "$SKILL_DIR" --target "$PLATFORM" + ``` + +## Presets + +- `-p quick` — 1-2 minutes, basic skill +- `-p standard` — 5-10 minutes, good coverage +- `-p comprehensive` — 20-60 minutes, full analysis ## Source Types (auto-detected) -- **URL** (https://...) → Documentation scraping -- **owner/repo** or github.com URL → GitHub repo analysis -- **file.pdf** → PDF extraction -- **file.ipynb** → Jupyter notebook -- **file.docx** → Word document -- **file.epub** → EPUB book -- **YouTube/Vimeo URL** → Video transcript -- **./directory** → Local codebase analysis -- **file.yaml** with OpenAPI → API spec -- **file.pptx** → PowerPoint -- **file.adoc** → AsciiDoc -- **file.html** → HTML page -- **file.rss** → RSS/Atom feed -- **cmd.1** → Man page +- **URL** (https://...) — Documentation scraping +- **owner/repo** or github.com URL — GitHub repo analysis +- **file.pdf** — PDF extraction +- **file.ipynb** — Jupyter notebook +- **file.docx** — Word document +- **file.epub** — EPUB book +- **YouTube/Vimeo URL** — Video transcript +- **./directory** — Local codebase analysis +- **file.yaml** with OpenAPI — API spec +- **file.pptx** — PowerPoint +- **file.adoc** — AsciiDoc +- **file.html** — HTML page +- **file.rss** — RSS/Atom feed +- **cmd.1** — Man page ## Examples ``` /skill-seekers:create-skill https://react.dev -/skill-seekers:create-skill pallets/flask --target langchain -/skill-seekers:create-skill ./docs/api.pdf --target openai +/skill-seekers:create-skill pallets/flask -p standard +/skill-seekers:create-skill ./docs/api.pdf /skill-seekers:create-skill https://youtube.com/watch?v=abc123 ``` diff --git a/distribution/claude-plugin/commands/install-skill.md b/distribution/claude-plugin/commands/install-skill.md index 63595fa..0d9c1de 100644 --- a/distribution/claude-plugin/commands/install-skill.md +++ b/distribution/claude-plugin/commands/install-skill.md @@ -1,44 +1,41 @@ --- -description: One-command skill installation — fetch config, scrape, enhance, package, and install +description: One-command skill creation and packaging for a target platform --- # Install Skill -Complete end-to-end workflow: fetch a config (from preset or URL), scrape the source, optionally enhance with AI, package for the target platform, and install. +End-to-end workflow: create a skill from any source, then package it for a target LLM platform. ## Usage ``` -/skill-seekers:install-skill [--target ] [--enhance] +/skill-seekers:install-skill [--target ] [--preset ] ``` ## Instructions -When the user provides a source or config via `$ARGUMENTS`: +When the user provides a source via `$ARGUMENTS`: -1. Determine if the argument is a config preset name, config file path, or a direct source. -2. Use the `install_skill` MCP tool if available, or run the equivalent CLI commands: +1. Parse the arguments: extract source, `--target` (default: claude), `--preset` (default: quick). +2. Run the create command: ```bash - # For preset configs - skill-seekers install --config "$CONFIG" --target "$TARGET" - - # For direct sources - skill-seekers create "$SOURCE" --target "$TARGET" + skill-seekers create "$SOURCE" --preset "$PRESET" --output ./output ``` -3. If `--enhance` is specified, run enhancement after initial scraping: +3. Find the generated skill directory (look for the directory containing SKILL.md in ./output/). +4. Run the package command for the target platform: ```bash - skill-seekers enhance "$SKILL_DIR" --target "$TARGET" + skill-seekers package "$SKILL_DIR" --target "$TARGET" ``` -4. Report the final skill location and how to use it. +5. Report what was created and where to find the packaged output. ## Target Platforms -`claude`, `openai`, `gemini`, `langchain`, `llamaindex`, `haystack`, `cursor`, `windsurf`, `continue`, `cline`, `markdown` +`claude` (default), `openai`, `gemini`, `langchain`, `llamaindex`, `haystack`, `cursor`, `windsurf`, `continue`, `cline`, `markdown` ## Examples ``` -/skill-seekers:install-skill react --target claude -/skill-seekers:install-skill https://fastapi.tiangolo.com --target langchain --enhance -/skill-seekers:install-skill pallets/flask +/skill-seekers:install-skill https://react.dev --target claude +/skill-seekers:install-skill pallets/flask --target langchain -p standard +/skill-seekers:install-skill ./docs/api.pdf --target openai ``` From 37a23e6c6dbfbd93a93852917d3cc2cc7bc0b5c5 Mon Sep 17 00:00:00 2001 From: yusyus Date: Thu, 19 Mar 2026 00:10:50 +0300 Subject: [PATCH 03/21] fix: replace unicode arrows in CLI help text for Windows cp1252 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace → (U+2192) with -> in argparse help strings. Windows cmd uses cp1252 encoding which cannot render unicode arrows, causing --help to crash with UnicodeEncodeError. --- src/skill_seekers/cli/arguments/enhance.py | 2 +- src/skill_seekers/cli/create_command.py | 20 +++++++++---------- .../cli/parsers/create_parser.py | 4 ++-- .../cli/parsers/install_parser.py | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/skill_seekers/cli/arguments/enhance.py b/src/skill_seekers/cli/arguments/enhance.py index ef19dba..01389da 100644 --- a/src/skill_seekers/cli/arguments/enhance.py +++ b/src/skill_seekers/cli/arguments/enhance.py @@ -26,7 +26,7 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = { "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. " + "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." ), "metavar": "PLATFORM", diff --git a/src/skill_seekers/cli/create_command.py b/src/skill_seekers/cli/create_command.py index 6f01023..b39ddf2 100644 --- a/src/skill_seekers/cli/create_command.py +++ b/src/skill_seekers/cli/create_command.py @@ -626,17 +626,17 @@ Examples: Config: skill-seekers create configs/react.json Source Auto-Detection: - • URLs/domains → web scraping - • owner/repo → GitHub analysis - • ./path → local codebase - • file.pdf → PDF extraction - • file.docx → Word document extraction - • file.epub → EPUB extraction - • youtube.com/... → Video transcript extraction - • file.mp4 → Video file extraction - • file.json → multi-source config + URLs/domains -> web scraping + owner/repo -> GitHub analysis + ./path -> local codebase + file.pdf -> PDF extraction + file.docx -> Word document extraction + file.epub -> EPUB extraction + youtube.com/... -> Video transcript extraction + file.mp4 -> Video file extraction + file.json -> multi-source config -Progressive Help (13 → 120+ flags): +Progressive Help (13 -> 120+ flags): --help-web Web scraping options --help-github GitHub repository options --help-local Local codebase analysis diff --git a/src/skill_seekers/cli/parsers/create_parser.py b/src/skill_seekers/cli/parsers/create_parser.py index 062be7b..4f83d6d 100644 --- a/src/skill_seekers/cli/parsers/create_parser.py +++ b/src/skill_seekers/cli/parsers/create_parser.py @@ -35,8 +35,8 @@ Quick Examples: skill-seekers create ./my-project -p comprehensive Source Types (auto-detected): - URLs → web docs | owner/repo → GitHub | ./path → local code - file.pdf → PDF | file.json → config (multi-source) + URLs -> web docs | owner/repo -> GitHub | ./path -> local code + file.pdf -> PDF | file.json -> config (multi-source) Progressive Help (NEW -p shortcut): Default help shows 13 flags. For more: --help-web, --help-github, diff --git a/src/skill_seekers/cli/parsers/install_parser.py b/src/skill_seekers/cli/parsers/install_parser.py index 3d48e6d..526afaa 100644 --- a/src/skill_seekers/cli/parsers/install_parser.py +++ b/src/skill_seekers/cli/parsers/install_parser.py @@ -12,7 +12,7 @@ class InstallParser(SubcommandParser): @property def help(self) -> str: - return "Complete workflow: fetch → scrape → enhance → package → upload" + return "Complete workflow: fetch -> scrape -> enhance -> package -> upload" @property def description(self) -> str: From 4f87de6b5657ad50bf627b383404008113217971 Mon Sep 17 00:00:00 2001 From: yusyus Date: Fri, 20 Mar 2026 22:12:23 +0300 Subject: [PATCH 04/21] fix: improve MiniMax adaptor from PR #318 review (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MiniMax AI as LLM platform adaptor Original implementation by octo-patch in PR #318. This commit includes comprehensive improvements and documentation. Code Improvements: - Fix API key validation to properly check JWT format (eyJ prefix) - Add specific exception handling for timeout and connection errors - Remove unused variable in upload method Dependencies: - Add MiniMax to [all-llms] extra group in pyproject.toml Tests: - Remove duplicate setUp method in integration test class - Add 4 new test methods: * test_package_excludes_backup_files * test_upload_success_mocked (with OpenAI mocking) * test_upload_network_error * test_upload_connection_error * test_validate_api_key_jwt_format - Update test_validate_api_key_valid to use JWT format keys - Fix test assertions for error message matching Documentation: - Create comprehensive MINIMAX_INTEGRATION.md guide (380+ lines) - Update MULTI_LLM_SUPPORT.md with MiniMax platform entry - Update 01-installation.md extras table - Update INTEGRATIONS.md AI platforms table - Update AGENTS.md adaptor import pattern example - Fix README.md platform count from 4 to 5 All tests pass (33 passed, 3 skipped) Lint checks pass Co-authored-by: octo-patch * fix: improve MiniMax adaptor — typed exceptions, key validation, tests, docs - Remove invalid "minimax" self-reference from all-llms dependency group - Use typed OpenAI exceptions (APITimeoutError, APIConnectionError) instead of string-matching on generic Exception - Replace incorrect JWT assumption in validate_api_key with length check - Use DEFAULT_API_ENDPOINT constant instead of hardcoded URLs (3 sites) - Add Path() cast for output_path before .is_dir() call - Add sys.modules mock to test_enhance_missing_library - Add mocked test_enhance_success with backup/content verification - Update test assertions for new exception types and key validation - Add MiniMax to __init__.py docstrings (module, get_adaptor, list_platforms) - Add MiniMax sections to MULTI_LLM_SUPPORT.md (install, format, API key, workflow example, export-to-all) Follows up on PR #318 by @octo-patch (feat: add MiniMax AI as LLM platform adaptor). Co-Authored-By: Octopus Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: octo-patch Co-authored-by: Claude Opus 4.6 (1M context) --- AGENTS.md | 4 +- CLAUDE.md | 2511 ++----------------- README.md | 27 +- docs/getting-started/01-installation.md | 3 + docs/integrations/INTEGRATIONS.md | 3 +- docs/integrations/MINIMAX_INTEGRATION.md | 391 +++ docs/integrations/MULTI_LLM_SUPPORT.md | 49 +- pyproject.toml | 5 + src/skill_seekers/cli/adaptors/__init__.py | 14 +- src/skill_seekers/cli/adaptors/minimax.py | 503 ++++ tests/test_adaptors/test_minimax_adaptor.py | 517 ++++ uv.lock | 8 +- 12 files changed, 1676 insertions(+), 2359 deletions(-) create mode 100644 docs/integrations/MINIMAX_INTEGRATION.md create mode 100644 src/skill_seekers/cli/adaptors/minimax.py create mode 100644 tests/test_adaptors/test_minimax_adaptor.py diff --git a/AGENTS.md b/AGENTS.md index 1afdc9a..9f69468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ mypy src/skill_seekers --show-error-codes --pretty **Pytest config** (from pyproject.toml): `addopts = "-v --tb=short --strict-markers"`, `asyncio_mode = "auto"`, `asyncio_default_fixture_loop_scope = "function"`. **Test markers:** `slow`, `integration`, `e2e`, `venv`, `bootstrap`, `benchmark`, `asyncio`. **Async tests:** use `@pytest.mark.asyncio`; asyncio_mode is `auto` so the decorator is often implicit. -**Test count:** 120 test files (107 in `tests/`, 13 in `tests/test_adaptors/`). +**Test count:** 123 test files (107 in `tests/`, 16 in `tests/test_adaptors/`). ## Code Style @@ -69,8 +69,10 @@ mypy src/skill_seekers --show-error-codes --pretty ```python try: from .claude import ClaudeAdaptor + from .minimax import MiniMaxAdaptor except ImportError: ClaudeAdaptor = None + MiniMaxAdaptor = None ``` ### Naming Conventions diff --git a/CLAUDE.md b/CLAUDE.md index 3615cf0..185526c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,2389 +2,218 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## 🎯 Project Overview +## Project Overview -**Skill Seekers** is the **universal documentation preprocessor** for AI systems. It transforms documentation websites, GitHub repositories, PDFs, and EPUBs into production-ready formats for **16+ platforms**: RAG pipelines (LangChain, LlamaIndex, Haystack), vector databases (Pinecone, Chroma, Weaviate, FAISS, Qdrant), AI coding assistants (Cursor, Windsurf, Cline, Continue.dev), and LLM platforms (Claude, Gemini, OpenAI). +**Skill Seekers** converts documentation from 17 source types into production-ready formats for 16+ AI platforms (LLM platforms, RAG frameworks, vector databases, AI coding assistants). Published on PyPI as `skill-seekers`. -**Current Version:** v3.1.3 -**Python Version:** 3.10+ required -**Status:** Production-ready, published on PyPI -**Website:** https://skillseekersweb.com/ - Browse configs, share, and access documentation +**Version:** 3.3.0 | **Python:** 3.10+ | **Website:** https://skillseekersweb.com/ -## 📚 Table of Contents - -- [First Time Here?](#-first-time-here) - Start here! -- [Quick Commands](#-quick-command-reference-most-used) - Common workflows -- [Architecture](#️-architecture) - How it works -- [Development](#️-development-commands) - Building & testing -- [Testing](#-testing-guidelines) - Test strategy -- [Debugging](#-debugging-tips) - Troubleshooting -- [Contributing](#-where-to-make-changes) - How to add features - -## 👋 First Time Here? - -**Complete this 3-minute setup to start contributing:** +## Essential Commands ```bash -# 1. Install package in editable mode (REQUIRED for development) +# REQUIRED before running tests or CLI (src/ layout) pip install -e . -# 2. Verify installation -python -c "import skill_seekers; print(skill_seekers.__version__)" # Should print: 3.1.0-dev +# Run all tests (NEVER skip - all must pass before commits) +pytest tests/ -v -# 3. Run a quick test -pytest tests/test_scraper_features.py::test_detect_language -v +# Fast iteration (skip slow MCP tests ~20min) +pytest tests/ --ignore=tests/test_mcp_fastmcp.py --ignore=tests/test_mcp_server.py --ignore=tests/test_install_skill_e2e.py -q -# 4. You're ready! Pick a task from the roadmap: -# https://github.com/users/yusufkaraaslan/projects/2 +# Single test +pytest tests/test_scraper_features.py::test_detect_language -vv -s + +# Code quality (must pass before push - matches CI) +uvx ruff check src/ tests/ +uvx ruff format --check src/ tests/ +mypy src/skill_seekers # continue-on-error in CI + +# Auto-fix lint/format issues +uvx ruff check --fix --unsafe-fixes src/ tests/ +uvx ruff format src/ tests/ + +# Build & publish +uv build +uv publish ``` -**Quick Navigation:** -- Building/Testing → [Development Commands](#️-development-commands) -- Architecture → [Core Design Pattern](#️-architecture) -- Common Issues → [Common Pitfalls](#-common-pitfalls--solutions) -- Contributing → See `CONTRIBUTING.md` +## CI Matrix -## ⚡ Quick Command Reference (Most Used) +Runs on push/PR to `main` or `development`. Lint job (Python 3.12, Ubuntu) + Test job (Ubuntu + macOS, Python 3.10/3.11/3.12, excludes macOS+3.10). Both must pass for merge. -**First time setup:** -```bash -pip install -e . # REQUIRED before running tests or CLI +## Git Workflow + +- **Main branch:** `main` (requires tests + 1 review) +- **Development branch:** `development` (default PR target, requires tests) +- **Feature branches:** `feature/{task-id}-{description}` from `development` +- PRs always target `development`, never `main` directly + +## Architecture + +### CLI: Git-style dispatcher + +Entry point `src/skill_seekers/cli/main.py` maps subcommands to modules. The `create` command auto-detects source type and is the recommended entry point for users. + +``` +skill-seekers create # Auto-detect: URL, owner/repo, ./path, file.pdf, etc. +skill-seekers [options] # Direct: scrape, github, pdf, word, epub, video, jupyter, html, openapi, asciidoc, pptx, rss, manpage, confluence, notion, chat +skill-seekers package # Package for platform (--target claude/gemini/openai/markdown, --format langchain/llama-index/haystack/chroma/faiss/weaviate/qdrant) ``` -**Running tests (NEVER skip - user requirement):** -```bash -pytest tests/ -v # All tests -pytest tests/test_scraper_features.py -v # Single file -pytest tests/ --cov=src/skill_seekers --cov-report=html # With coverage -``` +### Data Flow (5 phases) -**Code quality checks (matches CI):** -```bash -ruff check src/ tests/ # Lint -ruff format src/ tests/ # Format -mypy src/skill_seekers # Type check -``` +1. **Scrape** - Source-specific scraper extracts content to `output/{name}_data/pages/*.json` +2. **Build** - `build_skill()` categorizes pages, extracts patterns, generates `output/{name}/SKILL.md` +3. **Enhance** (optional) - LLM rewrites SKILL.md (`--enhance-level 0-3`, auto-detects API vs LOCAL mode) +4. **Package** - Platform adaptor formats output (`.zip`, `.tar.gz`, JSON, vector index) +5. **Upload** (optional) - Platform API upload -**Common workflows:** -```bash -# NEW unified create command (auto-detects source type) -skill-seekers create https://docs.react.dev/ -p quick -skill-seekers create facebook/react -p standard -skill-seekers create ./my-project -p comprehensive -skill-seekers create tutorial.pdf - -# Legacy commands (still supported) -skill-seekers scrape --config configs/react.json -skill-seekers github --repo facebook/react -skill-seekers analyze --directory . --comprehensive - -# Package for LLM platforms -skill-seekers package output/react/ --target claude -skill-seekers package output/react/ --target gemini -``` - -**RAG Pipeline workflows:** -```bash -# LangChain Documents -skill-seekers package output/react/ --format langchain - -# LlamaIndex TextNodes -skill-seekers package output/react/ --format llama-index - -# Haystack Documents -skill-seekers package output/react/ --format haystack - -# ChromaDB direct upload -skill-seekers package output/react/ --format chroma --upload - -# FAISS export -skill-seekers package output/react/ --format faiss - -# Weaviate/Qdrant upload (requires API keys) -skill-seekers package output/react/ --format weaviate --upload -skill-seekers package output/react/ --format qdrant --upload -``` - -**AI Coding Assistant workflows:** -```bash -# Cursor IDE -skill-seekers package output/react/ --target claude -cp output/react-claude/SKILL.md .cursorrules - -# Windsurf -cp output/react-claude/SKILL.md .windsurf/rules/react.md - -# Cline (VS Code) -cp output/react-claude/SKILL.md .clinerules - -# Continue.dev (universal IDE) -python examples/continue-dev-universal/context_server.py -# Configure in ~/.continue/config.json -``` - -**Cloud Storage:** -```bash -# Upload to S3 -skill-seekers cloud upload --provider s3 --bucket my-skills output/react.zip - -# Upload to GCS -skill-seekers cloud upload --provider gcs --bucket my-skills output/react.zip - -# Upload to Azure -skill-seekers cloud upload --provider azure --container my-skills output/react.zip -``` - -## 🏗️ Architecture - -### Core Design Pattern: Platform Adaptors - -The codebase uses the **Strategy Pattern** with a factory method to support **16 platforms** across 4 categories: +### Platform Adaptor Pattern (Strategy + Factory) ``` src/skill_seekers/cli/adaptors/ -├── __init__.py # Factory: get_adaptor(target/format) -├── base.py # Abstract base class -# LLM Platforms (3) -├── claude.py # Claude AI (ZIP + YAML) -├── gemini.py # Google Gemini (tar.gz) -├── openai.py # OpenAI ChatGPT (ZIP + Vector Store) -# RAG Frameworks (3) -├── langchain.py # LangChain Documents -├── llama_index.py # LlamaIndex TextNodes -├── haystack.py # Haystack Documents -# Vector Databases (5) -├── chroma.py # ChromaDB -├── faiss_helpers.py # FAISS -├── qdrant.py # Qdrant -├── weaviate.py # Weaviate -# AI Coding Assistants (4 - via Claude format + config files) -# - Cursor, Windsurf, Cline, Continue.dev -# Generic (1) -├── markdown.py # Generic Markdown (ZIP) -└── streaming_adaptor.py # Streaming data ingest +├── __init__.py # Factory: get_adaptor(target=..., format=...) +├── base_adaptor.py # Abstract base: package(), upload(), enhance(), export() +├── claude_adaptor.py # --target claude +├── gemini_adaptor.py # --target gemini +├── openai_adaptor.py # --target openai +├── markdown_adaptor.py # --target markdown +├── langchain.py # --format langchain +├── llama_index.py # --format llama-index +├── haystack.py # --format haystack +├── chroma.py # --format chroma +├── faiss_helpers.py # --format faiss +├── qdrant.py # --format qdrant +├── weaviate.py # --format weaviate +└── streaming_adaptor.py # --format streaming ``` -**Key Methods:** -- `package(skill_dir, output_path)` - Platform-specific packaging -- `upload(package_path, api_key)` - Platform-specific upload (where applicable) -- `enhance(skill_dir, mode)` - AI enhancement with platform-specific models -- `export(skill_dir, format)` - Export to RAG/vector DB formats +`--target` = LLM platforms, `--format` = RAG/vector DBs. -### Data Flow (5 Phases) +### 17 Source Type Scrapers -1. **Scrape Phase** (`doc_scraper.py:scrape_all()`) - - BFS traversal from base_url - - Output: `output/{name}_data/pages/*.json` +Each in `src/skill_seekers/cli/{type}_scraper.py` with a `main()` entry point. The `create_command.py` uses `source_detector.py` to auto-route. New scrapers added in v3.2.0+: jupyter, html, openapi, asciidoc, pptx, rss, manpage, confluence, notion, chat. -2. **Build Phase** (`doc_scraper.py:build_skill()`) - - Load pages → Categorize → Extract patterns - - Output: `output/{name}/SKILL.md` + `references/*.md` - -3. **Enhancement Phase** (optional, `enhance_skill_local.py`) - - LLM analyzes references → Rewrites SKILL.md - - Platform-specific models (Sonnet 4, Gemini 2.0, GPT-4o) - -4. **Package Phase** (`package_skill.py` → adaptor) - - Platform adaptor packages in appropriate format - - Output: `.zip` or `.tar.gz` - -5. **Upload Phase** (optional, `upload_skill.py` → adaptor) - - Upload via platform API - -### File Structure (src/ layout) - Key Files Only +### CLI Argument System ``` -src/skill_seekers/ -├── cli/ # All CLI commands -│ ├── main.py # ⭐ Git-style CLI dispatcher -│ ├── doc_scraper.py # ⭐ Main scraper (~790 lines) -│ │ ├── scrape_all() # BFS traversal engine -│ │ ├── smart_categorize() # Category detection -│ │ └── build_skill() # SKILL.md generation -│ ├── github_scraper.py # GitHub repo analysis -│ ├── codebase_scraper.py # ⭐ Local analysis (C2.x+C3.x) -│ ├── package_skill.py # Platform packaging -│ ├── unified_scraper.py # Multi-source scraping -│ ├── unified_codebase_analyzer.py # Three-stream GitHub+local analyzer -│ ├── enhance_skill_local.py # AI enhancement (LOCAL mode) -│ ├── enhance_status.py # Enhancement status monitoring -│ ├── upload_skill.py # Upload to platforms -│ ├── install_skill.py # Complete workflow automation -│ ├── install_agent.py # Install to AI agent directories -│ ├── pattern_recognizer.py # C3.1 Design pattern detection -│ ├── test_example_extractor.py # C3.2 Test example extraction -│ ├── how_to_guide_builder.py # C3.3 How-to guide generation -│ ├── config_extractor.py # C3.4 Configuration extraction -│ ├── generate_router.py # C3.5 Router skill generation -│ ├── code_analyzer.py # Multi-language code analysis -│ ├── api_reference_builder.py # API documentation builder -│ ├── dependency_analyzer.py # Dependency graph analysis -│ ├── signal_flow_analyzer.py # C3.10 Signal flow analysis (Godot) -│ ├── pdf_scraper.py # PDF extraction -│ ├── epub_scraper.py # EPUB extraction -│ └── adaptors/ # ⭐ Platform adaptor pattern -│ ├── __init__.py # Factory: get_adaptor() -│ ├── base_adaptor.py # Abstract base -│ ├── claude_adaptor.py # Claude AI -│ ├── gemini_adaptor.py # Google Gemini -│ ├── openai_adaptor.py # OpenAI ChatGPT -│ ├── markdown_adaptor.py # Generic Markdown -│ ├── langchain.py # LangChain RAG -│ ├── llama_index.py # LlamaIndex RAG -│ ├── haystack.py # Haystack RAG -│ ├── chroma.py # ChromaDB -│ ├── faiss_helpers.py # FAISS -│ ├── qdrant.py # Qdrant -│ ├── weaviate.py # Weaviate -│ └── streaming_adaptor.py # Streaming data ingest -└── mcp/ # MCP server (26 tools) - ├── server_fastmcp.py # FastMCP server - └── tools/ # Tool implementations +src/skill_seekers/cli/ +├── parsers/ # Subcommand parser registration +│ └── create_parser.py # Progressive help disclosure (--help-web, --help-github, etc.) +├── arguments/ # Argument definitions +│ ├── common.py # add_all_standard_arguments() - shared across all scrapers +│ └── create.py # UNIVERSAL_ARGUMENTS, WEB_ARGUMENTS, GITHUB_ARGUMENTS, etc. +└── source_detector.py # Auto-detect source type from input string ``` -**Most Modified Files (when contributing):** -- Platform adaptors: `src/skill_seekers/cli/adaptors/{platform}.py` -- Tests: `tests/test_{feature}.py` -- Configs: `configs/{framework}.json` +### C3.x Codebase Analysis Pipeline -## 🛠️ Development Commands +Local codebase analysis features, all opt-out (`--skip-*` flags): +- C3.1 `pattern_recognizer.py` - Design pattern detection (10 GoF patterns, 9 languages) +- C3.2 `test_example_extractor.py` - Usage examples from tests +- C3.3 `how_to_guide_builder.py` - AI-enhanced educational guides +- C3.4 `config_extractor.py` - Configuration pattern extraction +- C3.5 `generate_router.py` - Architecture overview generation +- C3.10 `signal_flow_analyzer.py` - Godot signal flow analysis -### Setup +### MCP Server + +`src/skill_seekers/mcp/server_fastmcp.py` - 26+ tools via FastMCP. Transport: stdio (Claude Code) or HTTP (Cursor/Windsurf). Optional dependency: `pip install -e ".[mcp]"` + +### Enhancement Modes + +- **API mode** (if `ANTHROPIC_API_KEY` set): Direct Claude API calls +- **LOCAL mode** (fallback): Uses Claude Code CLI (free with Max plan) +- Control: `--enhance-level 0` (off) / `1` (SKILL.md only) / `2` (default, balanced) / `3` (full) + +## Key Implementation Details + +### Smart Categorization (`doc_scraper.py:smart_categorize()`) + +Scores pages against category keywords: 3 points for URL match, 2 for title, 1 for content. Threshold of 2+ required. Falls back to "other". + +### Content Extraction (`doc_scraper.py`) + +`FALLBACK_MAIN_SELECTORS` constant + `_find_main_content()` helper handle CSS selector fallback. Links are extracted from the full page before early return (not just main content). `body` is deliberately excluded from fallbacks. + +### Three-Stream GitHub Architecture (`unified_codebase_analyzer.py`) + +Stream 1: Code Analysis (AST, patterns, tests, guides). Stream 2: Documentation (README, docs/, wiki). Stream 3: Community (issues, PRs, metadata). Depth control: `basic` (1-2 min) or `c3x` (20-60 min). + +## Testing + +### Test markers (pytest.ini) ```bash -# Install in editable mode (required before tests due to src/ layout) -pip install -e . - -# Install with all platform dependencies -pip install -e ".[all-llms]" - -# Install specific platforms -pip install -e ".[gemini]" # Google Gemini -pip install -e ".[openai]" # OpenAI ChatGPT +pytest tests/ -v # Default: fast tests only +pytest tests/ -v -m slow # Include slow tests (>5s) +pytest tests/ -v -m integration # External services required +pytest tests/ -v -m e2e # Resource-intensive +pytest tests/ -v -m "not slow and not integration" # Fastest subset ``` -### Running Tests +### Known legitimate skips (~11) -**CRITICAL: Never skip tests** - User requires all tests to pass before commits. +- 2: chromadb incompatible with Python 3.14 (pydantic v1) +- 2: weaviate-client not installed +- 2: Qdrant not running (requires docker) +- 2: langchain/llama_index not installed +- 3: GITHUB_TOKEN not set + +### sys.modules gotcha + +`test_swift_detection.py` deletes `skill_seekers.cli` modules from `sys.modules`. It must save and restore both `sys.modules` entries AND parent package attributes (`setattr`). See the test file for the pattern. + +## Dependencies + +Core deps include `langchain`, `llama-index`, `anthropic`, `httpx`, `PyMuPDF`, `pydantic`. Platform-specific deps are optional: ```bash -# All tests (must run pip install -e . first!) -pytest tests/ -v - -# Specific test file -pytest tests/test_scraper_features.py -v - -# Multi-platform tests -pytest tests/test_install_multiplatform.py -v - -# With coverage -pytest tests/ --cov=src/skill_seekers --cov-report=term --cov-report=html - -# Single test -pytest tests/test_scraper_features.py::test_detect_language -v - -# MCP server tests -pytest tests/test_mcp_fastmcp.py -v +pip install -e ".[mcp]" # MCP server +pip install -e ".[gemini]" # Google Gemini +pip install -e ".[openai]" # OpenAI +pip install -e ".[docx]" # Word documents +pip install -e ".[epub]" # EPUB books +pip install -e ".[video]" # Video (lightweight) +pip install -e ".[video-full]"# Video (Whisper + visual) +pip install -e ".[jupyter]" # Jupyter notebooks +pip install -e ".[pptx]" # PowerPoint +pip install -e ".[rss]" # RSS/Atom feeds +pip install -e ".[confluence]"# Confluence wiki +pip install -e ".[notion]" # Notion pages +pip install -e ".[chroma]" # ChromaDB +pip install -e ".[all]" # Everything (except video-full) ``` -**Test Architecture:** -- 46 test files covering all features -- CI Matrix: Ubuntu + macOS, Python 3.10-3.13 -- **2,540 tests passing** (current), up from 700+ in v2.x -- Must run `pip install -e .` before tests (src/ layout requirement) -- Tests include create command integration tests, CLI refactor E2E tests +Dev dependencies use PEP 735 `[dependency-groups]` in pyproject.toml. -### Building & Publishing +## Environment Variables ```bash -# Build package (using uv - recommended) -uv build - -# Or using build -python -m build - -# Publish to PyPI -uv publish - -# Or using twine -python -m twine upload dist/* +ANTHROPIC_API_KEY=sk-ant-... # Claude AI (or compatible endpoint) +ANTHROPIC_BASE_URL=https://... # Optional: Claude-compatible API endpoint +GOOGLE_API_KEY=AIza... # Google Gemini (optional) +OPENAI_API_KEY=sk-... # OpenAI (optional) +GITHUB_TOKEN=ghp_... # Higher GitHub rate limits ``` -### Testing CLI Commands - -```bash -# Test configuration wizard (NEW: v2.7.0) -skill-seekers config --show # Show current configuration -skill-seekers config --github # GitHub token setup -skill-seekers config --test # Test connections - -# Test resume functionality (NEW: v2.7.0) -skill-seekers resume --list # List resumable jobs -skill-seekers resume --clean # Clean up old jobs - -# Test GitHub scraping with profiles (NEW: v2.7.0) -skill-seekers github --repo facebook/react --profile personal # Use specific profile -skill-seekers github --repo owner/repo --non-interactive # CI/CD mode - -# Test scraping (dry run) -skill-seekers scrape --config configs/react.json --dry-run - -# Test codebase analysis (C2.x features) -skill-seekers analyze --directory . --output output/codebase/ - -# Test pattern detection (C3.1) -skill-seekers patterns --file src/skill_seekers/cli/code_analyzer.py - -# Test how-to guide generation (C3.3) -skill-seekers how-to-guides output/test_examples.json --output output/guides/ - -# Test enhancement status monitoring -skill-seekers enhance-status output/react/ --watch - -# Video setup (auto-detect GPU and install deps) -skill-seekers video --setup - -# Test multi-platform packaging -skill-seekers package output/react/ --target gemini --dry-run - -# Test MCP server (stdio mode) -python -m skill_seekers.mcp.server_fastmcp - -# Test MCP server (HTTP mode) -python -m skill_seekers.mcp.server_fastmcp --transport http --port 8765 -``` - -### New v3.0.0 CLI Commands - -```bash -# Setup wizard (interactive configuration) -skill-seekers-setup - -# Cloud storage operations -skill-seekers cloud upload --provider s3 --bucket my-bucket output/react.zip -skill-seekers cloud download --provider gcs --bucket my-bucket react.zip -skill-seekers cloud list --provider azure --container my-container - -# Embedding server (for RAG pipelines) -skill-seekers embed --port 8080 --model sentence-transformers - -# Sync & incremental updates -skill-seekers sync --source https://docs.react.dev/ --target output/react/ -skill-seekers update --skill output/react/ --check-changes - -# Quality metrics & benchmarking -skill-seekers quality --skill output/react/ --report -skill-seekers benchmark --config configs/react.json --compare-versions - -# Multilingual support -skill-seekers multilang --detect output/react/ -skill-seekers multilang --translate output/react/ --target zh-CN - -# Streaming data ingest -skill-seekers stream --source docs/ --target output/streaming/ -``` - -## 🔧 Key Implementation Details - -### CLI Architecture (Git-style) - -**Entry point:** `src/skill_seekers/cli/main.py` - -The unified CLI modifies `sys.argv` and calls existing `main()` functions to maintain backward compatibility: - -```python -# Example: skill-seekers scrape --config react.json -# Transforms to: doc_scraper.main() with modified sys.argv -``` - -**Subcommands:** create, scrape, github, pdf, epub, unified, codebase, enhance, enhance-status, package, upload, estimate, install, install-agent, patterns, how-to-guides - -### NEW: Unified `create` Command - -**The recommended way to create skills** - Auto-detects source type and provides progressive help disclosure: - -```bash -# Auto-detection examples -skill-seekers create https://docs.react.dev/ # → Web scraping -skill-seekers create facebook/react # → GitHub analysis -skill-seekers create ./my-project # → Local codebase -skill-seekers create tutorial.pdf # → PDF extraction -skill-seekers create book.epub # → EPUB extraction -skill-seekers create configs/react.json # → Multi-source - -# Progressive help system -skill-seekers create --help # Shows universal args only (13 flags) -skill-seekers create --help-web # Shows web-specific options -skill-seekers create --help-github # Shows GitHub-specific options -skill-seekers create --help-local # Shows local analysis options -skill-seekers create --help-pdf # Shows PDF extraction options -skill-seekers create --help-epub # Shows EPUB extraction options -skill-seekers create --help-advanced # Shows advanced/rare options -skill-seekers create --help-all # Shows all 120+ flags - -# Universal flags work for ALL sources -skill-seekers create -p quick # Preset (-p shortcut) -skill-seekers create --enhance-level 2 # AI enhancement (0-3) -skill-seekers create --chunk-for-rag # RAG chunking -skill-seekers create --dry-run # Preview -``` - -**Key improvements:** -- **Single command** replaces scrape/github/analyze for most use cases -- **Smart detection** - No need to specify source type -- **Progressive disclosure** - Default help shows 13 flags, detailed help available -- **-p shortcut** - Quick preset selection (`-p quick|standard|comprehensive`) -- **Universal features** - RAG chunking, dry-run, presets work everywhere - -**Recent Additions:** -- `create` - **NEW:** Unified command with auto-detection and progressive help -- `codebase` - Local codebase analysis without GitHub API (C2.x + C3.x features) -- `enhance-status` - Monitor background/daemon enhancement processes -- `patterns` - Detect design patterns in code (C3.1) -- `how-to-guides` - Generate educational guides from tests (C3.3) - -### Platform Adaptor Usage - -```python -from skill_seekers.cli.adaptors import get_adaptor - -# Get platform-specific adaptor -adaptor = get_adaptor('gemini') # or 'claude', 'openai', 'markdown' - -# Package skill -adaptor.package(skill_dir='output/react/', output_path='output/') - -# Upload to platform -adaptor.upload( - package_path='output/react-gemini.tar.gz', - api_key=os.getenv('GOOGLE_API_KEY') -) - -# AI enhancement -adaptor.enhance(skill_dir='output/react/', mode='api') -``` - -### C3.x Codebase Analysis Features - -The project has comprehensive codebase analysis capabilities (C3.1-C3.8): - -**C3.1 Design Pattern Detection** (`pattern_recognizer.py`): -- Detects 10 common patterns: Singleton, Factory, Observer, Strategy, Decorator, Builder, Adapter, Command, Template Method, Chain of Responsibility -- Supports 9 languages: Python, JavaScript, TypeScript, C++, C, C#, Go, Rust, Java -- Three detection levels: surface (fast), deep (balanced), full (thorough) -- 87% precision, 80% recall on real-world projects - -**C3.2 Test Example Extraction** (`test_example_extractor.py`): -- Extracts real usage examples from test files -- Categories: instantiation, method_call, config, setup, workflow -- AST-based for Python, regex-based for 8 other languages -- Quality filtering with confidence scoring - -**C3.3 How-To Guide Generation** (`how_to_guide_builder.py`): -- Transforms test workflows into educational guides -- 5 AI enhancements: step descriptions, troubleshooting, prerequisites, next steps, use cases -- Dual-mode AI: API (fast) or LOCAL (free with Claude Code Max) -- 4 grouping strategies: AI tutorial group, file path, test name, complexity - -**C3.4 Configuration Pattern Extraction** (`config_extractor.py`): -- Extracts configuration patterns from codebases -- Identifies config files, env vars, CLI arguments -- AI enhancement for better organization - -**C3.5 Architectural Overview** (`generate_router.py`): -- Generates comprehensive ARCHITECTURE.md files -- Router skill generation for large documentation -- Quality improvements: 6.5/10 → 8.5/10 (+31%) -- Integrates GitHub metadata, issues, labels - -**C3.6 AI Enhancement** (Claude API integration): -- Enhances C3.1-C3.5 with AI-powered insights -- Pattern explanations and improvement suggestions -- Test example context and best practices -- Guide enhancement with troubleshooting and prerequisites - -**C3.7 Architectural Pattern Detection** (`architectural_pattern_detector.py`): -- Detects 8 architectural patterns (MVC, MVVM, MVP, Repository, etc.) -- Framework detection (Django, Flask, Spring, React, Angular, etc.) -- Multi-file analysis with directory structure patterns -- Evidence-based detection with confidence scoring - -**C3.8 Standalone Codebase Scraper** (`codebase_scraper.py`): -```bash -# Quick analysis (1-2 min, basic features only) -skill-seekers analyze --directory /path/to/repo --quick - -# Comprehensive analysis (20-60 min, all features + AI) -skill-seekers analyze --directory . --comprehensive - -# With AI enhancement (auto-detects API or LOCAL) -skill-seekers analyze --directory . --enhance - -# Granular AI enhancement control (NEW) -skill-seekers analyze --directory . --enhance-level 1 # SKILL.md only -skill-seekers analyze --directory . --enhance-level 2 # + Architecture + Config + Docs -skill-seekers analyze --directory . --enhance-level 3 # Full enhancement (all features) - -# Disable specific features -skill-seekers analyze --directory . --skip-patterns --skip-how-to-guides -``` - -- Generates 300+ line standalone SKILL.md files from codebases -- All C3.x features integrated (patterns, tests, guides, config, architecture, docs) -- Complete codebase analysis without documentation scraping -- **NEW**: Granular AI enhancement control with `--enhance-level` (0-3) - -**C3.9 Project Documentation Extraction** (`codebase_scraper.py`): -- Extracts and categorizes all markdown files from the project -- Auto-detects categories: overview, architecture, guides, workflows, features, etc. -- Integrates documentation into SKILL.md with summaries -- AI enhancement (level 2+) adds topic extraction and cross-references -- Controlled by depth: surface=raw copy, deep=parse+summarize, full=AI-enhanced -- Default ON, use `--skip-docs` to disable - -**C3.10 Signal Flow Analysis for Godot Projects** (`signal_flow_analyzer.py`): -- Complete signal flow analysis system for event-driven Godot architectures -- Signal declaration extraction (detects `signal` keyword declarations) -- Connection mapping (tracks `.connect()` calls with targets and methods) -- Emission tracking (finds `.emit()` and `emit_signal()` calls) -- Real-world metrics: 208 signals, 634 connections, 298 emissions in test project -- Signal density metrics (signals per file) -- Event chain detection (signals triggering other signals) -- Signal pattern detection: - - **EventBus Pattern** (0.90 confidence): Centralized signal hub in autoload - - **Observer Pattern** (0.85 confidence): Multi-observer signals (3+ listeners) - - **Event Chains** (0.80 confidence): Cascading signal propagation -- Signal-based how-to guides (C3.10.1): - - AI-generated step-by-step usage guides (Connect → Emit → Handle) - - Real code examples from project - - Common usage locations - - Parameter documentation -- Outputs: `signal_flow.json`, `signal_flow.mmd` (Mermaid diagram), `signal_reference.md`, `signal_how_to_guides.md` -- Comprehensive Godot 4.x support: - - GDScript (.gd), Scene files (.tscn), Resources (.tres), Shaders (.gdshader) - - GDScript test extraction (GUT, gdUnit4, WAT frameworks) - - 396 test cases extracted in test project - - Framework detection (Unity, Unreal, Godot) - -**Key Architecture Decision (BREAKING in v2.5.2):** -- Changed from opt-in (`--build-*`) to opt-out (`--skip-*`) flags -- All analysis features now ON by default for maximum value -- Backward compatibility warnings for deprecated flags - -### Smart Categorization Algorithm - -Located in `doc_scraper.py:smart_categorize()`: -- Scores pages against category keywords -- 3 points for URL match, 2 for title, 1 for content -- Threshold of 2+ for categorization -- Auto-infers categories from URL segments if none provided -- Falls back to "other" category - -### Language Detection - -Located in `doc_scraper.py:detect_language()`: -1. CSS class attributes (`language-*`, `lang-*`) -2. Heuristics (keywords like `def`, `const`, `func`) - -### Configuration File Structure - -Configs (`configs/*.json`) define scraping behavior: - -```json -{ - "name": "framework-name", - "description": "When to use this skill", - "base_url": "https://docs.example.com/", - "selectors": { - "main_content": "article", // CSS selector - "title": "h1", - "code_blocks": "pre code" - }, - "url_patterns": { - "include": ["/docs"], - "exclude": ["/blog"] - }, - "categories": { - "getting_started": ["intro", "quickstart"], - "api": ["api", "reference"] - }, - "rate_limit": 0.5, - "max_pages": 500 -} -``` - -## 🧪 Testing Guidelines - -### Test Coverage Requirements - -- Core features: 100% coverage required -- Platform adaptors: Each platform has dedicated tests -- MCP tools: All 18 tools must be tested -- Integration tests: End-to-end workflows - -### Test Markers (from pytest.ini_options) - -The project uses pytest markers to categorize tests: - -```bash -# Run only fast unit tests (default) -pytest tests/ -v - -# Include slow tests (>5 seconds) -pytest tests/ -v -m slow - -# Run integration tests (requires external services) -pytest tests/ -v -m integration - -# Run end-to-end tests (resource-intensive, creates files) -pytest tests/ -v -m e2e - -# Run tests requiring virtual environment setup -pytest tests/ -v -m venv - -# Run bootstrap feature tests -pytest tests/ -v -m bootstrap - -# Skip slow and integration tests (fastest) -pytest tests/ -v -m "not slow and not integration" -``` - -### Test Execution Strategy - -**By default, only fast tests run**. Use markers to control test execution: - -```bash -# Default: Only fast tests (skip slow/integration/e2e) -pytest tests/ -v - -# Include slow tests (>5 seconds) -pytest tests/ -v -m slow - -# Include integration tests (requires external services) -pytest tests/ -v -m integration - -# Include resource-intensive e2e tests (creates files) -pytest tests/ -v -m e2e - -# Run ONLY fast tests (explicit) -pytest tests/ -v -m "not slow and not integration and not e2e" - -# Run everything (CI does this) -pytest tests/ -v -m "" -``` - -**When to use which:** -- **Local development:** Default (fast tests only) - `pytest tests/ -v` -- **Pre-commit:** Fast tests - `pytest tests/ -v` -- **Before PR:** Include slow + integration - `pytest tests/ -v -m "not e2e"` -- **CI validation:** All tests run automatically - -### Key Test Files - -- `test_scraper_features.py` - Core scraping functionality -- `test_mcp_server.py` - MCP integration (18 tools) -- `test_mcp_fastmcp.py` - FastMCP framework -- `test_unified.py` - Multi-source scraping -- `test_github_scraper.py` - GitHub analysis -- `test_pdf_scraper.py` - PDF extraction -- `test_epub_scraper.py` - EPUB extraction -- `test_install_multiplatform.py` - Multi-platform packaging -- `test_integration.py` - End-to-end workflows -- `test_install_skill.py` - One-command install -- `test_install_agent.py` - AI agent installation -- `conftest.py` - Test configuration (checks package installation) - -## 🌐 Environment Variables - -```bash -# Claude AI / Compatible APIs -# Option 1: Official Anthropic API (default) -export ANTHROPIC_API_KEY=sk-ant-... - -# Option 2: GLM-4.7 Claude-compatible API (or any compatible endpoint) -export ANTHROPIC_API_KEY=your-api-key -export ANTHROPIC_BASE_URL=https://glm-4-7-endpoint.com/v1 - -# Google Gemini (optional) -export GOOGLE_API_KEY=AIza... - -# OpenAI ChatGPT (optional) -export OPENAI_API_KEY=sk-... - -# GitHub (for higher rate limits) -export GITHUB_TOKEN=ghp_... - -# Private config repositories (optional) -export GITLAB_TOKEN=glpat-... -export GITEA_TOKEN=... -export BITBUCKET_TOKEN=... -``` - -**All AI enhancement features respect these settings**: -- `enhance_skill.py` - API mode SKILL.md enhancement -- `ai_enhancer.py` - C3.1/C3.2 pattern and test example enhancement -- `guide_enhancer.py` - C3.3 guide enhancement -- `config_enhancer.py` - C3.4 configuration enhancement -- `adaptors/claude.py` - Claude platform adaptor enhancement - -**Note**: Setting `ANTHROPIC_BASE_URL` allows you to use any Claude-compatible API endpoint, such as GLM-4.7 (智谱 AI). - -## 📦 Package Structure (pyproject.toml) - -### Entry Points - -```toml -[project.scripts] -# Main unified CLI -skill-seekers = "skill_seekers.cli.main:main" - -# Individual tool entry points (Core) -skill-seekers-config = "skill_seekers.cli.config_command:main" # v2.7.0 Configuration wizard -skill-seekers-resume = "skill_seekers.cli.resume_command:main" # v2.7.0 Resume interrupted jobs -skill-seekers-scrape = "skill_seekers.cli.doc_scraper:main" -skill-seekers-github = "skill_seekers.cli.github_scraper:main" -skill-seekers-pdf = "skill_seekers.cli.pdf_scraper:main" -skill-seekers-epub = "skill_seekers.cli.epub_scraper:main" -skill-seekers-unified = "skill_seekers.cli.unified_scraper:main" -skill-seekers-codebase = "skill_seekers.cli.codebase_scraper:main" # C2.x Local codebase analysis -skill-seekers-enhance = "skill_seekers.cli.enhance_skill_local:main" -skill-seekers-enhance-status = "skill_seekers.cli.enhance_status:main" # Status monitoring -skill-seekers-package = "skill_seekers.cli.package_skill:main" -skill-seekers-upload = "skill_seekers.cli.upload_skill:main" -skill-seekers-estimate = "skill_seekers.cli.estimate_pages:main" -skill-seekers-install = "skill_seekers.cli.install_skill:main" -skill-seekers-install-agent = "skill_seekers.cli.install_agent:main" -skill-seekers-patterns = "skill_seekers.cli.pattern_recognizer:main" # C3.1 Pattern detection -skill-seekers-how-to-guides = "skill_seekers.cli.how_to_guide_builder:main" # C3.3 Guide generation -skill-seekers-workflows = "skill_seekers.cli.workflows_command:main" # NEW: Workflow preset management -skill-seekers-video = "skill_seekers.cli.video_scraper:main" # Video scraping pipeline (use --setup to install deps) - -# New v3.0.0 Entry Points -skill-seekers-setup = "skill_seekers.cli.setup_wizard:main" # NEW: v3.0.0 Setup wizard -skill-seekers-cloud = "skill_seekers.cli.cloud_storage_cli:main" # NEW: v3.0.0 Cloud storage -skill-seekers-embed = "skill_seekers.embedding.server:main" # NEW: v3.0.0 Embedding server -skill-seekers-sync = "skill_seekers.cli.sync_cli:main" # NEW: v3.0.0 Sync & monitoring -skill-seekers-benchmark = "skill_seekers.cli.benchmark_cli:main" # NEW: v3.0.0 Benchmarking -skill-seekers-stream = "skill_seekers.cli.streaming_ingest:main" # NEW: v3.0.0 Streaming ingest -skill-seekers-update = "skill_seekers.cli.incremental_updater:main" # NEW: v3.0.0 Incremental updates -skill-seekers-multilang = "skill_seekers.cli.multilang_support:main" # NEW: v3.0.0 Multilingual -skill-seekers-quality = "skill_seekers.cli.quality_metrics:main" # NEW: v3.0.0 Quality metrics -``` - -### Optional Dependencies - -**Project uses PEP 735 `[dependency-groups]` (Python 3.13+)**: -- Replaces deprecated `tool.uv.dev-dependencies` -- Dev dependencies: `[dependency-groups] dev = [...]` in pyproject.toml -- Install with: `pip install -e .` (installs only core deps) -- Install dev deps: See CI workflow or manually install pytest, ruff, mypy - -**Note on video dependencies:** `easyocr` and GPU-specific PyTorch builds are **not** included in the `video-full` optional dependency group. They are installed at runtime by `skill-seekers video --setup`, which auto-detects the GPU (CUDA/ROCm/MPS/CPU) and installs the correct builds. - -```toml -[project.optional-dependencies] -gemini = ["google-generativeai>=0.8.0"] -openai = ["openai>=1.0.0"] -all-llms = ["google-generativeai>=0.8.0", "openai>=1.0.0"] - -[dependency-groups] # PEP 735 (replaces tool.uv.dev-dependencies) -dev = [ - "pytest>=8.4.2", - "pytest-asyncio>=0.24.0", - "pytest-cov>=7.0.0", - "coverage>=7.11.0", -] -``` - -## 🚨 Critical Development Notes - -### Must Run Before Tests - -```bash -# REQUIRED: Install package before running tests -pip install -e . - -# Why: src/ layout requires package installation -# Without this, imports will fail -``` - -### Never Skip Tests - -Per user instructions in `~/.claude/CLAUDE.md`: -- "never skip any test. always make sure all test pass" -- All 2,540 tests must pass before commits -- Run full test suite: `pytest tests/ -v` -- New tests added for create command and CLI refactor work - -### Platform-Specific Dependencies - -Platform dependencies are optional (install only what you need): - -```bash -# Install specific platform support -pip install -e ".[gemini]" # Google Gemini -pip install -e ".[openai]" # OpenAI ChatGPT -pip install -e ".[chroma]" # ChromaDB -pip install -e ".[weaviate]" # Weaviate -pip install -e ".[s3]" # AWS S3 -pip install -e ".[gcs]" # Google Cloud Storage -pip install -e ".[azure]" # Azure Blob Storage -pip install -e ".[mcp]" # MCP integration -pip install -e ".[all]" # Everything (16 platforms + cloud + embedding) - -# Or install from PyPI: -pip install skill-seekers[gemini] # Google Gemini support -pip install skill-seekers[openai] # OpenAI ChatGPT support -pip install skill-seekers[all-llms] # All LLM platforms -pip install skill-seekers[chroma] # ChromaDB support -pip install skill-seekers[weaviate] # Weaviate support -pip install skill-seekers[s3] # AWS S3 support -pip install skill-seekers[all] # All optional dependencies -``` - -### AI Enhancement Modes - -AI enhancement transforms basic skills (2-3/10) into production-ready skills (8-9/10). Two modes available: - -**API Mode** (default if ANTHROPIC_API_KEY is set): -- Direct Claude API calls (fast, efficient) -- Cost: ~$0.15-$0.30 per skill -- Perfect for CI/CD automation -- Requires: `export ANTHROPIC_API_KEY=sk-ant-...` - -**LOCAL Mode** (fallback if no API key): -- Uses Claude Code CLI (your existing Max plan) -- Free! No API charges -- 4 execution modes: - - Headless (default): Foreground, waits for completion - - Background (`--background`): Returns immediately - - Daemon (`--daemon`): Fully detached with nohup - - Terminal (`--interactive-enhancement`): Opens new terminal (macOS) -- Status monitoring: `skill-seekers enhance-status output/react/ --watch` -- Timeout configuration: `--timeout 300` (seconds) - -### Enhancement Flag Consolidation (Phase 1) - -**IMPORTANT CHANGE:** Three enhancement flags have been unified into a single granular control: - -**Old flags (deprecated):** -- `--enhance` - Enable AI enhancement -- `--enhance-local` - Use LOCAL mode (Claude Code) -- `--api-key KEY` - Anthropic API key - -**New unified flag:** -- `--enhance-level LEVEL` - Granular AI enhancement control (0-3, default: 2) - - `0` - Disabled, no AI enhancement - - `1` - SKILL.md only (core documentation) - - `2` - + Architecture + Config + Docs (default, balanced) - - `3` - Full enhancement (all features, comprehensive) - -**Auto-detection:** Mode (API vs LOCAL) is auto-detected: -- If `ANTHROPIC_API_KEY` is set → API mode -- Otherwise → LOCAL mode (Claude Code Max) - -**Examples:** -```bash -# Auto-detect mode, default enhancement level (2) -skill-seekers create https://docs.react.dev/ - -# Disable enhancement -skill-seekers create facebook/react --enhance-level 0 - -# SKILL.md only (fast) -skill-seekers create ./my-project --enhance-level 1 - -# Full enhancement (comprehensive) -skill-seekers create tutorial.pdf --enhance-level 3 - -# Force LOCAL mode with specific level -skill-seekers enhance output/react/ --mode LOCAL --enhance-level 2 - -# Background with status monitoring -skill-seekers enhance output/react/ --background -skill-seekers enhance-status output/react/ --watch -``` - -**Migration:** Old flags still work with deprecation warnings, will be removed in v4.0.0. - -See `docs/ENHANCEMENT_MODES.md` for detailed documentation. - -### Git Workflow - -**Git Workflow Notes:** -- Main branch: `main` -- Development branch: `development` -- Always create feature branches from `development` -- Branch naming: `feature/{task-id}-{description}` or `feature/{category}` - -**To see current status:** `git status` - -### CI/CD Pipeline - -The project has GitHub Actions workflows in `.github/workflows/`: - -**tests.yml** - Runs on every push and PR to `main` or `development`: - -1. **Lint Job** (Python 3.12, Ubuntu): - - `ruff check src/ tests/` - Code linting with GitHub annotations - - `ruff format --check src/ tests/` - Format validation - - `mypy src/skill_seekers` - Type checking (continue-on-error) - -2. **Test Job** (Matrix): - - **OS:** Ubuntu + macOS - - **Python:** 3.10, 3.11, 3.12 - - **Exclusions:** macOS + Python 3.10 (speed optimization) - - **Steps:** - - Install dependencies + `pip install -e .` - - Run CLI tests (scraper, config, integration) - - Run MCP server tests - - Generate coverage report → Upload to Codecov - -3. **Summary Job** - Single status check for branch protection - - Ensures both lint and test jobs succeed - - Provides single "All Checks Complete" status - -**release.yml** - Triggers on version tags (e.g., `v2.9.0`): -- Builds package with `uv build` -- Publishes to PyPI with `uv publish` -- Creates GitHub release - -**Local Pre-Commit Validation** - -Run the same checks as CI before pushing: - -```bash -# 1. Code quality (matches lint job) - WITH AUTO-FIX -uvx ruff check --fix --unsafe-fixes src/ tests/ # Auto-fix issues -uvx ruff format src/ tests/ # Auto-format -uvx ruff check src/ tests/ # Verify clean -uvx ruff format --check src/ tests/ # Verify formatted -mypy src/skill_seekers - -# 2. Tests (matches test job) -pip install -e . -pytest tests/ -v --cov=src/skill_seekers --cov-report=term - -# 3. If all pass, you're good to push! -git add -A # Stage any auto-fixes -git commit --amend --no-edit # Add fixes to commit (or new commit) -git push origin feature/my-feature -``` - -**Branch Protection Rules:** -- **main:** Requires tests + 1 review, only maintainers merge -- **development:** Requires tests to pass, default target for PRs - -**Common CI Failure Patterns and Fixes** - -If CI fails after your changes, follow this debugging checklist: - -```bash -# 1. Fix linting errors automatically -uvx ruff check --fix --unsafe-fixes src/ tests/ - -# 2. Fix formatting issues -uvx ruff format src/ tests/ - -# 3. Check for remaining issues -uvx ruff check src/ tests/ -uvx ruff format --check src/ tests/ - -# 4. Verify tests pass locally -pip install -e . -pytest tests/ -v - -# 5. Push fixes -git add -A -git commit -m "fix: resolve CI linting/formatting issues" -git push -``` - -**Critical dependency patterns to check:** -- **MCP version mismatch**: Ensure `requirements.txt` and `pyproject.toml` have matching MCP versions -- **Missing module-level imports**: If a tool file imports a module at top level (e.g., `import yaml`), that module MUST be in core dependencies -- **Try/except ImportError**: Silent failures in try/except blocks can hide missing dependencies - -**Timing-sensitive tests:** -- Benchmark tests may fail on slower CI runners (macOS) -- If a test times out or exceeds threshold only in CI, consider relaxing the threshold -- Local passing doesn't guarantee CI passing for performance tests - -## 🚨 Common Pitfalls & Solutions - -### 1. Import Errors -**Problem:** `ModuleNotFoundError: No module named 'skill_seekers'` - -**Solution:** Must install package first due to src/ layout -```bash -pip install -e . -``` - -**Why:** The src/ layout prevents imports from repo root. Package must be installed. - -### 2. Tests Fail with "No module named..." -**Problem:** Package not installed in test environment - -**Solution:** CI runs `pip install -e .` before tests - do the same locally -```bash -pip install -e . -pytest tests/ -v -``` - -### 3. Platform-Specific Dependencies Not Found -**Problem:** `ModuleNotFoundError: No module named 'google.generativeai'` - -**Solution:** Install platform-specific dependencies -```bash -pip install -e ".[gemini]" # For Gemini -pip install -e ".[openai]" # For OpenAI -pip install -e ".[all-llms]" # For all platforms -``` - -### 4. Git Branch Confusion -**Problem:** PR targets `main` instead of `development` - -**Solution:** Always create PRs targeting `development` branch -```bash -git checkout development -git pull upstream development -git checkout -b feature/my-feature -# ... make changes ... -git push origin feature/my-feature -# Create PR: feature/my-feature → development -``` - -**Important:** See `CONTRIBUTING.md` for complete branch workflow. - -### 5. Tests Pass Locally But Fail in CI -**Problem:** Different Python version or missing dependency - -**Solution:** Test with multiple Python versions locally -```bash -# CI tests: Python 3.10, 3.11, 3.12 on Ubuntu + macOS -# Use pyenv or docker to test locally: -pyenv install 3.10.13 3.11.7 3.12.1 - -pyenv local 3.10.13 -pip install -e . && pytest tests/ -v - -pyenv local 3.11.7 -pip install -e . && pytest tests/ -v - -pyenv local 3.12.1 -pip install -e . && pytest tests/ -v -``` - -### 6. Enhancement Not Working -**Problem:** AI enhancement fails or hangs - -**Solutions:** -```bash -# Check if API key is set -echo $ANTHROPIC_API_KEY - -# Try LOCAL mode instead (uses Claude Code Max, no API key needed) -skill-seekers enhance output/react/ --mode LOCAL - -# Monitor enhancement status for background jobs -skill-seekers enhance-status output/react/ --watch -``` - -### 7. Rate Limit Errors from GitHub -**Problem:** `403 Forbidden` from GitHub API - -**Solutions:** -```bash -# Check current rate limit -curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/rate_limit - -# Configure multiple GitHub profiles (recommended) -skill-seekers config --github - -# Use specific profile -skill-seekers github --repo owner/repo --profile work - -# Test all configured tokens -skill-seekers config --test -``` - -### 8. Confused About Command Options -**Problem:** "Too many flags!" or "Which flags work with which sources?" - -**Solution:** Use the progressive disclosure help system in the `create` command: -```bash -# Start with universal options (13 flags) -skill-seekers create --help - -# Need web scraping options? -skill-seekers create --help-web - -# GitHub-specific flags? -skill-seekers create --help-github - -# See ALL options (120+ flags)? -skill-seekers create --help-all - -# Quick preset shortcut -skill-seekers create -p quick -skill-seekers create -p standard -skill-seekers create -p comprehensive -``` - -**Why:** The create command shows only relevant flags by default to reduce cognitive load. - -**Legacy commands** (scrape, github, analyze) show all flags in one help screen - use them if you prefer that style. - -### 9. CI Passes Locally But Fails in GitHub Actions -**Problem:** Ruff check/format or tests pass locally but fail in CI - -**Common causes:** -1. **Dependency version mismatch** - `requirements.txt` vs `pyproject.toml` conflicts - ```bash - # Check both files have matching versions for core deps - grep "mcp" requirements.txt pyproject.toml - grep "PyYAML" requirements.txt pyproject.toml - ``` - -2. **Module imported but not declared** - File imports module at top level but it's not in dependencies - ```bash - # Search for imports that might not be in dependencies - grep -r "^import yaml" src/ - grep -r "^from yaml" src/ - # Ensure PyYAML is in pyproject.toml core dependencies - ``` - -3. **Ruff version differences** - Local ruff vs CI ruff may have different rules - ```bash - # Use uvx to match CI's ruff version - uvx ruff check src/ tests/ - uvx ruff format src/ tests/ - ``` - -**Solution:** -```bash -# Run CI validation commands exactly as CI does -pip install -e . # Fresh install -uvx ruff check src/ tests/ # Use uvx, not local ruff -uvx ruff format --check src/ tests/ -pytest tests/ -v -``` - -## 🔌 MCP Integration - -### MCP Server (26 Tools) - -**Transport modes:** -- stdio: Claude Code, VS Code + Cline -- HTTP: Cursor, Windsurf, IntelliJ IDEA - -**Core Tools (9):** -1. `list_configs` - List preset configurations -2. `generate_config` - Generate config from docs URL -3. `validate_config` - Validate config structure -4. `estimate_pages` - Estimate page count -5. `scrape_docs` - Scrape documentation -6. `package_skill` - Package to format (supports `--format` and `--target`) -7. `upload_skill` - Upload to platform (supports `--target`) -8. `enhance_skill` - AI enhancement with platform support -9. `install_skill` - Complete workflow automation - -**Extended Tools (10):** -10. `scrape_github` - GitHub repository analysis -11. `scrape_pdf` - PDF extraction -12. `unified_scrape` - Multi-source scraping -13. `merge_sources` - Merge docs + code -14. `detect_conflicts` - Find discrepancies -15. `add_config_source` - Register git repos -16. `fetch_config` - Fetch configs from git -17. `list_config_sources` - List registered sources -18. `remove_config_source` - Remove config source -19. `split_config` - Split large configs - -**NEW Vector DB Tools (4):** -20. `export_to_chroma` - Export to ChromaDB -21. `export_to_weaviate` - Export to Weaviate -22. `export_to_faiss` - Export to FAISS -23. `export_to_qdrant` - Export to Qdrant - -**NEW Cloud Tools (3):** -24. `cloud_upload` - Upload to S3/GCS/Azure -25. `cloud_download` - Download from cloud storage -26. `cloud_list` - List files in cloud storage - -### Starting MCP Server - -```bash -# stdio mode (Claude Code, VS Code + Cline) -python -m skill_seekers.mcp.server_fastmcp - -# HTTP mode (Cursor, Windsurf, IntelliJ) -python -m skill_seekers.mcp.server_fastmcp --transport http --port 8765 -``` - -## 🤖 RAG Framework & Vector Database Integrations (**NEW - v3.0.0**) - -Skill Seekers is now the **universal preprocessor for RAG pipelines**. Export documentation to any RAG framework or vector database with a single command. - -### RAG Frameworks - -**LangChain Documents:** -```bash -# Export to LangChain Document format -skill-seekers package output/django --format langchain - -# Output: output/django-langchain.json -# Format: Array of LangChain Document objects -# - page_content: Full text content -# - metadata: {source, category, type, url} - -# Use in LangChain: -from langchain.document_loaders import JSONLoader -loader = JSONLoader("output/django-langchain.json") -documents = loader.load() -``` - -**LlamaIndex TextNodes:** -```bash -# Export to LlamaIndex TextNode format -skill-seekers package output/django --format llama-index - -# Output: output/django-llama-index.json -# Format: Array of LlamaIndex TextNode objects -# - text: Content -# - id_: Unique identifier -# - metadata: {source, category, type} -# - relationships: Document relationships - -# Use in LlamaIndex: -from llama_index import StorageContext, load_index_from_storage -from llama_index.schema import TextNode -nodes = [TextNode.from_dict(n) for n in json.load(open("output/django-llama-index.json"))] -``` - -**Haystack Documents:** -```bash -# Export to Haystack Document format -skill-seekers package output/django --format haystack - -# Output: output/django-haystack.json -# Format: Haystack Document objects for pipelines -# Perfect for: Question answering, search, RAG pipelines -``` - -### Vector Databases - -**ChromaDB (Direct Integration):** -```bash -# Export and optionally upload to ChromaDB -skill-seekers package output/django --format chroma - -# Output: output/django-chroma/ (ChromaDB collection) -# With direct upload (requires chromadb running): -skill-seekers package output/django --format chroma --upload - -# Configuration via environment: -export CHROMA_HOST=localhost -export CHROMA_PORT=8000 -``` - -**FAISS (Facebook AI Similarity Search):** -```bash -# Export to FAISS index format -skill-seekers package output/django --format faiss - -# Output: -# - output/django-faiss.index (FAISS index) -# - output/django-faiss-metadata.json (Document metadata) - -# Use with FAISS: -import faiss -index = faiss.read_index("output/django-faiss.index") -``` - -**Weaviate:** -```bash -# Export and upload to Weaviate -skill-seekers package output/django --format weaviate --upload - -# Requires environment variables: -export WEAVIATE_URL=http://localhost:8080 -export WEAVIATE_API_KEY=your-api-key - -# Creates class "DjangoDoc" with schema -``` - -**Qdrant:** -```bash -# Export and upload to Qdrant -skill-seekers package output/django --format qdrant --upload - -# Requires environment variables: -export QDRANT_URL=http://localhost:6333 -export QDRANT_API_KEY=your-api-key - -# Creates collection "django_docs" -``` - -**Pinecone (via Markdown):** -```bash -# Pinecone uses the markdown format -skill-seekers package output/django --target markdown - -# Then use Pinecone's Python client for upsert -# See: docs/integrations/PINECONE.md -``` - -### Complete RAG Pipeline Example - -```bash -# 1. Scrape documentation -skill-seekers scrape --config configs/django.json - -# 2. Export to your RAG stack -skill-seekers package output/django --format langchain # For LangChain -skill-seekers package output/django --format llama-index # For LlamaIndex -skill-seekers package output/django --format chroma --upload # Direct to ChromaDB - -# 3. Use in your application -# See examples/: -# - examples/langchain-rag-pipeline/ -# - examples/llama-index-query-engine/ -# - examples/pinecone-upsert/ -``` - -**Integration Hub:** [docs/integrations/RAG_PIPELINES.md](docs/integrations/RAG_PIPELINES.md) - -## 🛠️ AI Coding Assistant Integrations (**NEW - v3.0.0**) - -Transform any framework documentation into persistent expert context for 4+ AI coding assistants. Your IDE's AI now "knows" your frameworks without manual prompting. - -### Cursor IDE - -**Setup:** -```bash -# 1. Generate skill -skill-seekers scrape --config configs/react.json -skill-seekers package output/react/ --target claude - -# 2. Install to Cursor -cp output/react-claude/SKILL.md .cursorrules - -# 3. Restart Cursor -# AI now has React expertise! -``` - -**Benefits:** -- ✅ AI suggests React-specific patterns -- ✅ No manual "use React hooks" prompts needed -- ✅ Consistent team patterns -- ✅ Works for ANY framework - -**Guide:** [docs/integrations/CURSOR.md](docs/integrations/CURSOR.md) -**Example:** [examples/cursor-react-skill/](examples/cursor-react-skill/) - -### Windsurf - -**Setup:** -```bash -# 1. Generate skill -skill-seekers scrape --config configs/django.json -skill-seekers package output/django/ --target claude - -# 2. Install to Windsurf -mkdir -p .windsurf/rules -cp output/django-claude/SKILL.md .windsurf/rules/django.md - -# 3. Restart Windsurf -# AI now knows Django patterns! -``` - -**Benefits:** -- ✅ Flow-based coding with framework knowledge -- ✅ IDE-native AI assistance -- ✅ Persistent context across sessions - -**Guide:** [docs/integrations/WINDSURF.md](docs/integrations/WINDSURF.md) -**Example:** [examples/windsurf-fastapi-context/](examples/windsurf-fastapi-context/) - -### Cline (VS Code Extension) - -**Setup:** -```bash -# 1. Generate skill -skill-seekers scrape --config configs/fastapi.json -skill-seekers package output/fastapi/ --target claude - -# 2. Install to Cline -cp output/fastapi-claude/SKILL.md .clinerules - -# 3. Reload VS Code -# Cline now has FastAPI expertise! -``` - -**Benefits:** -- ✅ Agentic code generation in VS Code -- ✅ Cursor Composer equivalent for VS Code -- ✅ System prompts + MCP integration - -**Guide:** [docs/integrations/CLINE.md](docs/integrations/CLINE.md) -**Example:** [examples/cline-django-assistant/](examples/cline-django-assistant/) - -### Continue.dev (Universal IDE) - -**Setup:** -```bash -# 1. Generate skill -skill-seekers scrape --config configs/react.json -skill-seekers package output/react/ --target claude - -# 2. Start context server -cd examples/continue-dev-universal/ -python context_server.py --port 8765 - -# 3. Configure in ~/.continue/config.json -{ - "contextProviders": [ - { - "name": "http", - "params": { - "url": "http://localhost:8765/context", - "title": "React Documentation" - } - } - ] -} - -# 4. Works in ALL IDEs! -# VS Code, JetBrains, Vim, Emacs... -``` - -**Benefits:** -- ✅ IDE-agnostic (works in VS Code, IntelliJ, Vim, Emacs) -- ✅ Custom LLM providers supported -- ✅ HTTP-based context serving -- ✅ Team consistency across mixed IDE environments - -**Guide:** [docs/integrations/CONTINUE_DEV.md](docs/integrations/CONTINUE_DEV.md) -**Example:** [examples/continue-dev-universal/](examples/continue-dev-universal/) - -### Multi-IDE Team Setup - -For teams using different IDEs (VS Code, IntelliJ, Vim): - -```bash -# Use Continue.dev as universal context provider -skill-seekers scrape --config configs/react.json -python context_server.py --host 0.0.0.0 --port 8765 - -# ALL team members configure Continue.dev -# Result: Identical AI suggestions across all IDEs! -``` - -**Integration Hub:** [docs/integrations/INTEGRATIONS.md](docs/integrations/INTEGRATIONS.md) - -## ☁️ Cloud Storage Integration (**NEW - v3.0.0**) - -Upload skills directly to cloud storage for team sharing and CI/CD pipelines. - -### Supported Providers - -**AWS S3:** -```bash -# Upload skill -skill-seekers cloud upload --provider s3 --bucket my-skills output/react.zip - -# Download skill -skill-seekers cloud download --provider s3 --bucket my-skills react.zip - -# List skills -skill-seekers cloud list --provider s3 --bucket my-skills - -# Environment variables: -export AWS_ACCESS_KEY_ID=your-key -export AWS_SECRET_ACCESS_KEY=your-secret -export AWS_REGION=us-east-1 -``` - -**Google Cloud Storage:** -```bash -# Upload skill -skill-seekers cloud upload --provider gcs --bucket my-skills output/react.zip - -# Download skill -skill-seekers cloud download --provider gcs --bucket my-skills react.zip - -# List skills -skill-seekers cloud list --provider gcs --bucket my-skills - -# Environment variables: -export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json -``` - -**Azure Blob Storage:** -```bash -# Upload skill -skill-seekers cloud upload --provider azure --container my-skills output/react.zip - -# Download skill -skill-seekers cloud download --provider azure --container my-skills react.zip - -# List skills -skill-seekers cloud list --provider azure --container my-skills - -# Environment variables: -export AZURE_STORAGE_CONNECTION_STRING=your-connection-string -``` - -### CI/CD Integration - -```yaml -# GitHub Actions example -- name: Upload skill to S3 - run: | - skill-seekers scrape --config configs/react.json - skill-seekers package output/react/ - skill-seekers cloud upload --provider s3 --bucket ci-skills output/react.zip - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} -``` - -**Guide:** [docs/integrations/CLOUD_STORAGE.md](docs/integrations/CLOUD_STORAGE.md) - -## 📋 Common Workflows - -### Adding a New Platform - -1. Create adaptor in `src/skill_seekers/cli/adaptors/{platform}_adaptor.py` -2. Inherit from `BaseAdaptor` -3. Implement `package()`, `upload()`, `enhance()` methods -4. Add to factory in `adaptors/__init__.py` -5. Add optional dependency to `pyproject.toml` -6. Add tests in `tests/test_install_multiplatform.py` - -### Adding a New Feature - -1. Implement in appropriate CLI module -2. Add entry point to `pyproject.toml` if needed -3. Add tests in `tests/test_{feature}.py` -4. Run full test suite: `pytest tests/ -v` -5. Update CHANGELOG.md -6. Commit only when all tests pass - -### Debugging Common Issues - -**Import Errors:** -```bash -# Always ensure package is installed first -pip install -e . - -# Verify installation -python -c "import skill_seekers; print(skill_seekers.__version__)" -``` - -**Rate Limit Issues:** -```bash -# Check current GitHub rate limit status -curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/rate_limit - -# Configure multiple GitHub profiles -skill-seekers config --github - -# Test your tokens -skill-seekers config --test -``` - -**Enhancement Not Working:** -```bash -# Check if API key is set -echo $ANTHROPIC_API_KEY - -# Try LOCAL mode instead (uses Claude Code Max) -skill-seekers enhance output/react/ --mode LOCAL - -# Monitor enhancement status -skill-seekers enhance-status output/react/ --watch -``` - -**Test Failures:** -```bash -# Run specific failing test with verbose output -pytest tests/test_file.py::test_name -vv - -# Run with print statements visible -pytest tests/test_file.py -s - -# Run with coverage to see what's not tested -pytest tests/test_file.py --cov=src/skill_seekers --cov-report=term-missing - -# Run only unit tests (skip slow integration tests) -pytest tests/ -v -m "not slow and not integration" -``` - -**Config Issues:** -```bash -# Validate config structure -skill-seekers-validate configs/myconfig.json - -# Show current configuration -skill-seekers config --show - -# Estimate pages before scraping -skill-seekers estimate configs/myconfig.json -``` - -## 🎯 Where to Make Changes - -This section helps you quickly locate the right files when implementing common changes. - -### Adding a New CLI Command - -**Files to modify:** -1. **Create command file:** `src/skill_seekers/cli/my_command.py` - ```python - def main(): - """Entry point for my-command.""" - # Implementation - ``` - -2. **Add entry point:** `pyproject.toml` - ```toml - [project.scripts] - skill-seekers-my-command = "skill_seekers.cli.my_command:main" - ``` - -3. **Update unified CLI:** `src/skill_seekers/cli/main.py` - - Add subcommand handler to dispatcher - -4. **Add tests:** `tests/test_my_command.py` - - Test main functionality - - Test CLI argument parsing - - Test error cases - -5. **Update docs:** `CHANGELOG.md` + `README.md` (if user-facing) - -### Adding a New Platform Adaptor - -**Files to modify:** -1. **Create adaptor:** `src/skill_seekers/cli/adaptors/my_platform_adaptor.py` - ```python - from .base import BaseAdaptor - - class MyPlatformAdaptor(BaseAdaptor): - def package(self, skill_dir, output_path, **kwargs): - # Platform-specific packaging - pass - - def upload(self, package_path, api_key=None, **kwargs): - # Platform-specific upload (optional for some platforms) - pass - - def export(self, skill_dir, format, **kwargs): - # For RAG/vector DB adaptors: export to specific format - pass - ``` - -2. **Register in factory:** `src/skill_seekers/cli/adaptors/__init__.py` - ```python - def get_adaptor(target=None, format=None): - # For LLM platforms (--target flag) - target_adaptors = { - 'claude': ClaudeAdaptor, - 'gemini': GeminiAdaptor, - 'openai': OpenAIAdaptor, - 'markdown': MarkdownAdaptor, - 'myplatform': MyPlatformAdaptor, # ADD THIS - } - - # For RAG/vector DBs (--format flag) - format_adaptors = { - 'langchain': LangChainAdaptor, - 'llama-index': LlamaIndexAdaptor, - 'chroma': ChromaAdaptor, - # ... etc - } - ``` - -3. **Add optional dependency:** `pyproject.toml` - ```toml - [project.optional-dependencies] - myplatform = ["myplatform-sdk>=1.0.0"] - ``` - -4. **Add tests:** `tests/test_adaptors/test_my_platform_adaptor.py` - - Test export format - - Test upload (if applicable) - - Test with real data - -5. **Update documentation:** - - README.md - Platform comparison table - - docs/integrations/MY_PLATFORM.md - Integration guide - - examples/my-platform-example/ - Working example - -### Adding a New Config Preset - -**Files to modify:** -1. **Create config:** `configs/my_framework.json` - ```json - { - "name": "my_framework", - "base_url": "https://docs.myframework.com/", - "selectors": {...}, - "categories": {...} - } - ``` - -2. **Test locally:** - ```bash - # Estimate first - skill-seekers estimate configs/my_framework.json - - # Test scrape (small sample) - skill-seekers scrape --config configs/my_framework.json --max-pages 50 - ``` - -3. **Add to README:** Update presets table in `README.md` - -4. **Submit to website:** (Optional) Submit to SkillSeekersWeb.com - -### Modifying Core Scraping Logic - -**Key files by feature:** - -| Feature | File | Size | Notes | -|---------|------|------|-------| -| Doc scraping | `src/skill_seekers/cli/doc_scraper.py` | ~90KB | Main scraper, BFS traversal | -| GitHub scraping | `src/skill_seekers/cli/github_scraper.py` | ~56KB | Repo analysis + metadata | -| GitHub API | `src/skill_seekers/cli/github_fetcher.py` | ~17KB | Rate limit handling | -| PDF extraction | `src/skill_seekers/cli/pdf_scraper.py` | Medium | PyMuPDF + OCR | -| EPUB extraction | `src/skill_seekers/cli/epub_scraper.py` | Medium | ebooklib + BeautifulSoup | -| Code analysis | `src/skill_seekers/cli/code_analyzer.py` | ~65KB | Multi-language AST parsing | -| Pattern detection | `src/skill_seekers/cli/pattern_recognizer.py` | Medium | C3.1 - 10 GoF patterns | -| Test extraction | `src/skill_seekers/cli/test_example_extractor.py` | Medium | C3.2 - 5 categories | -| Guide generation | `src/skill_seekers/cli/how_to_guide_builder.py` | ~45KB | C3.3 - AI-enhanced guides | -| Config extraction | `src/skill_seekers/cli/config_extractor.py` | ~32KB | C3.4 - 9 formats | -| Router generation | `src/skill_seekers/cli/generate_router.py` | ~43KB | C3.5 - Architecture docs | -| Signal flow | `src/skill_seekers/cli/signal_flow_analyzer.py` | Medium | C3.10 - Godot-specific | - -**Always add tests when modifying core logic!** - -### Modifying the Unified Create Command - -**The create command uses a modular argument system:** - -**Files involved:** -1. **Parser:** `src/skill_seekers/cli/parsers/create_parser.py` - - Defines help text and formatter - - Registers help mode flags (`--help-web`, `--help-github`, etc.) - - Uses custom `NoWrapFormatter` for better help display - -2. **Arguments:** `src/skill_seekers/cli/arguments/create.py` - - Three tiers of arguments: - - `UNIVERSAL_ARGUMENTS` (13 flags) - Work for all sources - - Source-specific dicts (`WEB_ARGUMENTS`, `GITHUB_ARGUMENTS`, `EPUB_ARGUMENTS`, etc.) - - `ADVANCED_ARGUMENTS` - Rare/advanced options - - `add_create_arguments(parser, mode)` - Multi-mode argument addition - -3. **Source Detection:** `src/skill_seekers/cli/source_detector.py` (if implemented) - - Auto-detect source type from input - - Pattern matching (URLs, GitHub repos, file extensions) - -4. **Main Logic:** `src/skill_seekers/cli/create_command.py` (if implemented) - - Route to appropriate scraper based on detected type - - Argument validation and compatibility checking - -**When adding new arguments:** -- Universal args → `UNIVERSAL_ARGUMENTS` in `arguments/create.py` -- Source-specific → Appropriate dict (`WEB_ARGUMENTS`, etc.) -- Always update help text and add tests - -**Example: Adding a new universal flag:** -```python -# In arguments/create.py -UNIVERSAL_ARGUMENTS = { - # ... existing args ... - "my_flag": { - "flags": ("--my-flag", "-m"), - "kwargs": { - "action": "store_true", - "help": "Description of my flag", - }, - }, -} -``` - -### Adding MCP Tools - -**Files to modify:** -1. **Add tool function:** `src/skill_seekers/mcp/tools/{category}_tools.py` - -2. **Register tool:** `src/skill_seekers/mcp/server.py` - ```python - @mcp.tool() - def my_new_tool(param: str) -> str: - """Tool description.""" - # Implementation - ``` - -3. **Add tests:** `tests/test_mcp_fastmcp.py` - -4. **Update count:** README.md (currently 18 tools) - -## 📍 Key Files Quick Reference - -| Task | File(s) | What to Modify | -|------|---------|----------------| -| Add new CLI command | `src/skill_seekers/cli/my_cmd.py`
`pyproject.toml` | Create `main()` function
Add entry point | -| Add platform adaptor | `src/skill_seekers/cli/adaptors/my_platform.py`
`adaptors/__init__.py` | Inherit `BaseAdaptor`
Register in factory | -| Fix scraping logic | `src/skill_seekers/cli/doc_scraper.py` | `scrape_all()`, `extract_content()` | -| Add MCP tool | `src/skill_seekers/mcp/server_fastmcp.py` | Add `@mcp.tool()` function | -| Fix tests | `tests/test_{feature}.py` | Add/modify test functions | -| Add config preset | `configs/{framework}.json` | Create JSON config | -| Update CI | `.github/workflows/tests.yml` | Modify workflow steps | - -## 📚 Key Code Locations - -**Documentation Scraper** (`src/skill_seekers/cli/doc_scraper.py`): -- `FALLBACK_MAIN_SELECTORS` - Shared fallback CSS selectors for finding main content (no `body`) -- `_find_main_content()` - Centralized selector fallback: config selector → fallback list -- `is_valid_url()` - URL validation -- `extract_content()` - Content extraction (links extracted from full page before early return) -- `detect_language()` - Code language detection -- `extract_patterns()` - Pattern extraction -- `smart_categorize()` - Smart categorization -- `infer_categories()` - Category inference -- `generate_quick_reference()` - Quick reference generation -- `create_enhanced_skill_md()` - SKILL.md generation -- `scrape_all()` - Main scraping loop (dry-run extracts links from full page) -- `main()` - Entry point - -**Codebase Analysis** (`src/skill_seekers/cli/`): -- `codebase_scraper.py` - Main CLI for local codebase analysis -- `code_analyzer.py` - Multi-language AST parsing (9 languages) -- `api_reference_builder.py` - API documentation generation -- `dependency_analyzer.py` - NetworkX-based dependency graphs -- `pattern_recognizer.py` - C3.1 design pattern detection -- `test_example_extractor.py` - C3.2 test example extraction -- `how_to_guide_builder.py` - C3.3 guide generation -- `config_extractor.py` - C3.4 configuration extraction -- `generate_router.py` - C3.5 router skill generation -- `signal_flow_analyzer.py` - C3.10 signal flow analysis (Godot projects) -- `unified_codebase_analyzer.py` - Three-stream GitHub+local analyzer - -**AI Enhancement** (`src/skill_seekers/cli/`): -- `enhance_skill_local.py` - LOCAL mode enhancement (4 execution modes) -- `enhance_skill.py` - API mode enhancement -- `enhance_status.py` - Status monitoring for background processes -- `ai_enhancer.py` - Shared AI enhancement logic -- `guide_enhancer.py` - C3.3 guide AI enhancement -- `config_enhancer.py` - C3.4 config AI enhancement - -**Platform Adaptors** (`src/skill_seekers/cli/adaptors/`): -- `__init__.py` - Factory function -- `base_adaptor.py` - Abstract base class -- `claude_adaptor.py` - Claude AI implementation -- `gemini_adaptor.py` - Google Gemini implementation -- `openai_adaptor.py` - OpenAI ChatGPT implementation -- `markdown_adaptor.py` - Generic Markdown implementation - -**MCP Server** (`src/skill_seekers/mcp/`): -- `server.py` - FastMCP-based server -- `tools/` - 18 MCP tool implementations - -**Configuration & Rate Limit Management** (NEW: v2.7.0 - `src/skill_seekers/cli/`): -- `config_manager.py` - Multi-token configuration system (~490 lines) - - `ConfigManager` class - Singleton pattern for global config access - - `add_github_profile()` - Add GitHub profile with token and strategy - - `get_github_token()` - Smart fallback chain (CLI → Env → Config → Prompt) - - `get_next_profile()` - Profile switching for rate limit handling - - `save_progress()` / `load_progress()` - Job resumption support - - `cleanup_old_progress()` - Auto-cleanup of old jobs (7 days default) -- `config_command.py` - Interactive configuration wizard (~400 lines) - - `main_menu()` - 7-option main menu with navigation - - `github_token_menu()` - GitHub profile management - - `add_github_profile()` - Guided token setup with browser integration - - `api_keys_menu()` - API key configuration for Claude/Gemini/OpenAI - - `test_connections()` - Connection testing for tokens and API keys -- `rate_limit_handler.py` - Smart rate limit detection and handling (~450 lines) - - `RateLimitHandler` class - Strategy pattern for rate limit handling - - `check_upfront()` - Upfront rate limit check before starting - - `check_response()` - Real-time detection from API responses - - `handle_rate_limit()` - Execute strategy (prompt/wait/switch/fail) - - `try_switch_profile()` - Automatic profile switching - - `wait_for_reset()` - Countdown timer with live progress - - `show_countdown_timer()` - Live terminal countdown display -- `resume_command.py` - Resume interrupted scraping jobs (~150 lines) - - `list_resumable_jobs()` - Display all jobs with progress details - - `resume_job()` - Resume from saved checkpoint - - `clean_old_jobs()` - Cleanup old progress files - -**GitHub Integration** (Modified for v2.7.0 - `src/skill_seekers/cli/`): -- `github_fetcher.py` - Integrated rate limit handler - - Constructor now accepts `interactive` and `profile_name` parameters - - `fetch()` - Added upfront rate limit check - - All API calls check responses for rate limits - - Raises `RateLimitError` when rate limit cannot be handled -- `github_scraper.py` - Added CLI flags - - `--non-interactive` flag for CI/CD mode (fail fast) - - `--profile` flag to select GitHub profile from config - - Config supports `interactive` and `github_profile` keys - -**RAG & Vector Database Adaptors** (NEW: v3.0.0 - `src/skill_seekers/cli/adaptors/`): -- `langchain.py` - LangChain Documents export (~250 lines) - - Exports to LangChain Document format - - Preserves metadata (source, category, type, url) - - Smart chunking with overlap -- `llama_index.py` - LlamaIndex TextNodes export (~280 lines) - - Exports to TextNode format with unique IDs - - Relationship mapping between documents - - Metadata preservation -- `haystack.py` - Haystack Documents export (~230 lines) - - Pipeline-ready document format - - Supports embeddings and filters -- `chroma.py` - ChromaDB integration (~350 lines) - - Direct collection creation - - Batch upsert with embeddings - - Query interface -- `weaviate.py` - Weaviate vector search (~320 lines) - - Schema creation with auto-detection - - Batch import with error handling -- `faiss_helpers.py` - FAISS index generation (~280 lines) - - Index building with metadata - - Search utilities -- `qdrant.py` - Qdrant vector database (~300 lines) - - Collection management - - Payload indexing -- `streaming_adaptor.py` - Streaming data ingest (~200 lines) - - Real-time data processing - - Incremental updates - -**Cloud Storage & Infrastructure** (NEW: v3.0.0 - `src/skill_seekers/cli/`): -- `cloud_storage_cli.py` - S3/GCS/Azure upload/download (~450 lines) - - Multi-provider abstraction - - Parallel uploads for large files - - Retry logic with exponential backoff -- `embedding_pipeline.py` - Embedding generation for vectors (~320 lines) - - Sentence-transformers integration - - Batch processing - - Multiple embedding models -- `sync_cli.py` - Continuous sync & monitoring (~380 lines) - - File watching for changes - - Automatic re-scraping - - Smart diff detection -- `incremental_updater.py` - Smart incremental updates (~350 lines) - - Change detection algorithms - - Partial skill updates - - Version tracking -- `streaming_ingest.py` - Real-time data streaming (~290 lines) - - Stream processing pipelines - - WebSocket support -- `benchmark_cli.py` - Performance benchmarking (~280 lines) - - Scraping performance tests - - Comparison reports - - CI/CD integration -- `quality_metrics.py` - Quality analysis & reporting (~340 lines) - - Completeness scoring - - Link checking - - Content quality metrics -- `multilang_support.py` - Internationalization support (~260 lines) - - Language detection - - Translation integration - - Multi-locale skills -- `setup_wizard.py` - Interactive setup wizard (~220 lines) - - Configuration management - - Profile creation - - First-time setup - -**Video Scraper** (`src/skill_seekers/cli/`): -- `video_scraper.py` - Main video scraping pipeline CLI -- `video_setup.py` - GPU auto-detection, PyTorch installation, visual dependency setup (~835 lines) - - Detects CUDA/ROCm/MPS/CPU and installs matching PyTorch build - - Installs `easyocr` and other visual processing deps at runtime via `--setup` - - Run `skill-seekers video --setup` before first use - -## 🎯 Project-Specific Best Practices - -1. **Prefer the unified `create` command** - Use `skill-seekers create ` over legacy commands for consistency -2. **Always use platform adaptors** - Never hardcode platform-specific logic -3. **Test all platforms** - Changes must work for all 16 platforms (was 4 in v2.x) -4. **Maintain backward compatibility** - Legacy commands (scrape, github, analyze) must still work -5. **Document API changes** - Update CHANGELOG.md for every release -6. **Keep dependencies optional** - Platform-specific deps are optional (RAG, cloud, etc.) -7. **Use src/ layout** - Proper package structure with `pip install -e .` -8. **Run tests before commits** - Per user instructions, never skip tests (1,765+ tests must pass) -9. **RAG-first mindset** - v3.0.0 is the universal preprocessor for AI systems -10. **Export format clarity** - Use `--format` for RAG/vector DBs, `--target` for LLM platforms -11. **Test with real integrations** - Verify exports work with actual LangChain, ChromaDB, etc. -12. **Progressive disclosure** - When adding flags, categorize as universal/source-specific/advanced - -## 🐛 Debugging Tips - -### Enable Verbose Logging - -```bash -# Set environment variable for debug output -export SKILL_SEEKERS_DEBUG=1 -skill-seekers scrape --config configs/react.json -``` - -### Test Single Function/Module - -Run Python modules directly for debugging: -```bash -# Run modules with --help to see options -python -m skill_seekers.cli.doc_scraper --help -python -m skill_seekers.cli.github_scraper --repo facebook/react --dry-run -python -m skill_seekers.cli.package_skill --help - -# Test MCP server directly -python -m skill_seekers.mcp.server_fastmcp -``` - -### Use pytest with Debugging - -```bash -# Drop into debugger on failure -pytest tests/test_scraper_features.py --pdb - -# Show print statements (normally suppressed) -pytest tests/test_scraper_features.py -s - -# Verbose test output (shows full diff, more details) -pytest tests/test_scraper_features.py -vv - -# Run only failed tests from last run -pytest tests/ --lf - -# Run until first failure (stop immediately) -pytest tests/ -x - -# Show local variables on failure -pytest tests/ -l -``` - -### Debug Specific Test - -```bash -# Run single test with full output -pytest tests/test_scraper_features.py::test_detect_language -vv -s - -# With debugger -pytest tests/test_scraper_features.py::test_detect_language --pdb -``` - -### Check Package Installation - -```bash -# Verify package is installed -pip list | grep skill-seekers - -# Check installation mode (should show editable location) -pip show skill-seekers - -# Verify imports work -python -c "import skill_seekers; print(skill_seekers.__version__)" - -# Check CLI entry points -which skill-seekers -skill-seekers --version -``` - -### Common Error Messages & Solutions - -**"ModuleNotFoundError: No module named 'skill_seekers'"** -→ **Solution:** `pip install -e .` -→ **Why:** src/ layout requires package installation - -**"403 Forbidden" from GitHub API** -→ **Solution:** Rate limit hit, set `GITHUB_TOKEN` or use `skill-seekers config --github` -→ **Check limit:** `curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/rate_limit` - -**"SKILL.md enhancement failed"** -→ **Solution:** Check if `ANTHROPIC_API_KEY` is set, or use `--mode LOCAL` -→ **Monitor:** `skill-seekers enhance-status output/react/ --watch` - -**"No such file or directory: 'configs/myconfig.json'"** -→ **Solution:** Config path resolution order: - 1. Exact path as provided - 2. `./configs/` (current directory) - 3. `~/.config/skill-seekers/configs/` (user config) - 4. SkillSeekersWeb.com API (presets) - -**"pytest: command not found"** -→ **Solution:** Install dev dependencies -```bash -pip install pytest pytest-asyncio pytest-cov coverage -# Or: pip install -e ".[dev]" (if available) -``` - -**"ruff: command not found"** -→ **Solution:** Install ruff -```bash -pip install ruff -# Or use uvx: uvx ruff check src/ -``` - -### Debugging Scraping Issues - -**No content extracted?** -```python -# Test selectors in Python -from bs4 import BeautifulSoup -import requests - -url = "https://docs.example.com/page" -soup = BeautifulSoup(requests.get(url).content, 'html.parser') - -# Try different selectors -print(soup.select_one('article')) -print(soup.select_one('main')) -print(soup.select_one('div[role="main"]')) -print(soup.select_one('.documentation-content')) -``` - -**Categories not working?** -- Check `categories` in config has correct keywords -- Run with `--dry-run` to see categorization without scraping -- Enable debug mode: `export SKILL_SEEKERS_DEBUG=1` - -### Profiling Performance - -```bash -# Profile scraping performance -python -m cProfile -o profile.stats -m skill_seekers.cli.doc_scraper --config configs/react.json --max-pages 10 - -# Analyze profile -python -m pstats profile.stats -# In pstats shell: -# > sort cumtime -# > stats 20 -``` - -## 📖 Additional Documentation - -**Official Website:** -- [SkillSeekersWeb.com](https://skillseekersweb.com/) - Browse 24+ preset configs, share configs, complete documentation - -**For Users:** -- [README.md](README.md) - Complete user documentation -- [BULLETPROOF_QUICKSTART.md](BULLETPROOF_QUICKSTART.md) - Beginner guide -- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues - -**For Developers:** -- [CHANGELOG.md](CHANGELOG.md) - Release history -- [ROADMAP.md](ROADMAP.md) - 136 tasks across 10 categories -- [docs/UNIFIED_SCRAPING.md](docs/UNIFIED_SCRAPING.md) - Multi-source scraping -- [docs/MCP_SETUP.md](docs/MCP_SETUP.md) - MCP server setup -- [docs/ENHANCEMENT_MODES.md](docs/ENHANCEMENT_MODES.md) - AI enhancement modes -- [docs/PATTERN_DETECTION.md](docs/PATTERN_DETECTION.md) - C3.1 pattern detection -- [docs/THREE_STREAM_STATUS_REPORT.md](docs/THREE_STREAM_STATUS_REPORT.md) - Three-stream architecture -- [docs/MULTI_LLM_SUPPORT.md](docs/MULTI_LLM_SUPPORT.md) - Multi-platform support - -## 🎓 Understanding the Codebase - -### Why src/ Layout? - -Modern Python best practice (PEP 517/518): -- Prevents accidental imports from repo root -- Forces proper package installation -- Better isolation between package and tests -- Required: `pip install -e .` before running tests - -### Why Platform Adaptors? - -Strategy pattern benefits: -- Single codebase supports 4 platforms -- Platform-specific optimizations (format, APIs, models) -- Easy to add new platforms (implement BaseAdaptor) -- Clean separation of concerns -- Testable in isolation - -### Why Git-style CLI? - -User experience benefits: -- Familiar to developers (like `git`) -- Single entry point: `skill-seekers` -- Backward compatible: individual tools still work -- Cleaner than multiple separate commands -- Easier to document and teach - -### Three-Stream GitHub Architecture - -The `unified_codebase_analyzer.py` splits GitHub repositories into three independent streams: - -**Stream 1: Code Analysis** (C3.x features) -- Deep AST parsing (9 languages) -- Design pattern detection (C3.1) -- Test example extraction (C3.2) -- How-to guide generation (C3.3) -- Configuration extraction (C3.4) -- Architectural overview (C3.5) -- API reference + dependency graphs - -**Stream 2: Documentation** -- README, CONTRIBUTING, LICENSE -- docs/ directory markdown files -- Wiki pages (if available) -- CHANGELOG and version history - -**Stream 3: Community Insights** -- GitHub metadata (stars, forks, watchers) -- Issue analysis (top problems and solutions) -- PR trends and contributor stats -- Release history -- Label-based topic detection - -**Key Benefits:** -- Unified interface for GitHub URLs and local paths -- Analysis depth control: 'basic' (1-2 min) or 'c3x' (20-60 min) -- Enhanced router generation with GitHub context -- Smart keyword extraction weighted by GitHub labels (2x weight) -- 81 E2E tests passing (0.44 seconds) - -## 🔧 Helper Scripts - -The `scripts/` directory contains utility scripts: - -```bash -# Bootstrap skill generation - self-hosting skill-seekers as a Claude skill -./scripts/bootstrap_skill.sh - -# Start MCP server for HTTP transport -./scripts/start_mcp_server.sh - -# Script templates are in scripts/skill_header.md -``` - -**Bootstrap Skill Workflow:** -1. Analyzes skill-seekers codebase itself (dogfooding) -2. Combines handcrafted header with auto-generated analysis -3. Validates SKILL.md structure -4. Outputs ready-to-use skill for Claude Code - -## 🔍 Performance Characteristics - -| Operation | Time | Notes | -|-----------|------|-------| -| Scraping (sync) | 15-45 min | First time, thread-based | -| Scraping (async) | 5-15 min | 2-3x faster with `--async` | -| Building | 1-3 min | Fast rebuild from cache | -| Re-building | <1 min | With `--skip-scrape` | -| Enhancement (LOCAL) | 30-60 sec | Uses Claude Code Max | -| Enhancement (API) | 20-40 sec | Requires API key | -| Packaging | 5-10 sec | Final .zip creation | - -## 🎉 Recent Achievements - -**v3.1.4 (Unreleased) - "Selector Fallback & Dry-Run Fix":** -- 🐛 **Issue #300: `create https://reactflow.dev/` only found 1 page** — Now finds 20+ pages -- 🔧 **Centralized selector fallback** — `FALLBACK_MAIN_SELECTORS` constant + `_find_main_content()` helper replace 3 duplicated fallback loops -- 🔗 **Link extraction before early return** — `extract_content()` now discovers links even when no content selector matches -- 🔍 **Dry-run full-page link discovery** — Both sync and async dry-run paths extract links from the full page (was main-content-only or missing entirely) -- 🛣️ **Smart `create --config` routing** — Peeks at JSON to route `base_url` configs to doc_scraper and `sources` configs to unified_scraper -- 🧹 **Removed `body` fallback** — `body` matched everything, hiding real selector failures -- ✅ **Pre-existing test fixes** — `test_auto_fetch_enabled` (react.json exists locally) and `test_mcp_validate_legacy_config` (react.json is now unified format) - -**v3.1.3 (Released) - "Unified Argument Interface":** -- 🔧 **Unified Scraper Arguments** - All scrapers (scrape, github, analyze, pdf) now share a common argument contract via `add_all_standard_arguments(parser)` in `arguments/common.py` -- 🐛 **Fix `create` Argument Forwarding** - `create --dry-run`, `create owner/repo --dry-run`, `create ./path --dry-run` all work now (previously crashed) -- 🏗️ **Argument Deduplication** - Removed duplicated arg definitions from github.py, scrape.py, analyze.py, pdf.py; all import shared args -- ➕ **New Flags** - GitHub and PDF scrapers gain `--dry-run`, `--verbose`, `--quiet`; analyze gains `--name`, `--description`, `--quiet` -- 🔀 **Route-Specific Forwarding** - `create` command's `_add_common_args()` now only forwards universal flags; route-specific flags moved to their respective methods - -**v3.1.0 - "Unified CLI & Developer Experience":** -- 🎯 **Unified `create` Command** - Auto-detects source type (web/GitHub/local/PDF/config) -- 📋 **Progressive Disclosure Help** - Default shows 13 universal flags, detailed help available per source -- ⚡ **-p Shortcut** - Quick preset selection (`-p quick|standard|comprehensive`) -- 🔧 **Enhancement Flag Consolidation** - `--enhance-level` (0-3) replaces 3 separate flags -- 🎨 **Smart Source Detection** - No need to specify whether input is URL, repo, or directory -- 🔄 **Enhancement Workflow Presets** - YAML-based presets; `skill-seekers workflows list/show/copy/add/remove/validate`; bundled presets: `default`, `minimal`, `security-focus`, `architecture-comprehensive`, `api-documentation` -- 🔀 **Multiple Workflows from CLI** - `--enhance-workflow wf-a --enhance-workflow wf-b` chains presets in a single command; `workflows copy/add/remove` all accept multiple names/files at once -- 🐛 **Bug Fix** - `create` command now correctly forwards multiple `--enhance-workflow` flags to sub-scrapers -- ✅ **2,121 Tests Passing** - All CLI refactor + workflow preset work verified -- 📚 **Improved Documentation** - CLAUDE.md, README, QUICK_REFERENCE updated with workflow preset details - -**v3.1.0 CI Stability (February 20, 2026):** -- 🔧 **Dependency Alignment** - Fixed MCP version mismatch between requirements.txt (was 1.18.0) and pyproject.toml (>=1.25) -- 📦 **PyYAML Core Dependency** - Added PyYAML>=6.0 to core dependencies (required by workflow_tools.py module-level import) -- ⚡ **Benchmark Stability** - Relaxed timing-sensitive test thresholds for CI environment variability -- ✅ **2,121 Tests Passing** - All CI matrix jobs passing (ubuntu 3.10/3.11/3.12, macos 3.11/3.12) - -**v3.0.0 (February 10, 2026) - "Universal Intelligence Platform":** -- 🚀 **16 Platform Adaptors** - RAG frameworks (LangChain, LlamaIndex, Haystack), vector DBs (Chroma, FAISS, Weaviate, Qdrant), AI coding assistants (Cursor, Windsurf, Cline, Continue.dev), LLM platforms (Claude, Gemini, OpenAI) -- 🛠️ **26 MCP Tools** (up from 18) - Complete automation for any AI system -- ✅ **1,852 Tests Passing** (up from 700+) - Production-grade reliability -- ☁️ **Cloud Storage** - S3, GCS, Azure Blob Storage integration -- 🎯 **AI Coding Assistants** - Persistent context for Cursor, Windsurf, Cline, Continue.dev -- 📊 **Quality Metrics** - Automated completeness scoring and content analysis -- 🌐 **Multilingual Support** - Language detection and translation -- 🔄 **Streaming Ingest** - Real-time data processing pipelines -- 📈 **Benchmarking Tools** - Performance comparison and CI/CD integration -- 🔧 **Setup Wizard** - Interactive first-time configuration -- 📦 **12 Example Projects** - Complete working examples for every integration -- 📚 **18 Integration Guides** - Comprehensive documentation for all platforms - -**v2.9.0 (February 3, 2026):** -- **C3.10: Signal Flow Analysis** - Complete signal flow analysis for Godot projects -- Comprehensive Godot 4.x support (GDScript, .tscn, .tres, .gdshader files) -- GDScript test extraction (GUT, gdUnit4, WAT frameworks) -- Signal pattern detection (EventBus, Observer, Event Chains) -- Signal-based how-to guides generation - -**v2.8.0 (February 1, 2026):** -- C3.9: Project Documentation Extraction -- Granular AI enhancement control with `--enhance-level` (0-3) - -**v2.7.1 (January 18, 2026 - Hotfix):** -- 🚨 **Critical Bug Fix:** Config download 404 errors resolved -- Fixed manual URL construction bug - now uses `download_url` from API response -- All 15 source tools tests + 8 fetch_config tests passing - -**v2.7.0 (January 18, 2026):** -- 🔐 **Smart Rate Limit Management** - Multi-token GitHub configuration system -- 🧙 **Interactive Configuration Wizard** - Beautiful terminal UI (`skill-seekers config`) -- 🚦 **Intelligent Rate Limit Handler** - Four strategies (prompt/wait/switch/fail) -- 📥 **Resume Capability** - Continue interrupted jobs with progress tracking -- 🔧 **CI/CD Support** - Non-interactive mode for automation -- 🎯 **Bootstrap Skill** - Self-hosting skill-seekers as Claude Code skill - -**v2.6.0 (January 14, 2026):** -- **C3.x Codebase Analysis Suite Complete** (C3.1-C3.8) -- Multi-platform support with platform adaptor architecture (4 platforms) -- 18 MCP tools fully functional -- 700+ tests passing -- Unified multi-source scraping maturity - -**C3.x Series (Complete - Code Analysis Features):** -- **C3.1:** Design pattern detection (10 GoF patterns, 9 languages, 87% precision) -- **C3.2:** Test example extraction (5 categories, AST-based for Python) -- **C3.3:** How-to guide generation with AI enhancement (5 improvements) -- **C3.4:** Configuration pattern extraction (env vars, config files, CLI args) -- **C3.5:** Architectural overview & router skill generation -- **C3.6:** AI enhancement for patterns and test examples (Claude API integration) -- **C3.7:** Architectural pattern detection (8 patterns, framework-aware) -- **C3.8:** Standalone codebase scraper (300+ line SKILL.md from code alone) -- **C3.9:** Project documentation extraction (markdown categorization, AI enhancement) -- **C3.10:** Signal flow analysis (Godot event-driven architecture, pattern detection) - -**v2.5.2:** -- UX Improvement: Analysis features now default ON with --skip-* flags (BREAKING) -- Router quality improvements: 6.5/10 → 8.5/10 (+31%) -- All 107 codebase analysis tests passing - -**v2.5.0:** -- Multi-platform support (Claude, Gemini, OpenAI, Markdown) -- Platform adaptor architecture -- 18 MCP tools (up from 9) -- Complete feature parity across platforms - -**v2.1.0:** -- Unified multi-source scraping (docs + GitHub + PDF) -- Conflict detection between sources -- 427 tests passing - -**v1.0.0:** -- Production release with MCP integration -- Documentation scraping with smart categorization -- 12 preset configurations +## Adding New Features + +### New platform adaptor +1. Create `src/skill_seekers/cli/adaptors/{platform}_adaptor.py` inheriting `BaseAdaptor` +2. Register in `adaptors/__init__.py` factory +3. Add optional dep to `pyproject.toml` +4. Add tests in `tests/` + +### New source type scraper +1. Create `src/skill_seekers/cli/{type}_scraper.py` with `main()` +2. Add to `COMMAND_MODULES` in `cli/main.py` +3. Add entry point in `pyproject.toml` `[project.scripts]` +4. Add auto-detection in `source_detector.py` +5. Add optional dep if needed +6. Add tests + +### New CLI argument +- Universal: `UNIVERSAL_ARGUMENTS` in `arguments/create.py` +- Source-specific: appropriate dict (`WEB_ARGUMENTS`, `GITHUB_ARGUMENTS`, etc.) +- Shared across scrapers: `add_all_standard_arguments()` in `arguments/common.py` diff --git a/README.md b/README.md index 14299c5..d2640e1 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ Instead of spending days on manual preprocessing, Skill Seekers: - ✅ **Backward Compatible** - Legacy single-source configs still work ### 🤖 Multi-LLM Platform Support -- ✅ **4 LLM Platforms** - Claude AI, Google Gemini, OpenAI ChatGPT, Generic Markdown +- ✅ **5 LLM Platforms** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generic Markdown - ✅ **Universal Scraping** - Same documentation works for all platforms - ✅ **Platform-Specific Packaging** - Optimized formats for each LLM - ✅ **One-Command Export** - `--target` flag selects platform @@ -260,6 +260,7 @@ Instead of spending days on manual preprocessing, Skill Seekers: | **Claude AI** | ZIP + YAML | ✅ Auto | ✅ Yes | ANTHROPIC_API_KEY | ANTHROPIC_BASE_URL | | **Google Gemini** | tar.gz | ✅ Auto | ✅ Yes | GOOGLE_API_KEY | - | | **OpenAI ChatGPT** | ZIP + Vector Store | ✅ Auto | ✅ Yes | OPENAI_API_KEY | - | +| **MiniMax AI** | ZIP + Knowledge Files | ✅ Auto | ✅ Yes | MINIMAX_API_KEY | - | | **Generic Markdown** | ZIP | ❌ Manual | ❌ No | - | - | ```bash @@ -277,6 +278,11 @@ pip install skill-seekers[openai] skill-seekers package output/react/ --target openai skill-seekers upload react-openai.zip --target openai +# MiniMax AI +pip install skill-seekers[minimax] +skill-seekers package output/react/ --target minimax +skill-seekers upload react-minimax.zip --target minimax + # Generic Markdown (universal export) skill-seekers package output/react/ --target markdown # Use the markdown files directly in any LLM @@ -312,6 +318,9 @@ pip install skill-seekers[gemini] # Install with OpenAI support pip install skill-seekers[openai] +# Install with MiniMax support +pip install skill-seekers[minimax] + # Install with all LLM platforms pip install skill-seekers[all-llms] ``` @@ -698,21 +707,21 @@ skill-seekers install --config react --dry-run ## 📊 Feature Matrix -Skill Seekers supports **4 LLM platforms**, **17 source types**, and full feature parity across all targets. +Skill Seekers supports **5 LLM platforms**, **17 source types**, and full feature parity across all targets. -**Platforms:** Claude AI, Google Gemini, OpenAI ChatGPT, Generic Markdown +**Platforms:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generic Markdown **Source Types:** Documentation websites, GitHub repos, PDFs, Word (.docx), EPUB, Video, Local codebases, Jupyter Notebooks, Local HTML, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), RSS/Atom feeds, Man pages, Confluence wikis, Notion pages, Slack/Discord chat exports See [Complete Feature Matrix](docs/FEATURE_MATRIX.md) for detailed platform and feature support. ### Quick Platform Comparison -| Feature | Claude | Gemini | OpenAI | Markdown | -|---------|--------|--------|--------|----------| -| Format | ZIP + YAML | tar.gz | ZIP + Vector | ZIP | -| Upload | ✅ API | ✅ API | ✅ API | ❌ Manual | -| Enhancement | ✅ Sonnet 4 | ✅ 2.0 Flash | ✅ GPT-4o | ❌ None | -| All Skill Modes | ✅ | ✅ | ✅ | ✅ | +| Feature | Claude | Gemini | OpenAI | MiniMax | Markdown | +|---------|--------|--------|--------|--------|----------| +| Format | ZIP + YAML | tar.gz | ZIP + Vector | ZIP + Knowledge | ZIP | +| Upload | ✅ API | ✅ API | ✅ API | ✅ API | ❌ Manual | +| Enhancement | ✅ Sonnet 4 | ✅ 2.0 Flash | ✅ GPT-4o | ✅ M2.7 | ❌ None | +| All Skill Modes | ✅ | ✅ | ✅ | ✅ | ✅ | --- diff --git a/docs/getting-started/01-installation.md b/docs/getting-started/01-installation.md index 84ff3c6..6df3543 100644 --- a/docs/getting-started/01-installation.md +++ b/docs/getting-started/01-installation.md @@ -86,6 +86,7 @@ pip install skill-seekers[all-llms] - Claude AI support - Google Gemini support - OpenAI ChatGPT support +- MiniMax AI support - All vector databases - MCP server - Cloud storage (S3, GCS, Azure) @@ -98,6 +99,7 @@ Install only what you need: # Specific platform only pip install skill-seekers[gemini] # Google Gemini pip install skill-seekers[openai] # OpenAI +pip install skill-seekers[minimax] # MiniMax AI pip install skill-seekers[chroma] # ChromaDB # Multiple extras @@ -115,6 +117,7 @@ pip install skill-seekers[dev] |-------|-------------|-----------------| | `gemini` | Google Gemini support | `pip install skill-seekers[gemini]` | | `openai` | OpenAI ChatGPT support | `pip install skill-seekers[openai]` | +| `minimax` | MiniMax AI support | `pip install skill-seekers[minimax]` | | `mcp` | MCP server | `pip install skill-seekers[mcp]` | | `chroma` | ChromaDB export | `pip install skill-seekers[chroma]` | | `weaviate` | Weaviate export | `pip install skill-seekers[weaviate]` | diff --git a/docs/integrations/INTEGRATIONS.md b/docs/integrations/INTEGRATIONS.md index 19d2fa1..3adb0f3 100644 --- a/docs/integrations/INTEGRATIONS.md +++ b/docs/integrations/INTEGRATIONS.md @@ -112,6 +112,7 @@ Upload documentation as custom skills to AI chat platforms: | **[Claude](CLAUDE.md)** | Anthropic | ZIP + YAML | Claude.ai Projects | [Setup →](CLAUDE.md) | | **[Gemini](GEMINI_INTEGRATION.md)** | Google | tar.gz | Gemini AI | [Setup →](GEMINI_INTEGRATION.md) | | **[ChatGPT](OPENAI_INTEGRATION.md)** | OpenAI | ZIP + Vector Store | GPT Actions | [Setup →](OPENAI_INTEGRATION.md) | +| **[MiniMax](MINIMAX_INTEGRATION.md)** | MiniMax | ZIP | MiniMax AI Platform | [Setup →](MINIMAX_INTEGRATION.md) | **Quick Example:** ```bash @@ -139,7 +140,7 @@ skill-seekers upload output/vue-claude.zip --target claude | **AI coding (flow-based)** | Windsurf | Unique flow paradigm, Codeium AI | 5 min | | **AI coding (VS Code ext)** | Cline | Claude in VS Code, MCP integration | 10 min | | **AI coding (any IDE)** | Continue.dev | Works everywhere, open-source | 5 min | -| **Chat with documentation** | Claude/Gemini/ChatGPT | Direct upload as custom skill | 3 min | +| **Chat with documentation** | Claude/Gemini/ChatGPT/MiniMax | Direct upload as custom skill | 3 min | ### By Technical Requirements diff --git a/docs/integrations/MINIMAX_INTEGRATION.md b/docs/integrations/MINIMAX_INTEGRATION.md new file mode 100644 index 0000000..f162ae2 --- /dev/null +++ b/docs/integrations/MINIMAX_INTEGRATION.md @@ -0,0 +1,391 @@ +# MiniMax AI Integration Guide + +Complete guide for using Skill Seekers with MiniMax AI platform. + +--- + +## Overview + +**MiniMax AI** is a Chinese AI company offering OpenAI-compatible APIs with their M2.7 model. Skill Seekers packages documentation for use with MiniMax's platform. + +### Key Features + +- **OpenAI-Compatible API**: Uses standard OpenAI client library +- **MiniMax-M2.7 Model**: Powerful LLM for enhancement and chat +- **Simple ZIP Format**: Easy packaging with system instructions +- **Knowledge Files**: Reference documentation included in package + +--- + +## Prerequisites + +### 1. Get MiniMax API Key + +1. Visit [MiniMax Platform](https://platform.minimaxi.com/) +2. Create an account and verify +3. Navigate to API Keys section +4. Generate a new API key +5. Copy the key (starts with `eyJ` - JWT format) + +### 2. Install Dependencies + +```bash +# Install MiniMax support (includes openai library) +pip install skill-seekers[minimax] + +# Or install all LLM platforms +pip install skill-seekers[all-llms] +``` + +### 3. Configure Environment + +```bash +export MINIMAX_API_KEY=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +Add to your `~/.bashrc`, `~/.zshrc`, or `.env` file for persistence. + +--- + +## Complete Workflow + +### Step 1: Scrape Documentation + +```bash +# Scrape documentation website +skill-seekers scrape --config configs/react.json + +# Or use quick preset +skill-seekers create https://docs.python.org/3/ --preset quick +``` + +### Step 2: Enhance with MiniMax-M2.7 + +```bash +# Enhance SKILL.md using MiniMax AI +skill-seekers enhance output/react/ --target minimax + +# With custom model (if available) +skill-seekers enhance output/react/ --target minimax --model MiniMax-M2.7 +``` + +This step: +- Reads reference documentation +- Generates enhanced system instructions +- Creates backup of original SKILL.md +- Uses MiniMax-M2.7 for AI enhancement + +### Step 3: Package for MiniMax + +```bash +# Package as MiniMax-compatible ZIP +skill-seekers package output/react/ --target minimax + +# Custom output path +skill-seekers package output/react/ --target minimax --output my-skill.zip +``` + +**Output structure:** +``` +react-minimax.zip +├── system_instructions.txt # Main instructions (from SKILL.md) +├── knowledge_files/ # Reference documentation +│ ├── guide.md +│ ├── api-reference.md +│ └── examples.md +└── minimax_metadata.json # Skill metadata +``` + +### Step 4: Validate Package + +```bash +# Validate package with MiniMax API +skill-seekers upload react-minimax.zip --target minimax +``` + +This validates: +- Package structure +- API connectivity +- System instructions format + +**Note:** MiniMax doesn't have persistent skill storage like Claude. The upload validates your package but you'll use the ZIP file directly with MiniMax's API. + +--- + +## Using Your Skill + +### Direct API Usage + +```python +from openai import OpenAI +import zipfile +import json + +# Extract package +with zipfile.ZipFile('react-minimax.zip', 'r') as zf: + with zf.open('system_instructions.txt') as f: + system_instructions = f.read().decode('utf-8') + + # Load metadata + with zf.open('minimax_metadata.json') as f: + metadata = json.load(f) + +# Initialize MiniMax client (OpenAI-compatible) +client = OpenAI( + api_key="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + base_url="https://api.minimax.io/v1" +) + +# Use with chat completions +response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + {"role": "system", "content": system_instructions}, + {"role": "user", "content": "How do I create a React component?"} + ], + temperature=0.3, + max_tokens=2000 +) + +print(response.choices[0].message.content) +``` + +### With Knowledge Files + +```python +import zipfile +from pathlib import Path + +# Extract knowledge files +with zipfile.ZipFile('react-minimax.zip', 'r') as zf: + zf.extractall('extracted_skill') + +# Read all knowledge files +knowledge_dir = Path('extracted_skill/knowledge_files') +knowledge_files = [] +for md_file in knowledge_dir.glob('*.md'): + knowledge_files.append({ + 'name': md_file.name, + 'content': md_file.read_text() + }) + +# Include in context (truncate if too long) +context = "\n\n".join([f"## {kf['name']}\n{kf['content'][:5000]}" + for kf in knowledge_files[:5]]) + +response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + {"role": "system", "content": system_instructions}, + {"role": "user", "content": f"Context: {context}\n\nQuestion: What are React hooks?"} + ] +) +``` + +--- + +## API Reference + +### SkillAdaptor Methods + +```python +from skill_seekers.cli.adaptors import get_adaptor + +# Get MiniMax adaptor +adaptor = get_adaptor('minimax') + +# Format SKILL.md as system instructions +instructions = adaptor.format_skill_md(skill_dir, metadata) + +# Package skill +package_path = adaptor.package(skill_dir, output_path) + +# Validate package with MiniMax API +result = adaptor.upload(package_path, api_key) +print(result['message']) # Validation result + +# Enhance SKILL.md +success = adaptor.enhance(skill_dir, api_key) +``` + +### Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `MINIMAX_API_KEY` | Your MiniMax API key (JWT format) | Yes | + +--- + +## Troubleshooting + +### Invalid API Key Format + +**Error:** `Invalid API key format` + +**Solution:** MiniMax API keys use JWT format starting with `eyJ`. Check: +```bash +# Should start with 'eyJ' +echo $MINIMAX_API_KEY | head -c 10 +# Output: eyJhbGciOi +``` + +### OpenAI Library Not Installed + +**Error:** `ModuleNotFoundError: No module named 'openai'` + +**Solution:** +```bash +pip install skill-seekers[minimax] +# or +pip install openai>=1.0.0 +``` + +### Upload Timeout + +**Error:** `Upload timed out` + +**Solution:** +- Check internet connection +- Try again (temporary network issue) +- Verify API key is correct +- Check MiniMax platform status + +### Connection Error + +**Error:** `Connection error` + +**Solution:** +- Verify internet connectivity +- Check if MiniMax API endpoint is accessible: +```bash +curl https://api.minimax.io/v1/models +``` +- Try with VPN if in restricted region + +### Package Validation Failed + +**Error:** `Invalid package: system_instructions.txt not found` + +**Solution:** +- Ensure SKILL.md exists before packaging +- Check package contents: +```bash +unzip -l react-minimax.zip +``` +- Re-package the skill + +--- + +## Best Practices + +### 1. Keep References Organized + +Structure your documentation: +``` +output/react/ +├── SKILL.md # Main instructions +├── references/ +│ ├── 01-getting-started.md +│ ├── 02-components.md +│ ├── 03-hooks.md +│ └── 04-api-reference.md +└── assets/ + └── diagrams/ +``` + +### 2. Use Enhancement + +Always enhance before packaging: +```bash +# Enhancement improves system instructions quality +skill-seekers enhance output/react/ --target minimax +``` + +### 3. Test Before Deployment + +```bash +# Validate package +skill-seekers upload react-minimax.zip --target minimax + +# If successful, package is ready to use +``` + +### 4. Version Your Skills + +```bash +# Include version in output name +skill-seekers package output/react/ --target minimax --output react-v2.0-minimax.zip +``` + +--- + +## Comparison with Other Platforms + +| Feature | MiniMax | Claude | Gemini | OpenAI | +|---------|---------|--------|--------|--------| +| **Format** | ZIP | ZIP | tar.gz | ZIP | +| **Upload** | Validation | Full API | Full API | Full API | +| **Enhancement** | MiniMax-M2.7 | Claude Sonnet | Gemini 2.0 | GPT-4o | +| **API Type** | OpenAI-compatible | Anthropic | Google | OpenAI | +| **Key Format** | JWT (eyJ...) | sk-ant... | AIza... | sk-... | +| **Knowledge Files** | Included in ZIP | Included | Included | Vector Store | + +--- + +## Advanced Usage + +### Custom Enhancement Prompt + +Programmatically customize enhancement: + +```python +from skill_seekers.cli.adaptors import get_adaptor +from pathlib import Path + +adaptor = get_adaptor('minimax') +skill_dir = Path('output/react') + +# Build custom prompt +references = adaptor._read_reference_files(skill_dir / 'references') +prompt = adaptor._build_enhancement_prompt( + skill_name='React', + references=references, + current_skill_md=(skill_dir / 'SKILL.md').read_text() +) + +# Customize prompt +prompt += "\n\nADDITIONAL FOCUS: Emphasize React 18 concurrent features." + +# Use with your own API call +``` + +### Batch Processing + +```bash +# Process multiple frameworks +for framework in react vue angular; do + skill-seekers scrape --config configs/${framework}.json + skill-seekers enhance output/${framework}/ --target minimax + skill-seekers package output/${framework}/ --target minimax --output ${framework}-minimax.zip +done +``` + +--- + +## Resources + +- [MiniMax Platform](https://platform.minimaxi.com/) +- [MiniMax API Documentation](https://platform.minimaxi.com/document) +- [OpenAI Python Client](https://github.com/openai/openai-python) +- [Multi-LLM Support Guide](MULTI_LLM_SUPPORT.md) + +--- + +## Next Steps + +1. Get your [MiniMax API key](https://platform.minimaxi.com/) +2. Install dependencies: `pip install skill-seekers[minimax]` +3. Try the [Quick Start example](#complete-workflow) +4. Explore [advanced usage](#advanced-usage) patterns + +For help, see [Troubleshooting](#troubleshooting) or open an issue on GitHub. diff --git a/docs/integrations/MULTI_LLM_SUPPORT.md b/docs/integrations/MULTI_LLM_SUPPORT.md index 0b96bd4..4313d46 100644 --- a/docs/integrations/MULTI_LLM_SUPPORT.md +++ b/docs/integrations/MULTI_LLM_SUPPORT.md @@ -9,6 +9,7 @@ Skill Seekers supports multiple LLM platforms through a clean adaptor system. Th | **Claude AI** | ✅ Full Support | ZIP + YAML | ✅ Automatic | ✅ Yes | ANTHROPIC_API_KEY | | **Google Gemini** | ✅ Full Support | tar.gz | ✅ Automatic | ✅ Yes | GOOGLE_API_KEY | | **OpenAI ChatGPT** | ✅ Full Support | ZIP + Vector Store | ✅ Automatic | ✅ Yes | OPENAI_API_KEY | +| **MiniMax AI** | ✅ Full Support | ZIP | ✅ Validation | ✅ Yes | MINIMAX_API_KEY | | **Generic Markdown** | ✅ Export Only | ZIP | ❌ Manual | ❌ No | None | ## Quick Start @@ -108,6 +109,9 @@ pip install skill-seekers[gemini] # OpenAI ChatGPT support pip install skill-seekers[openai] +# MiniMax AI support +pip install skill-seekers[minimax] + # All LLM platforms pip install skill-seekers[all-llms] @@ -150,6 +154,13 @@ pip install -e .[all-llms] - API: Assistants API + Vector Store - Enhancement: GPT-4o +**MiniMax AI:** +- Format: ZIP archive +- SKILL.md -> `system_instructions.txt` (plain text, no frontmatter) +- Structure: `system_instructions.txt`, `knowledge_files/`, `minimax_metadata.json` +- API: OpenAI-compatible chat completions +- Enhancement: MiniMax-M2.7 + **Generic Markdown:** - Format: ZIP archive - Structure: `README.md`, `references/`, `DOCUMENTATION.md` (combined) @@ -174,6 +185,11 @@ export GOOGLE_API_KEY=AIzaSy... export OPENAI_API_KEY=sk-proj-... ``` +**MiniMax AI:** +```bash +export MINIMAX_API_KEY=your-key +``` + ## Complete Workflow Examples ### Workflow 1: Claude AI (Default) @@ -238,7 +254,29 @@ skill-seekers upload react-openai.zip --target openai # Access at: https://platform.openai.com/assistants/ ``` -### Workflow 4: Export to All Platforms +### Workflow 4: MiniMax AI + +```bash +# Setup (one-time) +pip install skill-seekers[minimax] +export MINIMAX_API_KEY=your-key + +# 1. Scrape (universal) +skill-seekers scrape --config configs/react.json + +# 2. Enhance with MiniMax-M2.7 +skill-seekers enhance output/react/ --target minimax + +# 3. Package for MiniMax +skill-seekers package output/react/ --target minimax + +# 4. Upload to MiniMax (validates with API) +skill-seekers upload react-minimax.zip --target minimax + +# Access at: https://platform.minimaxi.com/ +``` + +### Workflow 5: Export to All Platforms ```bash # Install all platforms @@ -251,12 +289,14 @@ skill-seekers scrape --config configs/react.json skill-seekers package output/react/ --target claude skill-seekers package output/react/ --target gemini skill-seekers package output/react/ --target openai +skill-seekers package output/react/ --target minimax skill-seekers package output/react/ --target markdown # Result: # - react.zip (Claude) # - react-gemini.tar.gz (Gemini) # - react-openai.zip (OpenAI) +# - react-minimax.zip (MiniMax) # - react-markdown.zip (Universal) ``` @@ -300,7 +340,7 @@ from skill_seekers.cli.adaptors import list_platforms, is_platform_available # List all registered platforms platforms = list_platforms() -print(platforms) # ['claude', 'gemini', 'openai', 'markdown'] +print(platforms) # ['claude', 'gemini', 'minimax', 'openai', 'markdown'] # Check if platform is available if is_platform_available('gemini'): @@ -323,6 +363,7 @@ For detailed platform-specific instructions, see: - [Claude AI Integration](CLAUDE_INTEGRATION.md) (default) - [Google Gemini Integration](GEMINI_INTEGRATION.md) - [OpenAI ChatGPT Integration](OPENAI_INTEGRATION.md) +- [MiniMax AI Integration](MINIMAX_INTEGRATION.md) ## Troubleshooting @@ -340,6 +381,8 @@ pip install skill-seekers[gemini] **Solution:** ```bash pip install skill-seekers[openai] +# or for MiniMax (also uses openai library) +pip install skill-seekers[minimax] ``` ### API Key Issues @@ -350,6 +393,7 @@ pip install skill-seekers[openai] - Claude: `sk-ant-...` - Gemini: `AIza...` - OpenAI: `sk-proj-...` or `sk-...` +- MiniMax: Any valid API key string ### Package Format Errors @@ -380,6 +424,7 @@ A: Yes, each platform uses its own enhancement model: - Claude: Claude Sonnet 4 - Gemini: Gemini 2.0 Flash - OpenAI: GPT-4o +- MiniMax: MiniMax-M2.7 **Q: What if I don't want to upload automatically?** diff --git a/pyproject.toml b/pyproject.toml index 515e219..2bb1b8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,11 @@ openai = [ "openai>=1.0.0", ] +# MiniMax AI support (uses OpenAI-compatible API) +minimax = [ + "openai>=1.0.0", +] + # All LLM platforms combined all-llms = [ "google-generativeai>=0.8.0", diff --git a/src/skill_seekers/cli/adaptors/__init__.py b/src/skill_seekers/cli/adaptors/__init__.py index 6240082..2350858 100644 --- a/src/skill_seekers/cli/adaptors/__init__.py +++ b/src/skill_seekers/cli/adaptors/__init__.py @@ -3,7 +3,7 @@ Multi-LLM Adaptor Registry Provides factory function to get platform-specific adaptors for skill generation. -Supports Claude AI, Google Gemini, OpenAI ChatGPT, and generic Markdown export. +Supports Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, and generic Markdown export. """ from .base import SkillAdaptor, SkillMetadata @@ -69,6 +69,11 @@ try: except ImportError: PineconeAdaptor = None +try: + from .minimax import MiniMaxAdaptor +except ImportError: + MiniMaxAdaptor = None + # Registry of available adaptors ADAPTORS: dict[str, type[SkillAdaptor]] = {} @@ -98,6 +103,8 @@ if HaystackAdaptor: ADAPTORS["haystack"] = HaystackAdaptor if PineconeAdaptor: ADAPTORS["pinecone"] = PineconeAdaptor +if MiniMaxAdaptor: + ADAPTORS["minimax"] = MiniMaxAdaptor def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor: @@ -105,7 +112,7 @@ def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor: Factory function to get platform-specific adaptor instance. Args: - platform: Platform identifier ('claude', 'gemini', 'openai', 'markdown') + platform: Platform identifier ('claude', 'gemini', 'openai', 'minimax', 'markdown') config: Optional platform-specific configuration Returns: @@ -116,6 +123,7 @@ def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor: Examples: >>> adaptor = get_adaptor('claude') + >>> adaptor = get_adaptor('minimax') >>> adaptor = get_adaptor('gemini', {'api_version': 'v1beta'}) """ if platform not in ADAPTORS: @@ -141,7 +149,7 @@ def list_platforms() -> list[str]: Examples: >>> list_platforms() - ['claude', 'gemini', 'openai', 'markdown'] + ['claude', 'gemini', 'openai', 'minimax', 'markdown'] """ return list(ADAPTORS.keys()) diff --git a/src/skill_seekers/cli/adaptors/minimax.py b/src/skill_seekers/cli/adaptors/minimax.py new file mode 100644 index 0000000..ca9a272 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/minimax.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +""" +MiniMax AI Adaptor + +Implements platform-specific handling for MiniMax AI skills. +Uses MiniMax's OpenAI-compatible API for AI enhancement with M2.7 model. +""" + +import json +import zipfile +from pathlib import Path +from typing import Any + +from .base import SkillAdaptor, SkillMetadata +from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS + + +class MiniMaxAdaptor(SkillAdaptor): + """ + MiniMax AI platform adaptor. + + Handles: + - System instructions format (plain text, no YAML frontmatter) + - ZIP packaging with knowledge files + - AI enhancement using MiniMax-M2.7 + """ + + PLATFORM = "minimax" + PLATFORM_NAME = "MiniMax AI" + DEFAULT_API_ENDPOINT = "https://api.minimax.io/v1" + + def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str: + """ + Format SKILL.md as system instructions for MiniMax AI. + + MiniMax uses OpenAI-compatible chat completions, so instructions + are formatted as clear system prompts without YAML frontmatter. + + Args: + skill_dir: Path to skill directory + metadata: Skill metadata + + Returns: + Formatted instructions for MiniMax AI + """ + existing_content = self._read_existing_content(skill_dir) + + if existing_content and len(existing_content) > 100: + content_body = f"""You are an expert assistant for {metadata.name}. + +{metadata.description} + +Use the attached knowledge files to provide accurate, detailed answers about {metadata.name}. + +{existing_content} + +## How to Assist Users + +When users ask questions: +1. Search the knowledge files for relevant information +2. Provide clear, practical answers with code examples +3. Reference specific documentation sections when helpful +4. Be concise but thorough + +Always prioritize accuracy by consulting the knowledge base before responding.""" + else: + content_body = f"""You are an expert assistant for {metadata.name}. + +{metadata.description} + +## Your Knowledge Base + +You have access to comprehensive documentation files about {metadata.name}. Use these files to provide accurate answers to user questions. + +{self._generate_toc(skill_dir)} + +## Quick Reference + +{self._extract_quick_reference(skill_dir)} + +## How to Assist Users + +When users ask questions about {metadata.name}: + +1. **Search the knowledge files** - Find relevant information in the documentation +2. **Provide code examples** - Include practical, working code snippets +3. **Reference documentation** - Cite specific sections when helpful +4. **Be practical** - Focus on real-world usage and best practices +5. **Stay accurate** - Always verify information against the knowledge base + +## Response Guidelines + +- Keep answers clear and concise +- Use proper code formatting with language tags +- Provide both simple and detailed explanations as needed +- Suggest related topics when relevant +- Admit when information isn't in the knowledge base + +Always prioritize accuracy by consulting the attached documentation files before responding.""" + + return content_body + + def package( + self, + skill_dir: Path, + output_path: Path, + enable_chunking: bool = False, + chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS, + preserve_code_blocks: bool = True, + chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS, + ) -> Path: + """ + Package skill into ZIP file for MiniMax AI. + + Creates MiniMax-compatible structure: + - system_instructions.txt (main instructions) + - knowledge_files/*.md (reference files) + - minimax_metadata.json (skill metadata) + + Args: + skill_dir: Path to skill directory + output_path: Output path/filename for ZIP + + Returns: + Path to created ZIP file + """ + skill_dir = Path(skill_dir) + output_path = Path(output_path) + + if output_path.is_dir() or str(output_path).endswith("/"): + output_path = Path(output_path) / f"{skill_dir.name}-minimax.zip" + elif not str(output_path).endswith(".zip") and not str(output_path).endswith( + "-minimax.zip" + ): + output_str = str(output_path).replace(".zip", "-minimax.zip") + if not output_str.endswith(".zip"): + output_str += ".zip" + output_path = Path(output_str) + + output_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + skill_md = skill_dir / "SKILL.md" + if skill_md.exists(): + instructions = skill_md.read_text(encoding="utf-8") + zf.writestr("system_instructions.txt", instructions) + + refs_dir = skill_dir / "references" + if refs_dir.exists(): + for ref_file in refs_dir.rglob("*.md"): + if ref_file.is_file() and not ref_file.name.startswith("."): + arcname = f"knowledge_files/{ref_file.name}" + zf.write(ref_file, arcname) + + metadata = { + "platform": "minimax", + "name": skill_dir.name, + "version": "1.0.0", + "created_with": "skill-seekers", + "model": "MiniMax-M2.7", + "api_base": self.DEFAULT_API_ENDPOINT, + } + + zf.writestr("minimax_metadata.json", json.dumps(metadata, indent=2)) + + return output_path + + def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]: + """ + Upload packaged skill to MiniMax AI. + + MiniMax uses an OpenAI-compatible chat completion API. + This method validates the package and prepares it for use + with the MiniMax API. + + Args: + package_path: Path to skill ZIP file + api_key: MiniMax API key + **kwargs: Additional arguments (model, etc.) + + Returns: + Dictionary with upload result + """ + package_path = Path(package_path) + if not package_path.exists(): + return { + "success": False, + "skill_id": None, + "url": None, + "message": f"File not found: {package_path}", + } + + if package_path.suffix != ".zip": + return { + "success": False, + "skill_id": None, + "url": None, + "message": f"Not a ZIP file: {package_path}", + } + + try: + from openai import OpenAI, APITimeoutError, APIConnectionError + except ImportError: + return { + "success": False, + "skill_id": None, + "url": None, + "message": "openai library not installed. Run: pip install openai", + } + + try: + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(package_path, "r") as zf: + zf.extractall(temp_dir) + + temp_path = Path(temp_dir) + + instructions_file = temp_path / "system_instructions.txt" + if not instructions_file.exists(): + return { + "success": False, + "skill_id": None, + "url": None, + "message": "Invalid package: system_instructions.txt not found", + } + + instructions = instructions_file.read_text(encoding="utf-8") + + metadata_file = temp_path / "minimax_metadata.json" + skill_name = package_path.stem + model = kwargs.get("model", "MiniMax-M2.7") + + if metadata_file.exists(): + with open(metadata_file) as f: + metadata = json.load(f) + skill_name = metadata.get("name", skill_name) + model = metadata.get("model", model) + + knowledge_dir = temp_path / "knowledge_files" + knowledge_count = 0 + if knowledge_dir.exists(): + knowledge_count = len(list(knowledge_dir.glob("*.md"))) + + client = OpenAI( + api_key=api_key, + base_url=self.DEFAULT_API_ENDPOINT, + ) + + client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": instructions}, + { + "role": "user", + "content": f"Confirm you are ready to assist with {skill_name}. Reply briefly.", + }, + ], + temperature=0.3, + max_tokens=100, + ) + + return { + "success": True, + "skill_id": None, + "url": "https://platform.minimaxi.com/", + "message": f"Skill '{skill_name}' validated with MiniMax {model} ({knowledge_count} knowledge files)", + } + + except APITimeoutError: + return { + "success": False, + "skill_id": None, + "url": None, + "message": "Upload timed out. Try again.", + } + except APIConnectionError: + return { + "success": False, + "skill_id": None, + "url": None, + "message": "Connection error. Check your internet connection.", + } + except Exception as e: + return { + "success": False, + "skill_id": None, + "url": None, + "message": f"Upload failed: {str(e)}", + } + + def validate_api_key(self, api_key: str) -> bool: + """ + Validate MiniMax API key format. + + MiniMax API keys are opaque strings. We only check for + a non-empty key with a reasonable minimum length. + + Args: + api_key: API key to validate + + Returns: + True if key format appears valid + """ + key = api_key.strip() + return len(key) > 10 + + def get_env_var_name(self) -> str: + """ + Get environment variable name for MiniMax API key. + + Returns: + 'MINIMAX_API_KEY' + """ + return "MINIMAX_API_KEY" + + def supports_enhancement(self) -> bool: + """ + MiniMax supports AI enhancement via MiniMax-M2.7. + + Returns: + True + """ + return True + + def enhance(self, skill_dir: Path, api_key: str) -> bool: + """ + Enhance SKILL.md using MiniMax-M2.7 API. + + Uses MiniMax's OpenAI-compatible API endpoint for enhancement. + + Args: + skill_dir: Path to skill directory + api_key: MiniMax API key + + Returns: + True if enhancement succeeded + """ + try: + from openai import OpenAI + except ImportError: + print("❌ Error: openai package not installed") + print("Install with: pip install openai") + return False + + skill_dir = Path(skill_dir) + references_dir = skill_dir / "references" + skill_md_path = skill_dir / "SKILL.md" + + print("📖 Reading reference documentation...") + references = self._read_reference_files(references_dir) + + if not references: + print("❌ No reference files found to analyze") + return False + + print(f" ✓ Read {len(references)} reference files") + total_size = sum(len(c) for c in references.values()) + print(f" ✓ Total size: {total_size:,} characters\n") + + current_skill_md = None + if skill_md_path.exists(): + current_skill_md = skill_md_path.read_text(encoding="utf-8") + print(f" ℹ Found existing SKILL.md ({len(current_skill_md)} chars)") + else: + print(" ℹ No existing SKILL.md, will create new one") + + prompt = self._build_enhancement_prompt(skill_dir.name, references, current_skill_md) + + print("\n🤖 Asking MiniMax-M2.7 to enhance SKILL.md...") + print(f" Input: {len(prompt):,} characters") + + try: + client = OpenAI( + api_key=api_key, + base_url="https://api.minimax.io/v1", + ) + + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + { + "role": "system", + "content": "You are an expert technical writer creating system instructions for MiniMax AI.", + }, + {"role": "user", "content": prompt}, + ], + temperature=0.3, + max_tokens=4096, + ) + + enhanced_content = response.choices[0].message.content + print(f" ✓ Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n") + + if skill_md_path.exists(): + backup_path = skill_md_path.with_suffix(".md.backup") + skill_md_path.rename(backup_path) + print(f" 💾 Backed up original to: {backup_path.name}") + + skill_md_path.write_text(enhanced_content, encoding="utf-8") + print(" ✅ Saved enhanced SKILL.md") + + return True + + except Exception as e: + print(f"❌ Error calling MiniMax API: {e}") + return False + + def _read_reference_files( + self, references_dir: Path, max_chars: int = 200000 + ) -> dict[str, str]: + """ + Read reference markdown files from skill directory. + + Args: + references_dir: Path to references directory + max_chars: Maximum total characters to read + + Returns: + Dictionary mapping filename to content + """ + if not references_dir.exists(): + return {} + + references = {} + total_chars = 0 + + for ref_file in sorted(references_dir.glob("*.md")): + if total_chars >= max_chars: + break + + try: + content = ref_file.read_text(encoding="utf-8") + if len(content) > 30000: + content = content[:30000] + "\n\n...(truncated)" + + references[ref_file.name] = content + total_chars += len(content) + + except Exception as e: + print(f" ⚠️ Could not read {ref_file.name}: {e}") + + return references + + def _build_enhancement_prompt( + self, skill_name: str, references: dict[str, str], current_skill_md: str = None + ) -> str: + """ + Build MiniMax API prompt for enhancement. + + Args: + skill_name: Name of the skill + references: Dictionary of reference content + current_skill_md: Existing SKILL.md content (optional) + + Returns: + Enhancement prompt for MiniMax-M2.7 + """ + prompt = f"""You are creating system instructions for a MiniMax AI assistant about: {skill_name} + +I've scraped documentation and organized it into reference files. Your job is to create EXCELLENT system instructions that will help the assistant use this documentation effectively. + +CURRENT INSTRUCTIONS: +{"```" if current_skill_md else "(none - create from scratch)"} +{current_skill_md or "No existing instructions"} +{"```" if current_skill_md else ""} + +REFERENCE DOCUMENTATION: +""" + + for filename, content in references.items(): + prompt += f"\n\n## {filename}\n```markdown\n{content[:30000]}\n```\n" + + prompt += """ + +YOUR TASK: +Create enhanced system instructions that include: + +1. **Clear role definition** - "You are an expert assistant for [topic]" +2. **Knowledge base description** - What documentation is attached +3. **Excellent Quick Reference** - Extract 5-10 of the BEST, most practical code examples from the reference docs + - Choose SHORT, clear examples that demonstrate common tasks + - Include both simple and intermediate examples + - Annotate examples with clear descriptions + - Use proper language tags (cpp, python, javascript, json, etc.) +4. **Response guidelines** - How the assistant should help users +5. **Search strategy** - How to find information in the knowledge base +6. **DO NOT use YAML frontmatter** - This is plain text instructions + +IMPORTANT: +- Extract REAL examples from the reference docs, don't make them up +- Prioritize SHORT, clear examples (5-20 lines max) +- Make it actionable and practical +- Write clear, direct instructions +- Focus on how the assistant should behave and respond +- NO YAML frontmatter (no --- blocks) + +OUTPUT: +Return ONLY the complete system instructions as plain text. +""" + + return prompt diff --git a/tests/test_adaptors/test_minimax_adaptor.py b/tests/test_adaptors/test_minimax_adaptor.py new file mode 100644 index 0000000..94098c6 --- /dev/null +++ b/tests/test_adaptors/test_minimax_adaptor.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +""" +Tests for MiniMax AI adaptor +""" + +import json +import os +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +try: + from openai import APITimeoutError, APIConnectionError +except ImportError: + APITimeoutError = None + APIConnectionError = None + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available +from skill_seekers.cli.adaptors.base import SkillMetadata + + +class TestMiniMaxAdaptor(unittest.TestCase): + """Test MiniMax AI adaptor functionality""" + + def setUp(self): + """Set up test adaptor""" + self.adaptor = get_adaptor("minimax") + + def test_platform_info(self): + """Test platform identifiers""" + self.assertEqual(self.adaptor.PLATFORM, "minimax") + self.assertEqual(self.adaptor.PLATFORM_NAME, "MiniMax AI") + self.assertIsNotNone(self.adaptor.DEFAULT_API_ENDPOINT) + self.assertIn("minimax", self.adaptor.DEFAULT_API_ENDPOINT) + + def test_platform_available(self): + """Test that minimax platform is registered""" + self.assertTrue(is_platform_available("minimax")) + + def test_validate_api_key_valid(self): + """Test valid MiniMax API keys (any string >10 chars)""" + self.assertTrue( + self.adaptor.validate_api_key("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test.key") + ) + self.assertTrue(self.adaptor.validate_api_key("sk-some-long-api-key-string-here")) + self.assertTrue(self.adaptor.validate_api_key(" a-valid-key-with-spaces ")) + + def test_validate_api_key_invalid(self): + """Test invalid API keys""" + self.assertFalse(self.adaptor.validate_api_key("")) + self.assertFalse(self.adaptor.validate_api_key(" ")) + self.assertFalse(self.adaptor.validate_api_key("short")) + + def test_get_env_var_name(self): + """Test environment variable name""" + self.assertEqual(self.adaptor.get_env_var_name(), "MINIMAX_API_KEY") + + def test_supports_enhancement(self): + """Test enhancement support""" + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_format_skill_md_no_frontmatter(self): + """Test that MiniMax format has no YAML frontmatter""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test content") + + metadata = SkillMetadata(name="test-skill", description="Test skill description") + + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + self.assertFalse(formatted.startswith("---")) + self.assertIn("You are an expert assistant", formatted) + self.assertIn("test-skill", formatted) + self.assertIn("Test skill description", formatted) + + def test_format_skill_md_with_existing_content(self): + """Test formatting when SKILL.md already has substantial content""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + + (skill_dir / "references").mkdir() + existing_content = "# Existing Content\n\n" + "x" * 200 + (skill_dir / "SKILL.md").write_text(existing_content) + + metadata = SkillMetadata(name="test-skill", description="Test description") + + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + self.assertIn("You are an expert assistant", formatted) + self.assertIn("test-skill", formatted) + + def test_format_skill_md_without_references(self): + """Test formatting without references directory""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + + metadata = SkillMetadata(name="test-skill", description="Test description") + + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + self.assertIn("You are an expert assistant", formatted) + self.assertIn("test-skill", formatted) + + def test_package_creates_zip(self): + """Test that package creates ZIP file with correct structure""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + + (skill_dir / "SKILL.md").write_text("You are an expert assistant") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Reference") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue(package_path.exists()) + self.assertTrue(str(package_path).endswith(".zip")) + self.assertIn("minimax", package_path.name) + + with zipfile.ZipFile(package_path, "r") as zf: + names = zf.namelist() + self.assertIn("system_instructions.txt", names) + self.assertIn("minimax_metadata.json", names) + self.assertTrue(any("knowledge_files" in name for name in names)) + + def test_package_metadata_content(self): + """Test that packaged ZIP contains correct metadata""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + + (skill_dir / "SKILL.md").write_text("Test instructions") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "guide.md").write_text("# User Guide") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + with zipfile.ZipFile(package_path, "r") as zf: + instructions = zf.read("system_instructions.txt").decode("utf-8") + self.assertEqual(instructions, "Test instructions") + + self.assertIn("knowledge_files/guide.md", zf.namelist()) + + metadata_content = zf.read("minimax_metadata.json").decode("utf-8") + metadata = json.loads(metadata_content) + self.assertEqual(metadata["platform"], "minimax") + self.assertEqual(metadata["name"], "test-skill") + self.assertEqual(metadata["model"], "MiniMax-M2.7") + self.assertIn("minimax", metadata["api_base"]) + + def test_package_output_path_as_file(self): + """Test packaging when output_path is a file path""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + + output_file = Path(temp_dir) / "output" / "custom-name-minimax.zip" + output_file.parent.mkdir(parents=True, exist_ok=True) + + package_path = self.adaptor.package(skill_dir, output_file) + + self.assertTrue(package_path.exists()) + self.assertTrue(str(package_path).endswith(".zip")) + + def test_package_without_references(self): + """Test packaging without reference files""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test instructions") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue(package_path.exists()) + with zipfile.ZipFile(package_path, "r") as zf: + names = zf.namelist() + self.assertIn("system_instructions.txt", names) + self.assertIn("minimax_metadata.json", names) + self.assertFalse(any("knowledge_files" in name for name in names)) + + def test_upload_missing_library(self): + """Test upload when openai library is not installed""" + with tempfile.NamedTemporaryFile(suffix=".zip") as tmp: + with patch.dict(sys.modules, {"openai": None}): + result = self.adaptor.upload(Path(tmp.name), "test-api-key") + + self.assertFalse(result["success"]) + self.assertIn("openai", result["message"]) + self.assertIn("not installed", result["message"]) + + def test_upload_invalid_file(self): + """Test upload with invalid file""" + result = self.adaptor.upload(Path("/nonexistent/file.zip"), "test-api-key") + + self.assertFalse(result["success"]) + self.assertIn("not found", result["message"].lower()) + + def test_upload_wrong_format(self): + """Test upload with wrong file format""" + with tempfile.NamedTemporaryFile(suffix=".tar.gz") as tmp: + result = self.adaptor.upload(Path(tmp.name), "test-api-key") + + self.assertFalse(result["success"]) + self.assertIn("not a zip", result["message"].lower()) + + @unittest.skip("covered by test_upload_success_mocked") + def test_upload_success(self): + """Test successful upload - skipped (needs real API for integration test)""" + pass + + def test_enhance_missing_references(self): + """Test enhance when no reference files exist""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + + success = self.adaptor.enhance(skill_dir, "test-api-key") + self.assertFalse(success) + + @patch("openai.OpenAI") + def test_enhance_success_mocked(self, mock_openai_class): + """Test successful enhancement with mocked OpenAI client""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Enhanced SKILL.md content" + mock_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_client + + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + refs_dir = skill_dir / "references" + refs_dir.mkdir() + (refs_dir / "test.md").write_text("# Test\nContent") + (skill_dir / "SKILL.md").write_text("Original content") + + success = self.adaptor.enhance(skill_dir, "test-api-key") + + self.assertTrue(success) + new_content = (skill_dir / "SKILL.md").read_text() + self.assertEqual(new_content, "Enhanced SKILL.md content") + backup = skill_dir / "SKILL.md.backup" + self.assertTrue(backup.exists()) + self.assertEqual(backup.read_text(), "Original content") + mock_client.chat.completions.create.assert_called_once() + + def test_enhance_missing_library(self): + """Test enhance when openai library is not installed""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + refs_dir = skill_dir / "references" + refs_dir.mkdir() + (refs_dir / "test.md").write_text("Test content") + + with patch.dict(sys.modules, {"openai": None}): + success = self.adaptor.enhance(skill_dir, "test-api-key") + + self.assertFalse(success) + + def test_read_reference_files(self): + """Test reading reference files""" + with tempfile.TemporaryDirectory() as temp_dir: + refs_dir = Path(temp_dir) + (refs_dir / "guide.md").write_text("# Guide\nContent here") + (refs_dir / "api.md").write_text("# API\nAPI docs") + + references = self.adaptor._read_reference_files(refs_dir) + + self.assertEqual(len(references), 2) + self.assertIn("guide.md", references) + self.assertIn("api.md", references) + + def test_read_reference_files_empty_dir(self): + """Test reading from empty references directory""" + with tempfile.TemporaryDirectory() as temp_dir: + references = self.adaptor._read_reference_files(Path(temp_dir)) + self.assertEqual(len(references), 0) + + def test_read_reference_files_nonexistent(self): + """Test reading from nonexistent directory""" + references = self.adaptor._read_reference_files(Path("/nonexistent/path")) + self.assertEqual(len(references), 0) + + def test_read_reference_files_truncation(self): + """Test that large reference files are truncated""" + with tempfile.TemporaryDirectory() as temp_dir: + (Path(temp_dir) / "large.md").write_text("x" * 50000) + + references = self.adaptor._read_reference_files(Path(temp_dir)) + + self.assertIn("large.md", references) + self.assertIn("truncated", references["large.md"]) + self.assertLessEqual(len(references["large.md"]), 31000) + + def test_build_enhancement_prompt(self): + """Test enhancement prompt generation""" + references = { + "guide.md": "# User Guide\nContent here", + "api.md": "# API Reference\nAPI docs", + } + + prompt = self.adaptor._build_enhancement_prompt( + "test-skill", references, "Existing SKILL.md content" + ) + + self.assertIn("test-skill", prompt) + self.assertIn("guide.md", prompt) + self.assertIn("api.md", prompt) + self.assertIn("Existing SKILL.md content", prompt) + self.assertIn("MiniMax", prompt) + + def test_build_enhancement_prompt_no_existing(self): + """Test enhancement prompt when no existing SKILL.md""" + references = {"test.md": "# Test\nContent"} + + prompt = self.adaptor._build_enhancement_prompt("test-skill", references, None) + + self.assertIn("test-skill", prompt) + self.assertIn("create from scratch", prompt) + + def test_config_initialization(self): + """Test adaptor initializes with config""" + config = {"custom_model": "MiniMax-M2.5"} + adaptor = get_adaptor("minimax", config) + self.assertEqual(adaptor.config, config) + + def test_default_config(self): + """Test adaptor initializes with empty config by default""" + self.assertEqual(self.adaptor.config, {}) + + def test_package_excludes_backup_files(self): + """Test that backup files are excluded from package""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + + (skill_dir / "SKILL.md").write_text("Test instructions") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "guide.md").write_text("# Guide") + (skill_dir / "references" / "guide.md.backup").write_text("# Old backup") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + with zipfile.ZipFile(package_path, "r") as zf: + names = zf.namelist() + self.assertIn("knowledge_files/guide.md", names) + self.assertNotIn("knowledge_files/guide.md.backup", names) + + @patch("openai.OpenAI") + def test_upload_success_mocked(self, mock_openai_class): + """Test successful upload with mocked OpenAI client""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Ready to assist with Python testing" + mock_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_client + + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("You are an expert assistant") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + result = self.adaptor.upload(package_path, "test-long-api-key-string") + + self.assertTrue(result["success"]) + self.assertIn("validated", result["message"]) + self.assertEqual(result["url"], "https://platform.minimaxi.com/") + mock_client.chat.completions.create.assert_called_once() + + @unittest.skipUnless(APITimeoutError, "openai library not installed") + @patch("openai.OpenAI") + def test_upload_network_error(self, mock_openai_class): + """Test upload with network timeout error""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = APITimeoutError(request=MagicMock()) + mock_openai_class.return_value = mock_client + + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("Content") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + result = self.adaptor.upload(package_path, "test-long-api-key-string") + + self.assertFalse(result["success"]) + self.assertIn("timed out", result["message"].lower()) + + @unittest.skipUnless(APIConnectionError, "openai library not installed") + @patch("openai.OpenAI") + def test_upload_connection_error(self, mock_openai_class): + """Test upload with connection error""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = APIConnectionError(request=MagicMock()) + mock_openai_class.return_value = mock_client + + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("Content") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + result = self.adaptor.upload(package_path, "test-long-api-key-string") + + self.assertFalse(result["success"]) + self.assertIn("connection", result["message"].lower()) + + def test_validate_api_key_format(self): + """Test that API key validation uses length-based check""" + # Valid - long enough strings + self.assertTrue(self.adaptor.validate_api_key("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test")) + self.assertTrue(self.adaptor.validate_api_key("sk-api-abc123-long-enough")) + # Invalid - too short + self.assertFalse(self.adaptor.validate_api_key("eyJshort")) + self.assertFalse(self.adaptor.validate_api_key("short")) + + +class TestMiniMaxAdaptorIntegration(unittest.TestCase): + """Integration tests for MiniMax AI adaptor (require MINIMAX_API_KEY)""" + + def setUp(self): + """Set up test adaptor""" + self.adaptor = get_adaptor("minimax") + + @unittest.skipUnless( + os.getenv("MINIMAX_API_KEY"), "MINIMAX_API_KEY not set - skipping integration test" + ) + def test_enhance_with_real_api(self): + """Test enhancement with real MiniMax API""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + refs_dir = skill_dir / "references" + refs_dir.mkdir() + (refs_dir / "test.md").write_text( + "# Python Testing\n\n" + "Use pytest for testing:\n" + "```python\n" + "def test_example():\n" + " assert 1 + 1 == 2\n" + "```\n" + ) + + api_key = os.getenv("MINIMAX_API_KEY") + success = self.adaptor.enhance(skill_dir, api_key) + + self.assertTrue(success) + skill_md = (skill_dir / "SKILL.md").read_text() + self.assertTrue(len(skill_md) > 100) + + @unittest.skipUnless( + os.getenv("MINIMAX_API_KEY"), "MINIMAX_API_KEY not set - skipping integration test" + ) + def test_upload_with_real_api(self): + """Test upload validation with real MiniMax API""" + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("You are an expert assistant for Python testing.") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test\nContent") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + api_key = os.getenv("MINIMAX_API_KEY") + result = self.adaptor.upload(package_path, api_key) + + self.assertTrue(result["success"]) + self.assertIn("validated", result["message"]) + + @unittest.skipUnless( + os.getenv("MINIMAX_API_KEY"), "MINIMAX_API_KEY not set - skipping integration test" + ) + def test_validate_api_key_real(self): + """Test validating a real API key""" + api_key = os.getenv("MINIMAX_API_KEY") + self.assertTrue(self.adaptor.validate_api_key(api_key)) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index ff87560..a754fd0 100644 --- a/uv.lock +++ b/uv.lock @@ -5699,7 +5699,7 @@ wheels = [ [[package]] name = "skill-seekers" -version = "3.2.0" +version = "3.3.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -5816,6 +5816,9 @@ mcp = [ { name = "starlette" }, { name = "uvicorn" }, ] +minimax = [ + { name = "openai" }, +] notion = [ { name = "notion-client" }, ] @@ -5930,6 +5933,7 @@ requires-dist = [ { name = "numpy", marker = "extra == 'embedding'", specifier = ">=1.24.0" }, { name = "openai", marker = "extra == 'all'", specifier = ">=1.0.0" }, { name = "openai", marker = "extra == 'all-llms'", specifier = ">=1.0.0" }, + { name = "openai", marker = "extra == 'minimax'", specifier = ">=1.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.0.0" }, { name = "opencv-python-headless", marker = "extra == 'video-full'", specifier = ">=4.9.0" }, { name = "pathspec", specifier = ">=0.12.1" }, @@ -5978,7 +5982,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", "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", "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"] [package.metadata.requires-dev] dev = [ From f6131c67983a90bf4b8f472497240c0cedbafccf Mon Sep 17 00:00:00 2001 From: yusyus Date: Fri, 20 Mar 2026 22:35:12 +0300 Subject: [PATCH 05/21] fix: unified scraper temp config uses unified format for doc_scraper (#317) The unified scraper's _scrape_documentation() was creating temp configs in flat/legacy format (no "sources" key), causing doc_scraper's ConfigValidator to reject them. Wrap the temp config in unified format with a "sources" array. Also remove dead code branches and fix a pre-existing test that didn't clear GITHUB_TOKEN from env. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/skill_seekers/cli/doc_scraper.py | 2 -- src/skill_seekers/cli/unified_scraper.py | 23 ++++++++++++--------- tests/test_github_scraper.py | 7 ++++++- tests/test_unified_scraper_orchestration.py | 12 +++++++++-- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/skill_seekers/cli/doc_scraper.py b/src/skill_seekers/cli/doc_scraper.py index cbe4908..0fa3ba3 100755 --- a/src/skill_seekers/cli/doc_scraper.py +++ b/src/skill_seekers/cli/doc_scraper.py @@ -1970,8 +1970,6 @@ def load_config(config_path: str) -> dict[str, Any]: # Log config type if validator.is_unified: logger.debug("✓ Unified config format detected") - else: - logger.debug("✓ Legacy config format detected") except ValueError as e: logger.error("❌ Configuration validation errors in %s:", config_path) logger.error(" %s", str(e)) diff --git a/src/skill_seekers/cli/unified_scraper.py b/src/skill_seekers/cli/unified_scraper.py index c413176..02057c4 100644 --- a/src/skill_seekers/cli/unified_scraper.py +++ b/src/skill_seekers/cli/unified_scraper.py @@ -165,10 +165,6 @@ class UnifiedScraper: logger.info("PHASE 1: Scraping all sources") logger.info("=" * 60) - if not self.validator.is_unified: - logger.warning("Config is not unified format, converting...") - self.config = self.validator.convert_legacy_to_unified() - sources = self.config.get("sources", []) for i, source in enumerate(sources): @@ -220,9 +216,10 @@ class UnifiedScraper: def _scrape_documentation(self, source: dict[str, Any]): """Scrape documentation website.""" - # Create temporary config for doc scraper - doc_config = { - "name": f"{self.name}_docs", + # Create temporary config for doc scraper in unified format + # (doc_scraper's ConfigValidator requires "sources" key) + doc_source = { + "type": "documentation", "base_url": source["base_url"], "selectors": source.get("selectors", {}), "url_patterns": source.get("url_patterns", {}), @@ -233,14 +230,20 @@ class UnifiedScraper: # Pass through llms.txt settings (so unified configs behave the same as doc_scraper configs) if "llms_txt_url" in source: - doc_config["llms_txt_url"] = source.get("llms_txt_url") + doc_source["llms_txt_url"] = source["llms_txt_url"] if "skip_llms_txt" in source: - doc_config["skip_llms_txt"] = source.get("skip_llms_txt") + doc_source["skip_llms_txt"] = source["skip_llms_txt"] # Optional: support overriding start URLs if "start_urls" in source: - doc_config["start_urls"] = source.get("start_urls") + doc_source["start_urls"] = source["start_urls"] + + doc_config = { + "name": f"{self.name}_docs", + "description": f"Documentation for {self.name}", + "sources": [doc_source], + } # Write temporary config temp_config_path = os.path.join(self.data_dir, "temp_docs_config.json") diff --git a/tests/test_github_scraper.py b/tests/test_github_scraper.py index 149e171..9909233 100644 --- a/tests/test_github_scraper.py +++ b/tests/test_github_scraper.py @@ -68,7 +68,12 @@ class TestGitHubScraperInitialization(unittest.TestCase): "github_token": "test_token_123", } - with patch("skill_seekers.cli.github_scraper.Github") as mock_github: + # Clear GITHUB_TOKEN env var so config token is used (env takes priority) + env = {k: v for k, v in os.environ.items() if k != "GITHUB_TOKEN"} + with ( + patch.dict(os.environ, env, clear=True), + patch("skill_seekers.cli.github_scraper.Github") as mock_github, + ): _scraper = self.GitHubScraper(config) mock_github.assert_called_once_with("test_token_123") diff --git a/tests/test_unified_scraper_orchestration.py b/tests/test_unified_scraper_orchestration.py index e6d431f..02309fc 100644 --- a/tests/test_unified_scraper_orchestration.py +++ b/tests/test_unified_scraper_orchestration.py @@ -224,7 +224,11 @@ class TestScrapeDocumentation: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") scraper._scrape_documentation(source) - assert any("llms_txt_url" in c for c in written_configs) + assert any( + "llms_txt_url" in s + for c in written_configs + for s in c.get("sources", [c]) + ) def test_start_urls_forwarded_to_doc_config(self, tmp_path): """start_urls from source is forwarded to the temporary doc config.""" @@ -247,7 +251,11 @@ class TestScrapeDocumentation: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") scraper._scrape_documentation(source) - assert any("start_urls" in c for c in written_configs) + assert any( + "start_urls" in s + for c in written_configs + for s in c.get("sources", [c]) + ) # =========================================================================== From 2ef6e59d06c1a4e5a97183839eea4a1ebcb38a08 Mon Sep 17 00:00:00 2001 From: yusyus Date: Fri, 20 Mar 2026 23:44:35 +0300 Subject: [PATCH 06/21] fix: stop blindly appending /index.html.md to non-.md URLs (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (a82cf69) only addressed anchor fragment stripping but left the fundamental problem: _convert_to_md_urls() blindly appended /index.html.md to ALL non-.md URLs from llms.txt. This only works for Docusaurus sites — for sites like Discord docs it generates mass 404s. Changes: - _convert_to_md_urls() now strips anchors and deduplicates only, preserving original URLs as-is instead of appending /index.html.md - New _has_md_extension() helper uses urlparse().path.endswith(".md") instead of error-prone ".md" in url substring matching - Fixed ".md" in url checks at 4 locations (lines 465, 554, 716, 775) - Removed 24 lines of dead commented-out code - Added real-world e2e test against docs.discord.com (no mocks) - Updated unit tests for new behavior (32 tests) Fixes #277 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/skill_seekers/cli/doc_scraper.py | 101 ++++++------- tests/test_issue_277_discord_e2e.py | 146 +++++++++++++++++++ tests/test_issue_277_real_world.py | 203 ++++++++++++++++----------- tests/test_url_conversion.py | 127 +++++++++++++---- 4 files changed, 408 insertions(+), 169 deletions(-) create mode 100644 tests/test_issue_277_discord_e2e.py diff --git a/src/skill_seekers/cli/doc_scraper.py b/src/skill_seekers/cli/doc_scraper.py index 0fa3ba3..3490cc6 100755 --- a/src/skill_seekers/cli/doc_scraper.py +++ b/src/skill_seekers/cli/doc_scraper.py @@ -252,6 +252,15 @@ class DocToSkillConverter: return not any(pattern in url for pattern in self._exclude_patterns) + @staticmethod + def _has_md_extension(url: str) -> bool: + """Check if URL path ends with .md extension. + + Uses URL path parsing instead of substring matching to avoid + false positives on URLs like /embed/page or /cmd-line. + """ + return urlparse(url).path.endswith(".md") + def save_checkpoint(self) -> None: """Save progress checkpoint""" if not self.checkpoint_enabled or self.dry_run: @@ -462,7 +471,7 @@ class DocToSkillConverter: else: continue full_url = full_url.split("#")[0] - if ".md" in full_url and self.is_valid_url(full_url) and full_url not in links: + if self._has_md_extension(full_url) and self.is_valid_url(full_url) and full_url not in links: links.append(full_url) return { @@ -551,7 +560,7 @@ class DocToSkillConverter: # Strip anchor fragments full_url = full_url.split("#")[0] # Only include .md URLs to avoid client-side rendered HTML pages - if ".md" in full_url and self.is_valid_url(full_url) and full_url not in page["links"]: + if self._has_md_extension(full_url) and self.is_valid_url(full_url) and full_url not in page["links"]: page["links"].append(full_url) return page @@ -713,7 +722,7 @@ class DocToSkillConverter: response.raise_for_status() # Check if this is a Markdown file - if url.endswith(".md") or ".md" in url: + if self._has_md_extension(url): page = self._extract_markdown_content(response.text, url) else: soup = BeautifulSoup(response.content, "html.parser") @@ -772,7 +781,7 @@ class DocToSkillConverter: response.raise_for_status() # Check if this is a Markdown file - if url.endswith(".md") or ".md" in url: + if self._has_md_extension(url): page = self._extract_markdown_content(response.text, url) else: # BeautifulSoup parsing (still synchronous, but fast) @@ -798,71 +807,45 @@ class DocToSkillConverter: def _convert_to_md_urls(self, urls: list[str]) -> list[str]: """ - Convert URLs to .md format, trying /index.html.md suffix for non-.md URLs. - Strips anchor fragments (#anchor) and deduplicates base URLs to avoid 404 errors. - 不预先检查 URL 是否存在,直接加入队列,在爬取时再验证。 + Clean URLs from llms.txt: strip anchor fragments, deduplicate base URLs. + + Previously this method blindly appended /index.html.md to non-.md URLs, + which caused 404 errors on sites that don't serve raw markdown files + (e.g. Discord docs, see issue #277). Now it preserves original URLs as-is + and lets the scraper handle both HTML and markdown content. Args: urls: List of URLs to process Returns: - List of .md URLs (未验证, deduplicated, no anchors) + List of cleaned, deduplicated URLs (no anchors) """ from urllib.parse import urlparse, urlunparse seen_base_urls = set() - md_urls = [] + cleaned_urls = [] for url in urls: # Parse URL to extract and remove fragment (anchor) parsed = urlparse(url) base_url = urlunparse(parsed._replace(fragment="")) # Remove #anchor - # Skip if we've already processed this base URL - if base_url in seen_base_urls: - continue - seen_base_urls.add(base_url) + # Normalize trailing slashes for dedup (but keep original form) + dedup_key = base_url.rstrip("/") - # Check if URL already ends with .md (not just contains "md") - if base_url.endswith(".md"): - md_urls.append(base_url) - else: - # 直接转换为 .md 格式,不发送 HEAD 请求检查 - base_url = base_url.rstrip("/") - md_url = f"{base_url}/index.html.md" - md_urls.append(md_url) + # Skip if we've already processed this base URL + if dedup_key in seen_base_urls: + continue + seen_base_urls.add(dedup_key) + + cleaned_urls.append(base_url) logger.info( - " ✓ Converted %d URLs to %d unique .md URLs (anchors stripped, will validate during crawl)", + " ✓ Cleaned %d URLs to %d unique URLs (anchors stripped, will validate during crawl)", len(urls), - len(md_urls), + len(cleaned_urls), ) - return md_urls - - # ORIGINAL _convert_to_md_urls (with HEAD request validation): - # def _convert_to_md_urls(self, urls: List[str]) -> List[str]: - # md_urls = [] - # non_md_urls = [] - # for url in urls: - # if '.md' in url: - # md_urls.append(url) - # else: - # non_md_urls.append(url) - # if non_md_urls: - # logger.info(" 🔄 Trying to convert %d non-.md URLs to .md format...", len(non_md_urls)) - # converted = 0 - # for url in non_md_urls: - # url = url.rstrip('/') - # md_url = f"{url}/index.html.md" - # try: - # resp = requests.head(md_url, timeout=5, allow_redirects=True) - # if resp.status_code == 200: - # md_urls.append(md_url) - # converted += 1 - # except Exception: - # pass - # logger.info(" ✓ Converted %d URLs to .md format", converted) - # return md_urls + return cleaned_urls def _try_llms_txt(self) -> bool: """ @@ -933,16 +916,16 @@ class DocToSkillConverter: # Extract URLs from llms.txt and add to pending_urls for BFS crawling extracted_urls = parser.extract_urls() if extracted_urls: - # Convert non-.md URLs to .md format by trying /index.html.md suffix - md_urls = self._convert_to_md_urls(extracted_urls) + # Clean URLs: strip anchors, deduplicate + cleaned_urls = self._convert_to_md_urls(extracted_urls) logger.info( - "\n🔗 Found %d URLs in llms.txt (%d .md files), starting BFS crawl...", + "\n🔗 Found %d URLs in llms.txt (%d unique), starting BFS crawl...", len(extracted_urls), - len(md_urls), + len(cleaned_urls), ) # Filter URLs based on url_patterns config - for url in md_urls: + for url in cleaned_urls: if self.is_valid_url(url): self._enqueue_url(url) @@ -1019,16 +1002,16 @@ class DocToSkillConverter: # Extract URLs from llms.txt and add to pending_urls for BFS crawling extracted_urls = parser.extract_urls() if extracted_urls: - # Convert non-.md URLs to .md format by trying /index.html.md suffix - md_urls = self._convert_to_md_urls(extracted_urls) + # Clean URLs: strip anchors, deduplicate + cleaned_urls = self._convert_to_md_urls(extracted_urls) logger.info( - "\n🔗 Found %d URLs in llms.txt (%d .md files), starting BFS crawl...", + "\n🔗 Found %d URLs in llms.txt (%d unique), starting BFS crawl...", len(extracted_urls), - len(md_urls), + len(cleaned_urls), ) # Filter URLs based on url_patterns config - for url in md_urls: + for url in cleaned_urls: if self.is_valid_url(url): self._enqueue_url(url) diff --git a/tests/test_issue_277_discord_e2e.py b/tests/test_issue_277_discord_e2e.py new file mode 100644 index 0000000..dcd1e9f --- /dev/null +++ b/tests/test_issue_277_discord_e2e.py @@ -0,0 +1,146 @@ +""" +E2E test for Issue #277 - Discord docs case reported by @skeith. + +This test hits the REAL Discord docs llms.txt and verifies that +no /index.html.md URLs are generated. No mocks. + +Requires network access. Marked as integration test. +""" + +import os +import shutil +import unittest + +import pytest + +from skill_seekers.cli.doc_scraper import DocToSkillConverter +from skill_seekers.cli.llms_txt_detector import LlmsTxtDetector +from skill_seekers.cli.llms_txt_downloader import LlmsTxtDownloader +from skill_seekers.cli.llms_txt_parser import LlmsTxtParser + + +@pytest.mark.integration +class TestIssue277DiscordDocsE2E(unittest.TestCase): + """E2E: Reproduce @skeith's report with real Discord docs.""" + + def setUp(self): + self.base_url = "https://docs.discord.com/" + self.config = { + "name": "DiscordDocsE2E", + "description": "Discord API Documentation", + "base_url": self.base_url, + "selectors": {"main_content": "article"}, + "url_patterns": {"include": ["/developers"], "exclude": []}, + } + self.output_dir = f"output/{self.config['name']}_data" + + def tearDown(self): + # Clean up any output created + for path in [self.output_dir, f"output/{self.config['name']}"]: + if os.path.exists(path): + shutil.rmtree(path) + + def test_discord_llms_txt_exists(self): + """Verify Discord docs has llms.txt (precondition for the bug).""" + detector = LlmsTxtDetector(self.base_url) + variants = detector.detect_all() + self.assertTrue( + len(variants) > 0, + "Discord docs should have at least one llms.txt variant", + ) + + def test_discord_llms_txt_urls_no_index_html_md(self): + """Core test: URLs extracted from Discord llms.txt must NOT get /index.html.md appended.""" + # Step 1: Detect llms.txt + detector = LlmsTxtDetector(self.base_url) + variants = detector.detect_all() + self.assertTrue(len(variants) > 0, "No llms.txt found at docs.discord.com") + + # Step 2: Download the largest variant (same logic as doc_scraper) + downloaded = {} + for variant_info in variants: + downloader = LlmsTxtDownloader(variant_info["url"]) + content = downloader.download() + if content: + downloaded[variant_info["variant"]] = content + + self.assertTrue(len(downloaded) > 0, "Failed to download any llms.txt variant") + + largest_content = max(downloaded.values(), key=len) + + # Step 3: Parse URLs from llms.txt + parser = LlmsTxtParser(largest_content, self.base_url) + extracted_urls = parser.extract_urls() + self.assertTrue( + len(extracted_urls) > 0, + "No URLs extracted from Discord llms.txt", + ) + + # Step 4: Run _convert_to_md_urls (the function that was causing 404s) + converter = DocToSkillConverter(self.config, dry_run=True) + converted_urls = converter._convert_to_md_urls(extracted_urls) + + # Step 5: Verify NO /index.html.md was blindly appended + bad_urls = [u for u in converted_urls if "/index.html.md" in u] + self.assertEqual( + len(bad_urls), + 0, + f"Found {len(bad_urls)} URLs with /index.html.md appended " + f"(would cause 404s):\n" + + "\n".join(bad_urls[:10]), + ) + + # Step 6: Verify no anchor fragments leaked through + anchor_urls = [u for u in converted_urls if "#" in u] + self.assertEqual( + len(anchor_urls), + 0, + f"Found {len(anchor_urls)} URLs with anchor fragments:\n" + + "\n".join(anchor_urls[:10]), + ) + + # Step 7: Verify we got a reasonable number of URLs + self.assertGreater( + len(converted_urls), + 10, + "Expected at least 10 unique URLs from Discord docs", + ) + + def test_discord_full_pipeline_no_404_urls(self): + """Full pipeline: detector -> downloader -> parser -> converter -> queue. + + Simulates what `skill-seekers create https://docs.discord.com` does, + without actually scraping pages. + """ + converter = DocToSkillConverter(self.config, dry_run=True) + + # Run _try_llms_txt which calls _convert_to_md_urls internally + os.makedirs(os.path.join(converter.skill_dir, "references"), exist_ok=True) + os.makedirs(os.path.join(converter.data_dir, "pages"), exist_ok=True) + result = converter._try_llms_txt() + + # _try_llms_txt returns False when it populates pending_urls for BFS + # (True means it parsed content directly, no BFS needed) + if not result: + # Check every URL in the queue + for url in converter.pending_urls: + self.assertNotIn( + "/index.html.md", + url, + f"Queue contains 404-causing URL: {url}", + ) + self.assertNotIn( + "#", + url, + f"Queue contains URL with anchor fragment: {url}", + ) + + self.assertGreater( + len(converter.pending_urls), + 0, + "Pipeline should have queued URLs for crawling", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_277_real_world.py b/tests/test_issue_277_real_world.py index 1c15f30..da65d7f 100644 --- a/tests/test_issue_277_real_world.py +++ b/tests/test_issue_277_real_world.py @@ -1,6 +1,9 @@ """ -Real-world integration test for Issue #277: URL conversion bug with anchor fragments. -Tests the exact MikroORM case that was reported in the issue. +Real-world integration test for Issue #277: URL conversion bug with anchor fragments +and blind /index.html.md appending. + +Tests the exact MikroORM and Discord cases reported in the issue. +Updated: _convert_to_md_urls no longer appends /index.html.md to non-.md URLs. """ import unittest @@ -28,7 +31,6 @@ class TestIssue277RealWorld(unittest.TestCase): def test_mikro_orm_urls_from_issue_277(self): """Test the exact URLs that caused 404 errors in issue #277""" - # These are the actual problematic URLs from the bug report urls_from_llms_txt = [ "https://mikro-orm.io/docs/", "https://mikro-orm.io/docs/reference.md", @@ -44,54 +46,23 @@ class TestIssue277RealWorld(unittest.TestCase): # Verify no malformed URLs with anchor fragments for url in result: - self.assertNotIn( - "#synchronous-initialization/index.html.md", - url, - "Should not append /index.html.md after anchor fragments", - ) - self.assertNotIn( - "#formulas/index.html.md", - url, - "Should not append /index.html.md after anchor fragments", - ) - self.assertNotIn( - "#postgresql-native-enums/index.html.md", - url, - "Should not append /index.html.md after anchor fragments", - ) + self.assertNotIn("#", url, f"URL should not contain anchor: {url}") + # No /index.html.md should be appended to non-.md URLs + if not url.endswith(".md"): + self.assertNotIn( + "index.html.md", url, f"Should not append /index.html.md: {url}" + ) - # Verify correct transformed URLs - - # Check that we got the expected number of unique URLs - # Note: defining-entities has both .md and non-.md versions, so we have 2 entries for it - self.assertEqual( - len(result), - 7, - f"Should have 7 unique base URLs after deduplication, got {len(result)}", - ) - - # Verify specific URLs that were causing 404s are now correct - self.assertIn( - "https://mikro-orm.io/docs/quick-start/index.html.md", - result, - "quick-start URL should be correctly transformed", - ) - self.assertIn( - "https://mikro-orm.io/docs/propagation/index.html.md", - result, - "propagation URL should be correctly transformed", - ) - self.assertIn( - "https://mikro-orm.io/docs/defining-entities.md", - result, - "defining-entities.md should preserve .md extension", - ) + # .md URLs preserved, non-.md URLs preserved as-is, anchors deduplicated + self.assertIn("https://mikro-orm.io/docs/reference.md", result) + self.assertIn("https://mikro-orm.io/docs/repositories.md", result) + self.assertIn("https://mikro-orm.io/docs/defining-entities.md", result) + self.assertIn("https://mikro-orm.io/docs/quick-start", result) + self.assertIn("https://mikro-orm.io/docs/propagation", result) def test_no_404_causing_urls_generated(self): """Verify that no URLs matching the 404 error pattern are generated""" - # The exact 404-causing URL pattern from the issue problematic_patterns = [ - "/index.html.md#", # /index.html.md should never come after # "#synchronous-initialization/index.html.md", "#formulas/index.html.md", "#postgresql-native-enums/index.html.md", @@ -118,9 +89,30 @@ class TestIssue277RealWorld(unittest.TestCase): f"URL '{url}' contains problematic pattern '{pattern}' that causes 404", ) + def test_no_blind_index_html_md_appending(self): + """Verify non-.md URLs don't get /index.html.md appended (core fix)""" + urls = [ + "https://mikro-orm.io/docs/quick-start", + "https://mikro-orm.io/docs/propagation", + "https://mikro-orm.io/docs/filters", + ] + + result = self.converter._convert_to_md_urls(urls) + + self.assertEqual(len(result), 3) + for url in result: + self.assertNotIn( + "/index.html.md", + url, + f"Should not blindly append /index.html.md: {url}", + ) + + self.assertEqual(result[0], "https://mikro-orm.io/docs/quick-start") + self.assertEqual(result[1], "https://mikro-orm.io/docs/propagation") + self.assertEqual(result[2], "https://mikro-orm.io/docs/filters") + def test_deduplication_prevents_multiple_requests(self): """Verify that multiple anchors on same page don't create duplicate requests""" - # From the issue: These should all map to the same base URL urls_with_multiple_anchors = [ "https://mikro-orm.io/docs/defining-entities#formulas", "https://mikro-orm.io/docs/defining-entities#postgresql-native-enums", @@ -136,10 +128,7 @@ class TestIssue277RealWorld(unittest.TestCase): 1, "Multiple anchors on same page should deduplicate to single request", ) - self.assertEqual( - result[0], - "https://mikro-orm.io/docs/defining-entities/index.html.md", - ) + self.assertEqual(result[0], "https://mikro-orm.io/docs/defining-entities") def test_md_files_with_anchors_preserved(self): """Test that .md files with anchors are handled correctly""" @@ -167,7 +156,6 @@ class TestIssue277RealWorld(unittest.TestCase): Integration test: Simulate real scraping scenario with llms.txt URLs. Verify that the converted URLs would not cause 404 errors. """ - # Mock response for llms.txt content mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = """ @@ -179,7 +167,6 @@ https://mikro-orm.io/docs/defining-entities#formulas """ mock_get.return_value = mock_response - # Simulate the llms.txt parsing flow urls_from_llms = [ "https://mikro-orm.io/docs/quick-start", "https://mikro-orm.io/docs/quick-start#synchronous-initialization", @@ -187,42 +174,36 @@ https://mikro-orm.io/docs/defining-entities#formulas "https://mikro-orm.io/docs/defining-entities#formulas", ] - # Convert URLs (this is what happens in _try_llms_txt_v2) converted_urls = self.converter._convert_to_md_urls(urls_from_llms) - # Verify converted URLs are valid - # In real scenario, these would be added to pending_urls and scraped - self.assertTrue(len(converted_urls) > 0, "Should generate at least one URL to scrape") + self.assertTrue(len(converted_urls) > 0) - # Verify no URLs would cause 404 (no anchors in middle of path) for url in converted_urls: - # Check URL structure is valid + # Should not contain # anywhere self.assertRegex( url, - r"^https://[^#]+$", # Should not contain # anywhere + r"^https://[^#]+$", f"URL should not contain anchor fragments: {url}", ) - - # Verify the problematic pattern from the issue doesn't exist - self.assertNotRegex( - url, - r"#[^/]+/index\.html\.md", - f"URL should not have /index.html.md after anchor: {url}", - ) + # Should NOT have /index.html.md appended + if not url.endswith(".md"): + self.assertNotIn( + "index.html.md", + url, + f"Should not append /index.html.md: {url}", + ) def test_issue_277_error_message_urls(self): """ Test the exact URLs that appeared in error messages from the issue report. These were the actual 404-causing URLs that need to be fixed. """ - # These are the MALFORMED URLs that caused 404 errors (with anchors in the middle) error_urls_with_anchors = [ "https://mikro-orm.io/docs/quick-start#synchronous-initialization/index.html.md", "https://mikro-orm.io/docs/defining-entities#formulas/index.html.md", "https://mikro-orm.io/docs/defining-entities#postgresql-native-enums/index.html.md", ] - # Extract the input URLs that would have generated these errors input_urls = [ "https://mikro-orm.io/docs/quick-start#synchronous-initialization", "https://mikro-orm.io/docs/propagation", @@ -232,7 +213,7 @@ https://mikro-orm.io/docs/defining-entities#formulas result = self.converter._convert_to_md_urls(input_urls) - # Verify NONE of the malformed error URLs (with anchors) are generated + # Verify NONE of the malformed error URLs are generated for error_url in error_urls_with_anchors: self.assertNotIn( error_url, @@ -240,20 +221,82 @@ https://mikro-orm.io/docs/defining-entities#formulas f"Should not generate the 404-causing URL: {error_url}", ) - # Verify correct URLs are generated instead - correct_urls = [ - "https://mikro-orm.io/docs/quick-start/index.html.md", - "https://mikro-orm.io/docs/propagation/index.html.md", - "https://mikro-orm.io/docs/defining-entities/index.html.md", + # Verify correct URLs are generated + self.assertIn("https://mikro-orm.io/docs/quick-start", result) + self.assertIn("https://mikro-orm.io/docs/propagation", result) + self.assertIn("https://mikro-orm.io/docs/defining-entities", result) + + +class TestIssue277DiscordDocs(unittest.TestCase): + """Test for Discord docs case reported by @skeith""" + + def setUp(self): + self.config = { + "name": "DiscordDocs", + "description": "Discord API Documentation", + "base_url": "https://docs.discord.com/", + "selectors": {"main_content": "article"}, + } + self.converter = DocToSkillConverter(self.config, dry_run=True) + + def test_discord_docs_no_index_html_md(self): + """Discord docs don't serve .md files - no /index.html.md should be appended""" + urls = [ + "https://docs.discord.com/developers/activities/building-an-activity", + "https://docs.discord.com/developers/activities/design-patterns", + "https://docs.discord.com/developers/components/overview", + "https://docs.discord.com/developers/bots/getting-started", ] - for correct_url in correct_urls: - self.assertIn( - correct_url, - result, - f"Should generate the correct URL: {correct_url}", + result = self.converter._convert_to_md_urls(urls) + + self.assertEqual(len(result), 4) + for url in result: + self.assertNotIn( + "index.html.md", + url, + f"Discord docs should not get /index.html.md appended: {url}", ) + def test_discord_docs_md_urls_preserved(self): + """Discord llms.txt has .md URLs that should be preserved""" + urls = [ + "https://docs.discord.com/developers/activities/building-an-activity.md", + "https://docs.discord.com/developers/components/overview.md", + "https://docs.discord.com/developers/change-log.md", + ] + + result = self.converter._convert_to_md_urls(urls) + + self.assertEqual(len(result), 3) + self.assertEqual( + result[0], + "https://docs.discord.com/developers/activities/building-an-activity.md", + ) + + def test_discord_docs_mixed_urls(self): + """Mix of .md and non-.md URLs from Discord docs""" + urls = [ + "https://docs.discord.com/developers/activities/building-an-activity.md", + "https://docs.discord.com/developers/overview", + "https://docs.discord.com/developers/overview#quick-start", + "https://docs.discord.com/developers/bots/getting-started.md#step-1", + ] + + result = self.converter._convert_to_md_urls(urls) + + # .md URLs preserved, non-.md as-is, anchors stripped & deduped + self.assertEqual(len(result), 3) + self.assertIn( + "https://docs.discord.com/developers/activities/building-an-activity.md", + result, + ) + self.assertIn("https://docs.discord.com/developers/overview", result) + self.assertIn( + "https://docs.discord.com/developers/bots/getting-started.md", + result, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_url_conversion.py b/tests/test_url_conversion.py index e37a540..49a3c38 100644 --- a/tests/test_url_conversion.py +++ b/tests/test_url_conversion.py @@ -1,6 +1,9 @@ """ Tests for URL conversion logic (_convert_to_md_urls). Covers bug fix for issue #277: URLs with anchor fragments causing 404 errors. + +Updated: _convert_to_md_urls no longer blindly appends /index.html.md to non-.md URLs. +It now strips anchors, deduplicates, and preserves original URLs as-is. """ import unittest @@ -31,11 +34,11 @@ class TestConvertToMdUrls(unittest.TestCase): result = self.converter._convert_to_md_urls(urls) - # All should be converted without anchor fragments + # All should have anchors stripped self.assertEqual(len(result), 3) - self.assertEqual(result[0], "https://example.com/docs/quick-start/index.html.md") - self.assertEqual(result[1], "https://example.com/docs/api/index.html.md") - self.assertEqual(result[2], "https://example.com/docs/guide/index.html.md") + self.assertEqual(result[0], "https://example.com/docs/quick-start") + self.assertEqual(result[1], "https://example.com/docs/api") + self.assertEqual(result[2], "https://example.com/docs/guide") def test_deduplicates_multiple_anchors_same_url(self): """Test that multiple anchors on the same URL are deduplicated""" @@ -50,7 +53,7 @@ class TestConvertToMdUrls(unittest.TestCase): # Should only have one entry for the base URL self.assertEqual(len(result), 1) - self.assertEqual(result[0], "https://example.com/docs/api/index.html.md") + self.assertEqual(result[0], "https://example.com/docs/api") def test_preserves_md_extension_urls(self): """Test that URLs already ending with .md are preserved""" @@ -83,8 +86,27 @@ class TestConvertToMdUrls(unittest.TestCase): self.assertIn("https://example.com/docs/guide.md", result) self.assertIn("https://example.com/docs/api.md", result) + def test_non_md_urls_not_converted(self): + """Test that non-.md URLs are NOT converted to /index.html.md (issue #277)""" + urls = [ + "https://example.com/docs/getting-started", + "https://example.com/docs/api-reference", + "https://example.com/docs/tutorials", + ] + + result = self.converter._convert_to_md_urls(urls) + + # Should preserve URLs as-is, NOT append /index.html.md + self.assertEqual(len(result), 3) + for url in result: + self.assertNotIn("index.html.md", url, f"Should not append /index.html.md: {url}") + + self.assertEqual(result[0], "https://example.com/docs/getting-started") + self.assertEqual(result[1], "https://example.com/docs/api-reference") + self.assertEqual(result[2], "https://example.com/docs/tutorials") + def test_does_not_match_md_in_path(self): - """Test that URLs containing 'md' in path (but not ending with .md) are converted""" + """Test that URLs containing 'md' in path are preserved as-is""" urls = [ "https://example.com/cmd-line", "https://example.com/AMD-processors", @@ -93,27 +115,23 @@ class TestConvertToMdUrls(unittest.TestCase): result = self.converter._convert_to_md_urls(urls) - # All should be converted since they don't END with .md + # URLs with 'md' substring (not extension) should be preserved as-is self.assertEqual(len(result), 3) - self.assertEqual(result[0], "https://example.com/cmd-line/index.html.md") - self.assertEqual(result[1], "https://example.com/AMD-processors/index.html.md") - self.assertEqual(result[2], "https://example.com/metadata/index.html.md") + self.assertEqual(result[0], "https://example.com/cmd-line") + self.assertEqual(result[1], "https://example.com/AMD-processors") + self.assertEqual(result[2], "https://example.com/metadata") - def test_removes_trailing_slashes(self): - """Test that trailing slashes are removed before appending /index.html.md""" + def test_removes_trailing_slashes_for_dedup(self): + """Test that trailing slash variants are deduplicated""" urls = [ "https://example.com/docs/api/", - "https://example.com/docs/guide//", - "https://example.com/docs/reference", + "https://example.com/docs/api", ] result = self.converter._convert_to_md_urls(urls) - # All should have proper /index.html.md without double slashes - self.assertEqual(len(result), 3) - self.assertEqual(result[0], "https://example.com/docs/api/index.html.md") - self.assertEqual(result[1], "https://example.com/docs/guide/index.html.md") - self.assertEqual(result[2], "https://example.com/docs/reference/index.html.md") + # Should deduplicate (trailing slash vs no slash) + self.assertEqual(len(result), 1) def test_mixed_urls_with_and_without_anchors(self): """Test mixed URLs with various formats""" @@ -130,9 +148,9 @@ class TestConvertToMdUrls(unittest.TestCase): # Should deduplicate to 3 unique base URLs self.assertEqual(len(result), 3) - self.assertIn("https://example.com/docs/intro/index.html.md", result) + self.assertIn("https://example.com/docs/intro", result) self.assertIn("https://example.com/docs/api.md", result) - self.assertIn("https://example.com/docs/guide/index.html.md", result) + self.assertIn("https://example.com/docs/guide", result) def test_empty_url_list(self): """Test that empty URL list returns empty result""" @@ -155,14 +173,15 @@ class TestConvertToMdUrls(unittest.TestCase): # Should deduplicate to 3 unique base URLs self.assertEqual(len(result), 3) - self.assertIn("https://mikro-orm.io/docs/quick-start/index.html.md", result) - self.assertIn("https://mikro-orm.io/docs/propagation/index.html.md", result) - self.assertIn("https://mikro-orm.io/docs/defining-entities/index.html.md", result) # Should NOT contain any URLs with anchor fragments for url in result: self.assertNotIn("#", url, f"URL should not contain anchor: {url}") + # Should NOT contain /index.html.md + for url in result: + self.assertNotIn("index.html.md", url, f"Should not append /index.html.md: {url}") + def test_preserves_query_parameters(self): """Test that query parameters are preserved (only anchors stripped)""" urls = [ @@ -175,8 +194,6 @@ class TestConvertToMdUrls(unittest.TestCase): # Query parameters should be preserved, anchors stripped self.assertEqual(len(result), 2) # search deduplicated - # Note: Query parameters might not be ideal for .md conversion, - # but they should be preserved if present self.assertTrue( any("?q=test" in url for url in result), "Query parameter should be preserved", @@ -199,7 +216,7 @@ class TestConvertToMdUrls(unittest.TestCase): # All should deduplicate to single base URL self.assertEqual(len(result), 1) - self.assertEqual(result[0], "https://example.com/docs/guide/index.html.md") + self.assertEqual(result[0], "https://example.com/docs/guide") def test_url_order_preservation(self): """Test that first occurrence of base URL is preserved""" @@ -214,9 +231,59 @@ class TestConvertToMdUrls(unittest.TestCase): # Should have 3 unique URLs, first occurrence preserved self.assertEqual(len(result), 3) - self.assertEqual(result[0], "https://example.com/docs/a/index.html.md") - self.assertEqual(result[1], "https://example.com/docs/b/index.html.md") - self.assertEqual(result[2], "https://example.com/docs/c/index.html.md") + self.assertEqual(result[0], "https://example.com/docs/a") + self.assertEqual(result[1], "https://example.com/docs/b") + self.assertEqual(result[2], "https://example.com/docs/c") + + def test_discord_docs_case(self): + """Test the Discord docs case reported by @skeith in issue #277""" + urls = [ + "https://docs.discord.com/developers/activities/building-an-activity", + "https://docs.discord.com/developers/activities/design-patterns", + "https://docs.discord.com/developers/components/overview", + "https://docs.discord.com/developers/bots/getting-started#step-1", + ] + + result = self.converter._convert_to_md_urls(urls) + + # No /index.html.md should be appended + for url in result: + self.assertNotIn("index.html.md", url, f"Should not append /index.html.md: {url}") + self.assertNotIn("#", url, f"Should not contain anchor: {url}") + + self.assertEqual(len(result), 4) + + +class TestHasMdExtension(unittest.TestCase): + """Test suite for _has_md_extension static method""" + + def test_md_extension(self): + self.assertTrue(DocToSkillConverter._has_md_extension("https://example.com/page.md")) + + def test_md_with_query(self): + self.assertTrue(DocToSkillConverter._has_md_extension("https://example.com/page.md?v=1")) + + def test_no_md_extension(self): + self.assertFalse(DocToSkillConverter._has_md_extension("https://example.com/page")) + + def test_md_in_path_not_extension(self): + """'cmd-line' contains 'md' but is not a .md extension""" + self.assertFalse(DocToSkillConverter._has_md_extension("https://example.com/cmd-line")) + + def test_md_in_domain(self): + """'.md' in domain should not match""" + self.assertFalse(DocToSkillConverter._has_md_extension("https://docs.md.example.com/page")) + + def test_mdx_not_md(self): + """.mdx is not .md""" + self.assertFalse(DocToSkillConverter._has_md_extension("https://example.com/page.mdx")) + + def test_md_in_middle_of_path(self): + """.md in middle of path should not match""" + self.assertFalse(DocToSkillConverter._has_md_extension("https://example.com/page.md/subpage")) + + def test_index_html_md(self): + self.assertTrue(DocToSkillConverter._has_md_extension("https://example.com/page/index.html.md")) if __name__ == "__main__": From 1d3d7389d7f1913b5f5ae6e3882c3883218239db Mon Sep 17 00:00:00 2001 From: yusyus Date: Sat, 21 Mar 2026 00:30:48 +0300 Subject: [PATCH 07/21] fix: sanitize_url crashes on Python 3.14 strict urlparse (#284) Python 3.14's urlparse() raises ValueError on URLs with unencoded brackets that look like malformed IPv6 (e.g. http://[fdaa:x:x:x::x from docs.openclaw.ai llms-full.txt). sanitize_url() called urlparse() BEFORE encoding brackets, so it crashed before it could fix them. Fix: catch ValueError from urlparse, encode ALL brackets, then retry. This is safe because if urlparse rejected the brackets, they are NOT valid IPv6 host literals and should be encoded anyway. Also fixed Discord e2e tests to skip gracefully on network issues. Fixes #284 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/skill_seekers/cli/utils.py | 36 +++++++++++++++++++++++++---- tests/test_issue_277_discord_e2e.py | 20 ++++++++-------- tests/test_markdown_parsing.py | 32 +++++++++++++++++++++++++ tests/test_scraper_features.py | 21 +++++++++++++++++ 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/src/skill_seekers/cli/utils.py b/src/skill_seekers/cli/utils.py index c7cc558..077eaaf 100755 --- a/src/skill_seekers/cli/utils.py +++ b/src/skill_seekers/cli/utils.py @@ -499,6 +499,10 @@ def sanitize_url(url: str) -> str: such as *httpx* and *urllib3* interpret them as IPv6 address markers and raise ``Invalid IPv6 URL``. + Python 3.14+ also raises ``ValueError: Invalid IPv6 URL`` from + ``urlparse()`` itself when brackets appear in the URL, so we must + encode them with simple string splitting BEFORE calling ``urlparse``. + This function encodes **only** the path and query — the scheme, host, and fragment are left untouched. @@ -508,6 +512,7 @@ def sanitize_url(url: str) -> str: Returns: The URL with ``[`` → ``%5B`` and ``]`` → ``%5D`` in its path/query, or the original URL unchanged when no brackets are present. + Returns the original URL if it is malformed beyond repair. Examples: >>> sanitize_url("https://example.com/api/[v1]/users") @@ -518,9 +523,30 @@ def sanitize_url(url: str) -> str: if "[" not in url and "]" not in url: return url - from urllib.parse import urlparse, urlunparse + # Encode brackets BEFORE urlparse — Python 3.14 raises ValueError + # on unencoded brackets because it tries to parse them as IPv6. + # We split scheme://authority from the rest manually to avoid + # encoding brackets in legitimate IPv6 host literals like [::1]. + try: + # Try urlparse first — works if brackets are in a valid position + # (e.g., legitimate IPv6 host) + from urllib.parse import urlparse, urlunparse - parsed = urlparse(url) - encoded_path = parsed.path.replace("[", "%5B").replace("]", "%5D") - encoded_query = parsed.query.replace("[", "%5B").replace("]", "%5D") - return urlunparse(parsed._replace(path=encoded_path, query=encoded_query)) + parsed = urlparse(url) + encoded_path = parsed.path.replace("[", "%5B").replace("]", "%5D") + encoded_query = parsed.query.replace("[", "%5B").replace("]", "%5D") + return urlunparse(parsed._replace(path=encoded_path, query=encoded_query)) + except ValueError: + # urlparse rejected the URL (Python 3.14+ strict IPv6 validation). + # Encode ALL brackets and try again. This is safe because if + # urlparse failed, the brackets are NOT valid IPv6 host literals. + pre_encoded = url.replace("[", "%5B").replace("]", "%5D") + try: + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(pre_encoded) + return urlunparse(parsed) + except ValueError: + # URL is fundamentally malformed — return the pre-encoded + # version which is at least safe for HTTP libraries. + return pre_encoded diff --git a/tests/test_issue_277_discord_e2e.py b/tests/test_issue_277_discord_e2e.py index dcd1e9f..f7b01a3 100644 --- a/tests/test_issue_277_discord_e2e.py +++ b/tests/test_issue_277_discord_e2e.py @@ -40,21 +40,23 @@ class TestIssue277DiscordDocsE2E(unittest.TestCase): if os.path.exists(path): shutil.rmtree(path) - def test_discord_llms_txt_exists(self): - """Verify Discord docs has llms.txt (precondition for the bug).""" + def _detect_variants(self): + """Helper: detect llms.txt variants, skip test if site unreachable.""" detector = LlmsTxtDetector(self.base_url) variants = detector.detect_all() - self.assertTrue( - len(variants) > 0, - "Discord docs should have at least one llms.txt variant", - ) + if not variants: + self.skipTest("Discord docs llms.txt not reachable (network/rate-limit)") + return variants + + def test_discord_llms_txt_exists(self): + """Verify Discord docs has llms.txt (precondition for the bug).""" + variants = self._detect_variants() + self.assertGreater(len(variants), 0) def test_discord_llms_txt_urls_no_index_html_md(self): """Core test: URLs extracted from Discord llms.txt must NOT get /index.html.md appended.""" # Step 1: Detect llms.txt - detector = LlmsTxtDetector(self.base_url) - variants = detector.detect_all() - self.assertTrue(len(variants) > 0, "No llms.txt found at docs.discord.com") + variants = self._detect_variants() # Step 2: Download the largest variant (same logic as doc_scraper) downloaded = {} diff --git a/tests/test_markdown_parsing.py b/tests/test_markdown_parsing.py index b855fca..c71431b 100644 --- a/tests/test_markdown_parsing.py +++ b/tests/test_markdown_parsing.py @@ -310,6 +310,38 @@ API: https://example.com/api/reference.md result = parser._clean_url("https://example.com/api/[v1]/page#section/deep") self.assertEqual(result, "https://example.com/api/%5Bv1%5D/page") + def test_clean_url_malformed_ipv6_no_crash(self): + """Test that incomplete IPv6 placeholder URLs don't crash (issue #284). + + Python 3.14 raises ValueError from urlparse() on these URLs. + Seen in real-world llms-full.txt from docs.openclaw.ai. + """ + from skill_seekers.cli.llms_txt_parser import LlmsTxtParser + + parser = LlmsTxtParser("", base_url="https://example.com") + + # Must not raise ValueError + result = parser._clean_url("http://[fdaa:x:x:x:x::x") + self.assertIn("%5B", result) + self.assertNotIn("[", result) + + def test_extract_urls_with_ipv6_placeholder_no_crash(self): + """Test that extract_urls handles content with broken IPv6 URLs (issue #284).""" + from skill_seekers.cli.llms_txt_parser import LlmsTxtParser + + content = """# Docs +- [Guide](https://example.com/guide.md) +- Connect to http://[fdaa:x:x:x:x::x for private networking +- [API](https://example.com/api.md) +""" + parser = LlmsTxtParser(content, base_url="https://example.com") + + # Must not raise ValueError + urls = parser.extract_urls() + # Should still extract the valid URLs + valid = [u for u in urls if "example.com" in u] + self.assertGreaterEqual(len(valid), 2) + def test_deduplicate_urls(self): """Test that duplicate URLs are removed.""" from skill_seekers.cli.llms_txt_parser import LlmsTxtParser diff --git a/tests/test_scraper_features.py b/tests/test_scraper_features.py index 338e4ff..3d8aa5f 100644 --- a/tests/test_scraper_features.py +++ b/tests/test_scraper_features.py @@ -568,6 +568,27 @@ class TestSanitizeUrl(unittest.TestCase): self.assertEqual(sanitize_url("https://example.com"), "https://example.com") self.assertEqual(sanitize_url("https://example.com/"), "https://example.com/") + def test_malformed_ipv6_url_no_crash(self): + """URLs with brackets that look like broken IPv6 must not crash (issue #284). + + Python 3.14 raises ValueError from urlparse() on unencoded brackets + that look like IPv6 but are malformed (e.g. from documentation examples). + """ + from skill_seekers.cli.utils import sanitize_url + + # Incomplete IPv6 placeholder from docs.openclaw.ai llms-full.txt + result = sanitize_url("http://[fdaa:x:x:x:x::x") + self.assertNotIn("[", result) + self.assertIn("%5B", result) + + def test_unmatched_bracket_no_crash(self): + """Unmatched brackets should be encoded, not crash.""" + from skill_seekers.cli.utils import sanitize_url + + result = sanitize_url("https://example.com/api/[v1/users") + self.assertNotIn("[", result) + self.assertIn("%5B", result) + class TestEnqueueUrlSanitization(unittest.TestCase): """Test that _enqueue_url sanitises bracket URLs before enqueueing (#284).""" From cd7b322b5e8b4bb81c41fbb682da716c3466f37f Mon Sep 17 00:00:00 2001 From: yusyus Date: Sat, 21 Mar 2026 20:31:51 +0300 Subject: [PATCH 08/21] feat: expand platform coverage with 8 new adaptors, 7 new CLI agents, and OpenCode skill tools Phase 1 - OpenCode Integration: - Add OpenCodeAdaptor with directory-based packaging and dual-format YAML frontmatter - Kebab-case name validation matching OpenCode's regex spec Phase 2 - OpenAI-Compatible LLM Platforms: - Extract OpenAICompatibleAdaptor base class from MiniMax (shared format/package/upload/enhance) - Refactor MiniMax to ~20 lines of constants inheriting from base - Add 6 new LLM adaptors: Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - All use OpenAI-compatible API with platform-specific constants Phase 3 - CLI Agent Expansion: - Add 7 new install-agent paths: roo, cline, aider, bolt, kilo, continue, kimi-code - Total agents: 11 -> 18 Phase 4 - Advanced Features: - OpenCode skill splitter (auto-split large docs into focused sub-skills with router) - Bi-directional skill format converter (import/export between OpenCode and any platform) - GitHub Actions template for automated skill updates Totals: 12 --target platforms, 18 --agent paths, 2915 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 31 ++ src/skill_seekers/cli/adaptors/__init__.py | 57 +- src/skill_seekers/cli/adaptors/deepseek.py | 19 + src/skill_seekers/cli/adaptors/fireworks.py | 19 + src/skill_seekers/cli/adaptors/kimi.py | 19 + src/skill_seekers/cli/adaptors/minimax.py | 497 +----------------- .../cli/adaptors/openai_compatible.py | 431 +++++++++++++++ src/skill_seekers/cli/adaptors/opencode.py | 188 +++++++ src/skill_seekers/cli/adaptors/openrouter.py | 19 + src/skill_seekers/cli/adaptors/qwen.py | 19 + src/skill_seekers/cli/adaptors/together.py | 19 + src/skill_seekers/cli/install_agent.py | 10 +- .../cli/opencode_skill_splitter.py | 447 ++++++++++++++++ templates/github-actions/update-skills.yml | 153 ++++++ tests/test_adaptors/test_deepseek_adaptor.py | 51 ++ tests/test_adaptors/test_fireworks_adaptor.py | 51 ++ tests/test_adaptors/test_kimi_adaptor.py | 51 ++ .../test_openai_compatible_base.py | 224 ++++++++ tests/test_adaptors/test_opencode_adaptor.py | 210 ++++++++ .../test_adaptors/test_openrouter_adaptor.py | 51 ++ tests/test_adaptors/test_qwen_adaptor.py | 51 ++ tests/test_adaptors/test_together_adaptor.py | 51 ++ tests/test_install_agent.py | 33 +- tests/test_opencode_skill_splitter.py | 280 ++++++++++ 24 files changed, 2482 insertions(+), 499 deletions(-) create mode 100644 src/skill_seekers/cli/adaptors/deepseek.py create mode 100644 src/skill_seekers/cli/adaptors/fireworks.py create mode 100644 src/skill_seekers/cli/adaptors/kimi.py create mode 100644 src/skill_seekers/cli/adaptors/openai_compatible.py create mode 100644 src/skill_seekers/cli/adaptors/opencode.py create mode 100644 src/skill_seekers/cli/adaptors/openrouter.py create mode 100644 src/skill_seekers/cli/adaptors/qwen.py create mode 100644 src/skill_seekers/cli/adaptors/together.py create mode 100644 src/skill_seekers/cli/opencode_skill_splitter.py create mode 100644 templates/github-actions/update-skills.yml create mode 100644 tests/test_adaptors/test_deepseek_adaptor.py create mode 100644 tests/test_adaptors/test_fireworks_adaptor.py create mode 100644 tests/test_adaptors/test_kimi_adaptor.py create mode 100644 tests/test_adaptors/test_openai_compatible_base.py create mode 100644 tests/test_adaptors/test_opencode_adaptor.py create mode 100644 tests/test_adaptors/test_openrouter_adaptor.py create mode 100644 tests/test_adaptors/test_qwen_adaptor.py create mode 100644 tests/test_adaptors/test_together_adaptor.py create mode 100644 tests/test_opencode_skill_splitter.py diff --git a/pyproject.toml b/pyproject.toml index 2bb1b8b..8aa51b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,36 @@ minimax = [ "openai>=1.0.0", ] +# Kimi (Moonshot AI) support (uses OpenAI-compatible API) +kimi = [ + "openai>=1.0.0", +] + +# DeepSeek AI support (uses OpenAI-compatible API) +deepseek = [ + "openai>=1.0.0", +] + +# Qwen (Alibaba) support (uses OpenAI-compatible API) +qwen = [ + "openai>=1.0.0", +] + +# OpenRouter support (uses OpenAI-compatible API) +openrouter = [ + "openai>=1.0.0", +] + +# Together AI support (uses OpenAI-compatible API) +together = [ + "openai>=1.0.0", +] + +# Fireworks AI support (uses OpenAI-compatible API) +fireworks = [ + "openai>=1.0.0", +] + # All LLM platforms combined all-llms = [ "google-generativeai>=0.8.0", @@ -306,6 +336,7 @@ skill-seekers-manpage = "skill_seekers.cli.man_scraper:main" skill-seekers-confluence = "skill_seekers.cli.confluence_scraper:main" skill-seekers-notion = "skill_seekers.cli.notion_scraper:main" skill-seekers-chat = "skill_seekers.cli.chat_scraper:main" +skill-seekers-opencode-split = "skill_seekers.cli.opencode_skill_splitter:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/skill_seekers/cli/adaptors/__init__.py b/src/skill_seekers/cli/adaptors/__init__.py index 2350858..494ee50 100644 --- a/src/skill_seekers/cli/adaptors/__init__.py +++ b/src/skill_seekers/cli/adaptors/__init__.py @@ -3,7 +3,9 @@ Multi-LLM Adaptor Registry Provides factory function to get platform-specific adaptors for skill generation. -Supports Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, and generic Markdown export. +Supports Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, OpenCode, +Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, +and generic Markdown export. """ from .base import SkillAdaptor, SkillMetadata @@ -74,6 +76,41 @@ try: except ImportError: MiniMaxAdaptor = None +try: + from .opencode import OpenCodeAdaptor +except ImportError: + OpenCodeAdaptor = None + +try: + from .kimi import KimiAdaptor +except ImportError: + KimiAdaptor = None + +try: + from .deepseek import DeepSeekAdaptor +except ImportError: + DeepSeekAdaptor = None + +try: + from .qwen import QwenAdaptor +except ImportError: + QwenAdaptor = None + +try: + from .openrouter import OpenRouterAdaptor +except ImportError: + OpenRouterAdaptor = None + +try: + from .together import TogetherAdaptor +except ImportError: + TogetherAdaptor = None + +try: + from .fireworks import FireworksAdaptor +except ImportError: + FireworksAdaptor = None + # Registry of available adaptors ADAPTORS: dict[str, type[SkillAdaptor]] = {} @@ -105,6 +142,20 @@ if PineconeAdaptor: ADAPTORS["pinecone"] = PineconeAdaptor if MiniMaxAdaptor: ADAPTORS["minimax"] = MiniMaxAdaptor +if OpenCodeAdaptor: + ADAPTORS["opencode"] = OpenCodeAdaptor +if KimiAdaptor: + ADAPTORS["kimi"] = KimiAdaptor +if DeepSeekAdaptor: + ADAPTORS["deepseek"] = DeepSeekAdaptor +if QwenAdaptor: + ADAPTORS["qwen"] = QwenAdaptor +if OpenRouterAdaptor: + ADAPTORS["openrouter"] = OpenRouterAdaptor +if TogetherAdaptor: + ADAPTORS["together"] = TogetherAdaptor +if FireworksAdaptor: + ADAPTORS["fireworks"] = FireworksAdaptor def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor: @@ -112,7 +163,9 @@ def get_adaptor(platform: str, config: dict = None) -> SkillAdaptor: Factory function to get platform-specific adaptor instance. Args: - platform: Platform identifier ('claude', 'gemini', 'openai', 'minimax', 'markdown') + platform: Platform identifier (e.g., 'claude', 'gemini', 'openai', 'minimax', + 'opencode', 'kimi', 'deepseek', 'qwen', 'openrouter', 'together', + 'fireworks', 'markdown') config: Optional platform-specific configuration Returns: diff --git a/src/skill_seekers/cli/adaptors/deepseek.py b/src/skill_seekers/cli/adaptors/deepseek.py new file mode 100644 index 0000000..513537d --- /dev/null +++ b/src/skill_seekers/cli/adaptors/deepseek.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +""" +DeepSeek AI Adaptor + +OpenAI-compatible LLM platform adaptor for DeepSeek. +""" + +from .openai_compatible import OpenAICompatibleAdaptor + + +class DeepSeekAdaptor(OpenAICompatibleAdaptor): + """DeepSeek AI platform adaptor.""" + + PLATFORM = "deepseek" + PLATFORM_NAME = "DeepSeek AI" + DEFAULT_API_ENDPOINT = "https://api.deepseek.com/v1" + DEFAULT_MODEL = "deepseek-chat" + ENV_VAR_NAME = "DEEPSEEK_API_KEY" + PLATFORM_URL = "https://platform.deepseek.com/" diff --git a/src/skill_seekers/cli/adaptors/fireworks.py b/src/skill_seekers/cli/adaptors/fireworks.py new file mode 100644 index 0000000..7f4bae9 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/fireworks.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +""" +Fireworks AI Adaptor + +OpenAI-compatible LLM platform adaptor for Fireworks AI. +""" + +from .openai_compatible import OpenAICompatibleAdaptor + + +class FireworksAdaptor(OpenAICompatibleAdaptor): + """Fireworks AI platform adaptor.""" + + PLATFORM = "fireworks" + PLATFORM_NAME = "Fireworks AI" + DEFAULT_API_ENDPOINT = "https://api.fireworks.ai/inference/v1" + DEFAULT_MODEL = "accounts/fireworks/models/llama-v3p1-70b-instruct" + ENV_VAR_NAME = "FIREWORKS_API_KEY" + PLATFORM_URL = "https://fireworks.ai/" diff --git a/src/skill_seekers/cli/adaptors/kimi.py b/src/skill_seekers/cli/adaptors/kimi.py new file mode 100644 index 0000000..4a38389 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/kimi.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +""" +Kimi (Moonshot AI) Adaptor + +OpenAI-compatible LLM platform adaptor for Kimi/Moonshot AI. +""" + +from .openai_compatible import OpenAICompatibleAdaptor + + +class KimiAdaptor(OpenAICompatibleAdaptor): + """Kimi (Moonshot AI) platform adaptor.""" + + PLATFORM = "kimi" + PLATFORM_NAME = "Kimi (Moonshot AI)" + DEFAULT_API_ENDPOINT = "https://api.moonshot.cn/v1" + DEFAULT_MODEL = "moonshot-v1-128k" + ENV_VAR_NAME = "MOONSHOT_API_KEY" + PLATFORM_URL = "https://platform.moonshot.cn/" diff --git a/src/skill_seekers/cli/adaptors/minimax.py b/src/skill_seekers/cli/adaptors/minimax.py index ca9a272..8d73b66 100644 --- a/src/skill_seekers/cli/adaptors/minimax.py +++ b/src/skill_seekers/cli/adaptors/minimax.py @@ -2,502 +2,19 @@ """ MiniMax AI Adaptor -Implements platform-specific handling for MiniMax AI skills. +OpenAI-compatible LLM platform adaptor for MiniMax AI. Uses MiniMax's OpenAI-compatible API for AI enhancement with M2.7 model. """ -import json -import zipfile -from pathlib import Path -from typing import Any - -from .base import SkillAdaptor, SkillMetadata -from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS +from .openai_compatible import OpenAICompatibleAdaptor -class MiniMaxAdaptor(SkillAdaptor): - """ - MiniMax AI platform adaptor. - - Handles: - - System instructions format (plain text, no YAML frontmatter) - - ZIP packaging with knowledge files - - AI enhancement using MiniMax-M2.7 - """ +class MiniMaxAdaptor(OpenAICompatibleAdaptor): + """MiniMax AI platform adaptor.""" PLATFORM = "minimax" PLATFORM_NAME = "MiniMax AI" DEFAULT_API_ENDPOINT = "https://api.minimax.io/v1" - - def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str: - """ - Format SKILL.md as system instructions for MiniMax AI. - - MiniMax uses OpenAI-compatible chat completions, so instructions - are formatted as clear system prompts without YAML frontmatter. - - Args: - skill_dir: Path to skill directory - metadata: Skill metadata - - Returns: - Formatted instructions for MiniMax AI - """ - existing_content = self._read_existing_content(skill_dir) - - if existing_content and len(existing_content) > 100: - content_body = f"""You are an expert assistant for {metadata.name}. - -{metadata.description} - -Use the attached knowledge files to provide accurate, detailed answers about {metadata.name}. - -{existing_content} - -## How to Assist Users - -When users ask questions: -1. Search the knowledge files for relevant information -2. Provide clear, practical answers with code examples -3. Reference specific documentation sections when helpful -4. Be concise but thorough - -Always prioritize accuracy by consulting the knowledge base before responding.""" - else: - content_body = f"""You are an expert assistant for {metadata.name}. - -{metadata.description} - -## Your Knowledge Base - -You have access to comprehensive documentation files about {metadata.name}. Use these files to provide accurate answers to user questions. - -{self._generate_toc(skill_dir)} - -## Quick Reference - -{self._extract_quick_reference(skill_dir)} - -## How to Assist Users - -When users ask questions about {metadata.name}: - -1. **Search the knowledge files** - Find relevant information in the documentation -2. **Provide code examples** - Include practical, working code snippets -3. **Reference documentation** - Cite specific sections when helpful -4. **Be practical** - Focus on real-world usage and best practices -5. **Stay accurate** - Always verify information against the knowledge base - -## Response Guidelines - -- Keep answers clear and concise -- Use proper code formatting with language tags -- Provide both simple and detailed explanations as needed -- Suggest related topics when relevant -- Admit when information isn't in the knowledge base - -Always prioritize accuracy by consulting the attached documentation files before responding.""" - - return content_body - - def package( - self, - skill_dir: Path, - output_path: Path, - enable_chunking: bool = False, - chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS, - preserve_code_blocks: bool = True, - chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS, - ) -> Path: - """ - Package skill into ZIP file for MiniMax AI. - - Creates MiniMax-compatible structure: - - system_instructions.txt (main instructions) - - knowledge_files/*.md (reference files) - - minimax_metadata.json (skill metadata) - - Args: - skill_dir: Path to skill directory - output_path: Output path/filename for ZIP - - Returns: - Path to created ZIP file - """ - skill_dir = Path(skill_dir) - output_path = Path(output_path) - - if output_path.is_dir() or str(output_path).endswith("/"): - output_path = Path(output_path) / f"{skill_dir.name}-minimax.zip" - elif not str(output_path).endswith(".zip") and not str(output_path).endswith( - "-minimax.zip" - ): - output_str = str(output_path).replace(".zip", "-minimax.zip") - if not output_str.endswith(".zip"): - output_str += ".zip" - output_path = Path(output_str) - - output_path.parent.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: - skill_md = skill_dir / "SKILL.md" - if skill_md.exists(): - instructions = skill_md.read_text(encoding="utf-8") - zf.writestr("system_instructions.txt", instructions) - - refs_dir = skill_dir / "references" - if refs_dir.exists(): - for ref_file in refs_dir.rglob("*.md"): - if ref_file.is_file() and not ref_file.name.startswith("."): - arcname = f"knowledge_files/{ref_file.name}" - zf.write(ref_file, arcname) - - metadata = { - "platform": "minimax", - "name": skill_dir.name, - "version": "1.0.0", - "created_with": "skill-seekers", - "model": "MiniMax-M2.7", - "api_base": self.DEFAULT_API_ENDPOINT, - } - - zf.writestr("minimax_metadata.json", json.dumps(metadata, indent=2)) - - return output_path - - def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]: - """ - Upload packaged skill to MiniMax AI. - - MiniMax uses an OpenAI-compatible chat completion API. - This method validates the package and prepares it for use - with the MiniMax API. - - Args: - package_path: Path to skill ZIP file - api_key: MiniMax API key - **kwargs: Additional arguments (model, etc.) - - Returns: - Dictionary with upload result - """ - package_path = Path(package_path) - if not package_path.exists(): - return { - "success": False, - "skill_id": None, - "url": None, - "message": f"File not found: {package_path}", - } - - if package_path.suffix != ".zip": - return { - "success": False, - "skill_id": None, - "url": None, - "message": f"Not a ZIP file: {package_path}", - } - - try: - from openai import OpenAI, APITimeoutError, APIConnectionError - except ImportError: - return { - "success": False, - "skill_id": None, - "url": None, - "message": "openai library not installed. Run: pip install openai", - } - - try: - import tempfile - - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(package_path, "r") as zf: - zf.extractall(temp_dir) - - temp_path = Path(temp_dir) - - instructions_file = temp_path / "system_instructions.txt" - if not instructions_file.exists(): - return { - "success": False, - "skill_id": None, - "url": None, - "message": "Invalid package: system_instructions.txt not found", - } - - instructions = instructions_file.read_text(encoding="utf-8") - - metadata_file = temp_path / "minimax_metadata.json" - skill_name = package_path.stem - model = kwargs.get("model", "MiniMax-M2.7") - - if metadata_file.exists(): - with open(metadata_file) as f: - metadata = json.load(f) - skill_name = metadata.get("name", skill_name) - model = metadata.get("model", model) - - knowledge_dir = temp_path / "knowledge_files" - knowledge_count = 0 - if knowledge_dir.exists(): - knowledge_count = len(list(knowledge_dir.glob("*.md"))) - - client = OpenAI( - api_key=api_key, - base_url=self.DEFAULT_API_ENDPOINT, - ) - - client.chat.completions.create( - model=model, - messages=[ - {"role": "system", "content": instructions}, - { - "role": "user", - "content": f"Confirm you are ready to assist with {skill_name}. Reply briefly.", - }, - ], - temperature=0.3, - max_tokens=100, - ) - - return { - "success": True, - "skill_id": None, - "url": "https://platform.minimaxi.com/", - "message": f"Skill '{skill_name}' validated with MiniMax {model} ({knowledge_count} knowledge files)", - } - - except APITimeoutError: - return { - "success": False, - "skill_id": None, - "url": None, - "message": "Upload timed out. Try again.", - } - except APIConnectionError: - return { - "success": False, - "skill_id": None, - "url": None, - "message": "Connection error. Check your internet connection.", - } - except Exception as e: - return { - "success": False, - "skill_id": None, - "url": None, - "message": f"Upload failed: {str(e)}", - } - - def validate_api_key(self, api_key: str) -> bool: - """ - Validate MiniMax API key format. - - MiniMax API keys are opaque strings. We only check for - a non-empty key with a reasonable minimum length. - - Args: - api_key: API key to validate - - Returns: - True if key format appears valid - """ - key = api_key.strip() - return len(key) > 10 - - def get_env_var_name(self) -> str: - """ - Get environment variable name for MiniMax API key. - - Returns: - 'MINIMAX_API_KEY' - """ - return "MINIMAX_API_KEY" - - def supports_enhancement(self) -> bool: - """ - MiniMax supports AI enhancement via MiniMax-M2.7. - - Returns: - True - """ - return True - - def enhance(self, skill_dir: Path, api_key: str) -> bool: - """ - Enhance SKILL.md using MiniMax-M2.7 API. - - Uses MiniMax's OpenAI-compatible API endpoint for enhancement. - - Args: - skill_dir: Path to skill directory - api_key: MiniMax API key - - Returns: - True if enhancement succeeded - """ - try: - from openai import OpenAI - except ImportError: - print("❌ Error: openai package not installed") - print("Install with: pip install openai") - return False - - skill_dir = Path(skill_dir) - references_dir = skill_dir / "references" - skill_md_path = skill_dir / "SKILL.md" - - print("📖 Reading reference documentation...") - references = self._read_reference_files(references_dir) - - if not references: - print("❌ No reference files found to analyze") - return False - - print(f" ✓ Read {len(references)} reference files") - total_size = sum(len(c) for c in references.values()) - print(f" ✓ Total size: {total_size:,} characters\n") - - current_skill_md = None - if skill_md_path.exists(): - current_skill_md = skill_md_path.read_text(encoding="utf-8") - print(f" ℹ Found existing SKILL.md ({len(current_skill_md)} chars)") - else: - print(" ℹ No existing SKILL.md, will create new one") - - prompt = self._build_enhancement_prompt(skill_dir.name, references, current_skill_md) - - print("\n🤖 Asking MiniMax-M2.7 to enhance SKILL.md...") - print(f" Input: {len(prompt):,} characters") - - try: - client = OpenAI( - api_key=api_key, - base_url="https://api.minimax.io/v1", - ) - - response = client.chat.completions.create( - model="MiniMax-M2.7", - messages=[ - { - "role": "system", - "content": "You are an expert technical writer creating system instructions for MiniMax AI.", - }, - {"role": "user", "content": prompt}, - ], - temperature=0.3, - max_tokens=4096, - ) - - enhanced_content = response.choices[0].message.content - print(f" ✓ Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n") - - if skill_md_path.exists(): - backup_path = skill_md_path.with_suffix(".md.backup") - skill_md_path.rename(backup_path) - print(f" 💾 Backed up original to: {backup_path.name}") - - skill_md_path.write_text(enhanced_content, encoding="utf-8") - print(" ✅ Saved enhanced SKILL.md") - - return True - - except Exception as e: - print(f"❌ Error calling MiniMax API: {e}") - return False - - def _read_reference_files( - self, references_dir: Path, max_chars: int = 200000 - ) -> dict[str, str]: - """ - Read reference markdown files from skill directory. - - Args: - references_dir: Path to references directory - max_chars: Maximum total characters to read - - Returns: - Dictionary mapping filename to content - """ - if not references_dir.exists(): - return {} - - references = {} - total_chars = 0 - - for ref_file in sorted(references_dir.glob("*.md")): - if total_chars >= max_chars: - break - - try: - content = ref_file.read_text(encoding="utf-8") - if len(content) > 30000: - content = content[:30000] + "\n\n...(truncated)" - - references[ref_file.name] = content - total_chars += len(content) - - except Exception as e: - print(f" ⚠️ Could not read {ref_file.name}: {e}") - - return references - - def _build_enhancement_prompt( - self, skill_name: str, references: dict[str, str], current_skill_md: str = None - ) -> str: - """ - Build MiniMax API prompt for enhancement. - - Args: - skill_name: Name of the skill - references: Dictionary of reference content - current_skill_md: Existing SKILL.md content (optional) - - Returns: - Enhancement prompt for MiniMax-M2.7 - """ - prompt = f"""You are creating system instructions for a MiniMax AI assistant about: {skill_name} - -I've scraped documentation and organized it into reference files. Your job is to create EXCELLENT system instructions that will help the assistant use this documentation effectively. - -CURRENT INSTRUCTIONS: -{"```" if current_skill_md else "(none - create from scratch)"} -{current_skill_md or "No existing instructions"} -{"```" if current_skill_md else ""} - -REFERENCE DOCUMENTATION: -""" - - for filename, content in references.items(): - prompt += f"\n\n## {filename}\n```markdown\n{content[:30000]}\n```\n" - - prompt += """ - -YOUR TASK: -Create enhanced system instructions that include: - -1. **Clear role definition** - "You are an expert assistant for [topic]" -2. **Knowledge base description** - What documentation is attached -3. **Excellent Quick Reference** - Extract 5-10 of the BEST, most practical code examples from the reference docs - - Choose SHORT, clear examples that demonstrate common tasks - - Include both simple and intermediate examples - - Annotate examples with clear descriptions - - Use proper language tags (cpp, python, javascript, json, etc.) -4. **Response guidelines** - How the assistant should help users -5. **Search strategy** - How to find information in the knowledge base -6. **DO NOT use YAML frontmatter** - This is plain text instructions - -IMPORTANT: -- Extract REAL examples from the reference docs, don't make them up -- Prioritize SHORT, clear examples (5-20 lines max) -- Make it actionable and practical -- Write clear, direct instructions -- Focus on how the assistant should behave and respond -- NO YAML frontmatter (no --- blocks) - -OUTPUT: -Return ONLY the complete system instructions as plain text. -""" - - return prompt + DEFAULT_MODEL = "MiniMax-M2.7" + ENV_VAR_NAME = "MINIMAX_API_KEY" + PLATFORM_URL = "https://platform.minimaxi.com/" diff --git a/src/skill_seekers/cli/adaptors/openai_compatible.py b/src/skill_seekers/cli/adaptors/openai_compatible.py new file mode 100644 index 0000000..8f5ab3e --- /dev/null +++ b/src/skill_seekers/cli/adaptors/openai_compatible.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +OpenAI-Compatible Base Adaptor + +Shared base class for all LLM platforms that use OpenAI-compatible APIs. +Subclasses only need to override platform constants (~15 lines each). +""" + +import json +import tempfile +import zipfile +from pathlib import Path +from typing import Any + +from .base import SkillAdaptor, SkillMetadata +from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS + + +class OpenAICompatibleAdaptor(SkillAdaptor): + """ + Base class for OpenAI-compatible LLM platform adaptors. + + Subclasses override these constants: + - PLATFORM: Registry key (e.g., "kimi") + - PLATFORM_NAME: Display name (e.g., "Kimi (Moonshot AI)") + - DEFAULT_API_ENDPOINT: API base URL + - DEFAULT_MODEL: Default model name + - ENV_VAR_NAME: API key env var name + - PLATFORM_URL: Dashboard/platform URL + """ + + PLATFORM = "unknown" + PLATFORM_NAME = "Unknown" + DEFAULT_API_ENDPOINT = "" + DEFAULT_MODEL = "" + ENV_VAR_NAME = "" + PLATFORM_URL = "" + + def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str: + """ + Format SKILL.md as system instructions (no YAML frontmatter). + + Uses plain text format compatible with OpenAI-compatible chat APIs. + """ + existing_content = self._read_existing_content(skill_dir) + + if existing_content and len(existing_content) > 100: + return f"""You are an expert assistant for {metadata.name}. + +{metadata.description} + +Use the attached knowledge files to provide accurate, detailed answers about {metadata.name}. + +{existing_content} + +## How to Assist Users + +When users ask questions: +1. Search the knowledge files for relevant information +2. Provide clear, practical answers with code examples +3. Reference specific documentation sections when helpful +4. Be concise but thorough + +Always prioritize accuracy by consulting the knowledge base before responding.""" + + return f"""You are an expert assistant for {metadata.name}. + +{metadata.description} + +## Your Knowledge Base + +You have access to comprehensive documentation files about {metadata.name}. Use these files to provide accurate answers to user questions. + +{self._generate_toc(skill_dir)} + +## Quick Reference + +{self._extract_quick_reference(skill_dir)} + +## How to Assist Users + +When users ask questions about {metadata.name}: + +1. **Search the knowledge files** - Find relevant information in the documentation +2. **Provide code examples** - Include practical, working code snippets +3. **Reference documentation** - Cite specific sections when helpful +4. **Be practical** - Focus on real-world usage and best practices +5. **Stay accurate** - Always verify information against the knowledge base + +## Response Guidelines + +- Keep answers clear and concise +- Use proper code formatting with language tags +- Provide both simple and detailed explanations as needed +- Suggest related topics when relevant +- Admit when information isn't in the knowledge base + +Always prioritize accuracy by consulting the attached documentation files before responding.""" + + def package( + self, + skill_dir: Path, + output_path: Path, + enable_chunking: bool = False, + chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS, + preserve_code_blocks: bool = True, + chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS, + ) -> Path: + """ + Package skill into ZIP file for the platform. + + Creates platform-compatible structure: + - system_instructions.txt (main instructions) + - knowledge_files/*.md (reference files) + - {platform}_metadata.json (skill metadata) + """ + skill_dir = Path(skill_dir) + output_path = Path(output_path) + + suffix = f"-{self.PLATFORM}.zip" + + if output_path.is_dir() or str(output_path).endswith("/"): + output_path = Path(output_path) / f"{skill_dir.name}{suffix}" + elif not str(output_path).endswith(suffix): + output_str = str(output_path) + # Strip existing .zip extension if present + if output_str.endswith(".zip"): + output_str = output_str[:-4] + output_path = Path(output_str + suffix) + + output_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + skill_md = skill_dir / "SKILL.md" + if skill_md.exists(): + instructions = skill_md.read_text(encoding="utf-8") + zf.writestr("system_instructions.txt", instructions) + + refs_dir = skill_dir / "references" + if refs_dir.exists(): + for ref_file in refs_dir.rglob("*.md"): + if ref_file.is_file() and not ref_file.name.startswith("."): + arcname = f"knowledge_files/{ref_file.name}" + zf.write(ref_file, arcname) + + metadata = { + "platform": self.PLATFORM, + "name": skill_dir.name, + "version": "1.0.0", + "created_with": "skill-seekers", + "model": self.DEFAULT_MODEL, + "api_base": self.DEFAULT_API_ENDPOINT, + } + + zf.writestr(f"{self.PLATFORM}_metadata.json", json.dumps(metadata, indent=2)) + + return output_path + + def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]: + """ + Upload/validate packaged skill via OpenAI-compatible API. + """ + package_path = Path(package_path) + if not package_path.exists(): + return { + "success": False, + "skill_id": None, + "url": None, + "message": f"File not found: {package_path}", + } + + if package_path.suffix != ".zip": + return { + "success": False, + "skill_id": None, + "url": None, + "message": f"Not a ZIP file: {package_path}", + } + + try: + from openai import OpenAI, APITimeoutError, APIConnectionError + except ImportError: + return { + "success": False, + "skill_id": None, + "url": None, + "message": "openai library not installed. Run: pip install openai", + } + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(package_path, "r") as zf: + zf.extractall(temp_dir) + + temp_path = Path(temp_dir) + + instructions_file = temp_path / "system_instructions.txt" + if not instructions_file.exists(): + return { + "success": False, + "skill_id": None, + "url": None, + "message": "Invalid package: system_instructions.txt not found", + } + + instructions = instructions_file.read_text(encoding="utf-8") + + metadata_file = temp_path / f"{self.PLATFORM}_metadata.json" + skill_name = package_path.stem + model = kwargs.get("model", self.DEFAULT_MODEL) + + if metadata_file.exists(): + with open(metadata_file) as f: + metadata = json.load(f) + skill_name = metadata.get("name", skill_name) + model = metadata.get("model", model) + + knowledge_dir = temp_path / "knowledge_files" + knowledge_count = 0 + if knowledge_dir.exists(): + knowledge_count = len(list(knowledge_dir.glob("*.md"))) + + client = OpenAI( + api_key=api_key, + base_url=self.DEFAULT_API_ENDPOINT, + ) + + client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": instructions}, + { + "role": "user", + "content": f"Confirm you are ready to assist with {skill_name}. Reply briefly.", + }, + ], + temperature=0.3, + max_tokens=100, + ) + + return { + "success": True, + "skill_id": None, + "url": self.PLATFORM_URL, + "message": f"Skill '{skill_name}' validated with {self.PLATFORM_NAME} {model} ({knowledge_count} knowledge files)", + } + + except APITimeoutError: + return { + "success": False, + "skill_id": None, + "url": None, + "message": "Upload timed out. Try again.", + } + except APIConnectionError: + return { + "success": False, + "skill_id": None, + "url": None, + "message": "Connection error. Check your internet connection.", + } + except Exception as e: + return { + "success": False, + "skill_id": None, + "url": None, + "message": f"Upload failed: {str(e)}", + } + + def validate_api_key(self, api_key: str) -> bool: + """Validate API key (non-empty, >10 chars).""" + key = api_key.strip() + return len(key) > 10 + + def get_env_var_name(self) -> str: + """Get environment variable name for API key.""" + return self.ENV_VAR_NAME + + def supports_enhancement(self) -> bool: + """OpenAI-compatible platforms support enhancement.""" + return True + + def enhance(self, skill_dir: Path, api_key: str) -> bool: + """ + Enhance SKILL.md using the platform's OpenAI-compatible API. + """ + try: + from openai import OpenAI + except ImportError: + print("Error: openai package not installed") + print("Install with: pip install openai") + return False + + skill_dir = Path(skill_dir) + references_dir = skill_dir / "references" + skill_md_path = skill_dir / "SKILL.md" + + print("Reading reference documentation...") + references = self._read_reference_files(references_dir) + + if not references: + print("No reference files found to analyze") + return False + + print(f" Read {len(references)} reference files") + total_size = sum(len(c) for c in references.values()) + print(f" Total size: {total_size:,} characters\n") + + current_skill_md = None + if skill_md_path.exists(): + current_skill_md = skill_md_path.read_text(encoding="utf-8") + print(f" Found existing SKILL.md ({len(current_skill_md)} chars)") + else: + print(" No existing SKILL.md, will create new one") + + prompt = self._build_enhancement_prompt(skill_dir.name, references, current_skill_md) + + print(f"\nAsking {self.PLATFORM_NAME} ({self.DEFAULT_MODEL}) to enhance SKILL.md...") + print(f" Input: {len(prompt):,} characters") + + try: + client = OpenAI( + api_key=api_key, + base_url=self.DEFAULT_API_ENDPOINT, + ) + + response = client.chat.completions.create( + model=self.DEFAULT_MODEL, + messages=[ + { + "role": "system", + "content": f"You are an expert technical writer creating system instructions for {self.PLATFORM_NAME}.", + }, + {"role": "user", "content": prompt}, + ], + temperature=0.3, + max_tokens=4096, + ) + + enhanced_content = response.choices[0].message.content + print(f" Generated enhanced SKILL.md ({len(enhanced_content)} chars)\n") + + if skill_md_path.exists(): + backup_path = skill_md_path.with_suffix(".md.backup") + skill_md_path.rename(backup_path) + print(f" Backed up original to: {backup_path.name}") + + skill_md_path.write_text(enhanced_content, encoding="utf-8") + print(" Saved enhanced SKILL.md") + + return True + + except Exception as e: + print(f"Error calling {self.PLATFORM_NAME} API: {e}") + return False + + def _read_reference_files( + self, references_dir: Path, max_chars: int = 200000 + ) -> dict[str, str]: + """Read reference markdown files from skill directory.""" + if not references_dir.exists(): + return {} + + references = {} + total_chars = 0 + + for ref_file in sorted(references_dir.glob("*.md")): + if total_chars >= max_chars: + break + + try: + content = ref_file.read_text(encoding="utf-8") + if len(content) > 30000: + content = content[:30000] + "\n\n...(truncated)" + + references[ref_file.name] = content + total_chars += len(content) + + except Exception as e: + print(f" Could not read {ref_file.name}: {e}") + + return references + + def _build_enhancement_prompt( + self, skill_name: str, references: dict[str, str], current_skill_md: str = None + ) -> str: + """Build API prompt for enhancement.""" + prompt = f"""You are creating system instructions for a {self.PLATFORM_NAME} assistant about: {skill_name} + +I've scraped documentation and organized it into reference files. Your job is to create EXCELLENT system instructions that will help the assistant use this documentation effectively. + +CURRENT INSTRUCTIONS: +{"```" if current_skill_md else "(none - create from scratch)"} +{current_skill_md or "No existing instructions"} +{"```" if current_skill_md else ""} + +REFERENCE DOCUMENTATION: +""" + + for filename, content in references.items(): + prompt += f"\n\n## {filename}\n```markdown\n{content[:30000]}\n```\n" + + prompt += f""" + +YOUR TASK: +Create enhanced system instructions that include: + +1. **Clear role definition** - "You are an expert assistant for [topic]" +2. **Knowledge base description** - What documentation is attached +3. **Excellent Quick Reference** - Extract 5-10 of the BEST, most practical code examples from the reference docs + - Choose SHORT, clear examples that demonstrate common tasks + - Include both simple and intermediate examples + - Annotate examples with clear descriptions + - Use proper language tags (cpp, python, javascript, json, etc.) +4. **Response guidelines** - How the assistant should help users +5. **Search strategy** - How to find information in the knowledge base +6. **DO NOT use YAML frontmatter** - This is plain text instructions + +IMPORTANT: +- Extract REAL examples from the reference docs, don't make them up +- Prioritize SHORT, clear examples (5-20 lines max) +- Make it actionable and practical +- Write clear, direct instructions +- Focus on how the assistant should behave and respond +- NO YAML frontmatter (no --- blocks) + +OUTPUT: +Return ONLY the complete system instructions as plain text. +""" + + return prompt diff --git a/src/skill_seekers/cli/adaptors/opencode.py b/src/skill_seekers/cli/adaptors/opencode.py new file mode 100644 index 0000000..6785880 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/opencode.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +OpenCode Adaptor + +Generates skills in OpenCode-compatible format with YAML frontmatter. +OpenCode searches ~/.opencode/skills/ for SKILL.md files. +""" + +import re +import shutil +from pathlib import Path +from typing import Any + +from .base import SkillAdaptor, SkillMetadata +from skill_seekers.cli.arguments.common import DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP_TOKENS + + +class OpenCodeAdaptor(SkillAdaptor): + """ + OpenCode platform adaptor. + + Generates directory-based skill packages with dual-format YAML frontmatter + compatible with both OpenCode and Claude Code. + """ + + PLATFORM = "opencode" + PLATFORM_NAME = "OpenCode" + DEFAULT_API_ENDPOINT = None # Local file-based, no API + + # OpenCode name validation: kebab-case, 1-64 chars + NAME_REGEX = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + @staticmethod + def _to_kebab_case(name: str) -> str: + """ + Convert any title/name to valid OpenCode kebab-case. + + Rules: + - Lowercase + - Replace spaces, underscores, dots with hyphens + - Remove non-alphanumeric chars (except hyphens) + - Collapse multiple hyphens + - Strip leading/trailing hyphens + - Truncate to 64 chars + + Args: + name: Input name string + + Returns: + Valid kebab-case name (1-64 chars) + """ + result = name.lower() + result = re.sub(r"[_\s.]+", "-", result) + result = re.sub(r"[^a-z0-9-]", "", result) + result = re.sub(r"-+", "-", result) + result = result.strip("-") + result = result[:64] + result = result.rstrip("-") + return result or "skill" + + def format_skill_md(self, skill_dir: Path, metadata: SkillMetadata) -> str: + """ + Format SKILL.md with OpenCode-compatible YAML frontmatter. + + Generates a superset frontmatter that works with both Claude and OpenCode. + OpenCode-required fields: kebab-case name, compatibility, metadata map. + + Args: + skill_dir: Path to skill directory + metadata: Skill metadata + + Returns: + Formatted SKILL.md content with YAML frontmatter + """ + existing_content = self._read_existing_content(skill_dir) + kebab_name = self._to_kebab_case(metadata.name) + description = metadata.description[:1024] if metadata.description else "" + + # Quote description to handle colons and special YAML chars + safe_desc = description.replace('"', '\\"') + safe_source = metadata.name.replace('"', '\\"') + + frontmatter = f"""--- +name: {kebab_name} +description: "{safe_desc}" +version: {metadata.version} +license: MIT +compatibility: opencode +metadata: + generated-by: skill-seekers + source: "{safe_source}" + version: {metadata.version} +---""" + + if existing_content and len(existing_content) > 100: + return f"{frontmatter}\n\n{existing_content}" + + toc = self._generate_toc(skill_dir) + quick_ref = self._extract_quick_reference(skill_dir) + + body = f"""# {metadata.name} + +{metadata.description} + +## Documentation + +{toc if toc else "See references/ directory for documentation."} + +## Quick Reference + +{quick_ref}""" + + return f"{frontmatter}\n\n{body}" + + def package( + self, + skill_dir: Path, + output_path: Path, + enable_chunking: bool = False, + chunk_max_tokens: int = DEFAULT_CHUNK_TOKENS, + preserve_code_blocks: bool = True, + chunk_overlap_tokens: int = DEFAULT_CHUNK_OVERLAP_TOKENS, + ) -> Path: + """ + Package skill as a directory (not ZIP) for OpenCode. + + Creates: /-opencode/SKILL.md + references/ + + Args: + skill_dir: Path to skill directory + output_path: Output path for the package directory + + Returns: + Path to created directory + """ + skill_dir = Path(skill_dir) + output_path = Path(output_path) + + dir_name = f"{skill_dir.name}-opencode" + + if output_path.is_dir() or str(output_path).endswith("/"): + target_dir = output_path / dir_name + else: + target_dir = output_path + + # Clean and create target + if target_dir.exists(): + shutil.rmtree(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + + # Copy SKILL.md + skill_md = skill_dir / "SKILL.md" + if skill_md.exists(): + shutil.copy2(skill_md, target_dir / "SKILL.md") + + # Copy references + refs_dir = skill_dir / "references" + if refs_dir.exists(): + target_refs = target_dir / "references" + shutil.copytree( + refs_dir, + target_refs, + ignore=shutil.ignore_patterns("*.backup", ".*"), + ) + + return target_dir + + def upload(self, package_path: Path, api_key: str, **kwargs) -> dict[str, Any]: + """ + OpenCode uses local files, no upload needed. + + Returns local path information. + """ + package_path = Path(package_path) + return { + "success": True, + "skill_id": None, + "url": None, + "message": f"OpenCode skill packaged at: {package_path} (local install only)", + } + + def validate_api_key(self, api_key: str) -> bool: + """No API key needed for OpenCode.""" + return True + + def supports_enhancement(self) -> bool: + """OpenCode does not have its own enhancement API.""" + return False diff --git a/src/skill_seekers/cli/adaptors/openrouter.py b/src/skill_seekers/cli/adaptors/openrouter.py new file mode 100644 index 0000000..8a33b6e --- /dev/null +++ b/src/skill_seekers/cli/adaptors/openrouter.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +""" +OpenRouter Adaptor + +OpenAI-compatible LLM platform adaptor for OpenRouter. +""" + +from .openai_compatible import OpenAICompatibleAdaptor + + +class OpenRouterAdaptor(OpenAICompatibleAdaptor): + """OpenRouter platform adaptor.""" + + PLATFORM = "openrouter" + PLATFORM_NAME = "OpenRouter" + DEFAULT_API_ENDPOINT = "https://openrouter.ai/api/v1" + DEFAULT_MODEL = "openrouter/auto" + ENV_VAR_NAME = "OPENROUTER_API_KEY" + PLATFORM_URL = "https://openrouter.ai/" diff --git a/src/skill_seekers/cli/adaptors/qwen.py b/src/skill_seekers/cli/adaptors/qwen.py new file mode 100644 index 0000000..a7faa26 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/qwen.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +""" +Qwen (Alibaba) Adaptor + +OpenAI-compatible LLM platform adaptor for Qwen/DashScope. +""" + +from .openai_compatible import OpenAICompatibleAdaptor + + +class QwenAdaptor(OpenAICompatibleAdaptor): + """Qwen (Alibaba Cloud) platform adaptor.""" + + PLATFORM = "qwen" + PLATFORM_NAME = "Qwen (Alibaba)" + DEFAULT_API_ENDPOINT = "https://dashscope.aliyuncs.com/compatible-mode/v1" + DEFAULT_MODEL = "qwen-max" + ENV_VAR_NAME = "DASHSCOPE_API_KEY" + PLATFORM_URL = "https://dashscope.console.aliyun.com/" diff --git a/src/skill_seekers/cli/adaptors/together.py b/src/skill_seekers/cli/adaptors/together.py new file mode 100644 index 0000000..3617b48 --- /dev/null +++ b/src/skill_seekers/cli/adaptors/together.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +""" +Together AI Adaptor + +OpenAI-compatible LLM platform adaptor for Together AI. +""" + +from .openai_compatible import OpenAICompatibleAdaptor + + +class TogetherAdaptor(OpenAICompatibleAdaptor): + """Together AI platform adaptor.""" + + PLATFORM = "together" + PLATFORM_NAME = "Together AI" + DEFAULT_API_ENDPOINT = "https://api.together.xyz/v1" + DEFAULT_MODEL = "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" + ENV_VAR_NAME = "TOGETHER_API_KEY" + PLATFORM_URL = "https://api.together.xyz/" diff --git a/src/skill_seekers/cli/install_agent.py b/src/skill_seekers/cli/install_agent.py index 1c59204..47f187f 100644 --- a/src/skill_seekers/cli/install_agent.py +++ b/src/skill_seekers/cli/install_agent.py @@ -44,6 +44,13 @@ AGENT_PATHS = { "aide": "~/.aide/skills/", # Global "windsurf": "~/.windsurf/skills/", # Global "neovate": "~/.neovate/skills/", # Global + "roo": ".roo/skills/", # Project-relative (Roo Code, Cline fork) + "cline": ".cline/skills/", # Project-relative (Cline AI) + "aider": "~/.aider/skills/", # Global (terminal AI coding) + "bolt": ".bolt/skills/", # Project-relative (Bolt.new/Bolt.diy) + "kilo": ".kilo/skills/", # Project-relative (Kilo Code, Cline fork) + "continue": "~/.continue/skills/", # Global (Continue.dev) + "kimi-code": "~/.kimi/skills/", # Global (Kimi Code) } @@ -360,7 +367,8 @@ Examples: skill-seekers install-agent output/react/ --agent cursor --dry-run Supported agents: - claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf, neovate, all + claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf, + neovate, roo, cline, aider, bolt, kilo, continue, kimi-code, all """, ) diff --git a/src/skill_seekers/cli/opencode_skill_splitter.py b/src/skill_seekers/cli/opencode_skill_splitter.py new file mode 100644 index 0000000..4cd86ae --- /dev/null +++ b/src/skill_seekers/cli/opencode_skill_splitter.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +""" +OpenCode Skill Splitter + +Splits large documentation skills into multiple focused sub-skills for +OpenCode's on-demand loading. Reuses existing split_config + generate_router patterns. + +Usage: + skill-seekers opencode-split [--max-size 50000] [--output-dir output/] +""" + +import argparse +import contextlib +import re +import sys +from pathlib import Path +from typing import Any + +from skill_seekers.cli.adaptors.opencode import OpenCodeAdaptor + + +class OpenCodeSkillSplitter: + """ + Splits large skills into multiple focused sub-skills for OpenCode. + + Strategy: + 1. Read SKILL.md and references + 2. Split by H2 sections in SKILL.md (or by reference files if no sections) + 3. Generate a router SKILL.md that lists all sub-skills + 4. Output each sub-skill with OpenCode-compatible frontmatter + """ + + def __init__(self, skill_dir: str | Path, max_chars: int = 50000): + self.skill_dir = Path(skill_dir) + self.max_chars = max_chars + self.adaptor = OpenCodeAdaptor() + + def needs_splitting(self) -> bool: + """Check if the skill exceeds the size threshold.""" + total = 0 + skill_md = self.skill_dir / "SKILL.md" + if skill_md.exists(): + total += skill_md.stat().st_size + + refs_dir = self.skill_dir / "references" + if refs_dir.exists(): + for f in refs_dir.rglob("*.md"): + total += f.stat().st_size + + return total > self.max_chars + + def _extract_sections(self, content: str) -> list[dict[str, str]]: + """ + Extract H2 sections from markdown content. + + Returns list of {title, content} dicts. + """ + # Strip YAML frontmatter + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + content = parts[2] + + sections = [] + # Split on ## headers + pattern = re.compile(r"^## (.+)$", re.MULTILINE) + matches = list(pattern.finditer(content)) + + if not matches: + return [{"title": "main", "content": content.strip()}] + + # Content before first section + preamble = content[: matches[0].start()].strip() + if preamble: + sections.append({"title": "overview", "content": preamble}) + + for i, match in enumerate(matches): + title = match.group(1).strip() + start = match.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(content) + section_content = content[start:end].strip() + if section_content: + sections.append({"title": title, "content": f"## {title}\n\n{section_content}"}) + + return sections + + def _group_small_sections(self, sections: list[dict[str, str]]) -> list[dict[str, str]]: + """Merge sections that are too small to be standalone skills.""" + if not sections: + return sections + + grouped = [] + current = None + + for section in sections: + if current is None: + current = dict(section) + continue + + combined_size = len(current["content"]) + len(section["content"]) + if combined_size < self.max_chars // 4: + # Merge small sections + current["title"] = f"{current['title']}-and-{section['title']}" + current["content"] += f"\n\n{section['content']}" + else: + grouped.append(current) + current = dict(section) + + if current: + grouped.append(current) + + return grouped + + def split(self, output_dir: str | Path | None = None) -> list[Path]: + """ + Split the skill into multiple sub-skills. + + Args: + output_dir: Output directory (default: -split/) + + Returns: + List of paths to created sub-skill directories + """ + if output_dir is None: + output_dir = self.skill_dir.parent / f"{self.skill_dir.name}-opencode-split" + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + skill_name = self.skill_dir.name + base_name = OpenCodeAdaptor._to_kebab_case(skill_name) + + # Read SKILL.md + skill_md = self.skill_dir / "SKILL.md" + if not skill_md.exists(): + print(f"Error: SKILL.md not found in {self.skill_dir}") + return [] + + content = skill_md.read_text(encoding="utf-8") + + # Extract and group sections + sections = self._extract_sections(content) + sections = self._group_small_sections(sections) + + if len(sections) <= 1: + # Try splitting by reference files instead + sections = self._split_by_references() + + if len(sections) <= 1: + print(f"Skill {skill_name} has only 1 section, no splitting needed") + return [self.skill_dir] + + created_dirs = [] + sub_skill_names = [] + + # Create sub-skills + for section in sections: + section_name = OpenCodeAdaptor._to_kebab_case(section["title"]) + sub_name = f"{base_name}-{section_name}" + sub_dir = output_dir / sub_name + sub_dir.mkdir(parents=True, exist_ok=True) + + # Write sub-skill SKILL.md with frontmatter (quote values for YAML safety) + safe_title = section["title"].replace('"', '\\"') + safe_skill = skill_name.replace('"', '\\"') + frontmatter = f"""--- +name: {sub_name} +description: "{safe_skill} - {safe_title}" +version: 1.0.0 +license: MIT +compatibility: opencode +metadata: + generated-by: skill-seekers + source: "{safe_skill}" + parent-skill: {base_name} + section: "{safe_title}" +---""" + + sub_content = ( + f"{frontmatter}\n\n# {skill_name} - {section['title']}\n\n{section['content']}" + ) + (sub_dir / "SKILL.md").write_text(sub_content, encoding="utf-8") + + sub_skill_names.append(sub_name) + created_dirs.append(sub_dir) + + # Create router skill + router_dir = output_dir / base_name + router_dir.mkdir(parents=True, exist_ok=True) + + router_content = self._generate_router(base_name, skill_name, sub_skill_names) + (router_dir / "SKILL.md").write_text(router_content, encoding="utf-8") + created_dirs.insert(0, router_dir) + + print(f"Split '{skill_name}' into {len(sub_skill_names)} sub-skills + 1 router:") + print(f" Router: {base_name}/") + for name in sub_skill_names: + print(f" Sub-skill: {name}/") + + return created_dirs + + def _split_by_references(self) -> list[dict[str, str]]: + """Split by reference files when SKILL.md doesn't have enough sections.""" + refs_dir = self.skill_dir / "references" + if not refs_dir.exists(): + return [] + + sections = [] + for ref_file in sorted(refs_dir.glob("*.md")): + if ref_file.name.startswith(".") or ref_file.name == "index.md": + continue + try: + content = ref_file.read_text(encoding="utf-8") + title = ref_file.stem.replace("_", " ").replace("-", " ") + sections.append({"title": title, "content": content}) + except Exception: + continue + + return sections + + def _generate_router(self, base_name: str, skill_name: str, sub_skill_names: list[str]) -> str: + """Generate a router SKILL.md that lists all sub-skills.""" + safe_skill = skill_name.replace('"', '\\"') + frontmatter = f"""--- +name: {base_name} +description: "Router for {safe_skill} documentation. Directs to specialized sub-skills." +version: 1.0.0 +license: MIT +compatibility: opencode +metadata: + generated-by: skill-seekers + source: "{safe_skill}" + is-router: true + sub-skills: {len(sub_skill_names)} +---""" + + sub_list = "\n".join( + f"- `{name}` - {name.replace(base_name + '-', '').replace('-', ' ').title()}" + for name in sub_skill_names + ) + + body = f"""# {skill_name} + +This is a router skill that directs to specialized sub-skills. + +## Available Sub-Skills + +{sub_list} + +## Usage + +When answering questions about {skill_name}, load the relevant sub-skill for detailed information. +Each sub-skill covers a specific topic area of the documentation.""" + + return f"{frontmatter}\n\n{body}" + + +class OpenCodeSkillConverter: + """ + Bi-directional skill format converter. + + Converts between Skill Seekers format and OpenCode ecosystem format. + """ + + @staticmethod + def import_opencode_skill(source_dir: str | Path) -> dict[str, Any]: + """ + Import a skill from OpenCode format into Skill Seekers format. + + Reads an OpenCode skill directory and returns a normalized dict + suitable for further processing by Skill Seekers adaptors. + + Args: + source_dir: Path to OpenCode skill directory + + Returns: + Dict with keys: name, description, version, content, references, metadata + """ + source_dir = Path(source_dir) + + skill_md = source_dir / "SKILL.md" + if not skill_md.exists(): + raise FileNotFoundError(f"SKILL.md not found in {source_dir}") + + raw = skill_md.read_text(encoding="utf-8") + + # Parse frontmatter + frontmatter = {} + content = raw + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + for line in parts[1].strip().splitlines(): + if ":" in line: + key, _, value = line.partition(":") + frontmatter[key.strip()] = value.strip() + content = parts[2].strip() + + # Read references + references = {} + refs_dir = source_dir / "references" + if refs_dir.exists(): + for ref_file in sorted(refs_dir.glob("*.md")): + if not ref_file.name.startswith("."): + with contextlib.suppress(Exception): + references[ref_file.name] = ref_file.read_text(encoding="utf-8") + + return { + "name": frontmatter.get("name", source_dir.name), + "description": frontmatter.get("description", ""), + "version": frontmatter.get("version", "1.0.0"), + "content": content, + "references": references, + "metadata": frontmatter, + "source_format": "opencode", + } + + @staticmethod + def export_to_target( + skill_data: dict[str, Any], + target: str, + output_dir: str | Path, + ) -> Path: + """ + Export an imported skill to a target platform format. + + Args: + skill_data: Normalized skill dict from import_opencode_skill() + target: Target platform ('claude', 'gemini', 'openai', 'markdown', etc.) + output_dir: Output directory + + Returns: + Path to the exported skill directory + """ + from skill_seekers.cli.adaptors import get_adaptor + from skill_seekers.cli.adaptors.base import SkillMetadata + + output_dir = Path(output_dir) + skill_dir = output_dir / skill_data["name"] + skill_dir.mkdir(parents=True, exist_ok=True) + + # Write SKILL.md (raw content without frontmatter for now) + (skill_dir / "SKILL.md").write_text(skill_data["content"], encoding="utf-8") + + # Write references + if skill_data.get("references"): + refs_dir = skill_dir / "references" + refs_dir.mkdir(exist_ok=True) + for name, content in skill_data["references"].items(): + (refs_dir / name).write_text(content, encoding="utf-8") + + # Format using target adaptor + adaptor = get_adaptor(target) + metadata = SkillMetadata( + name=skill_data["name"], + description=skill_data.get("description", ""), + version=skill_data.get("version", "1.0.0"), + ) + + formatted = adaptor.format_skill_md(skill_dir, metadata) + (skill_dir / "SKILL.md").write_text(formatted, encoding="utf-8") + + return skill_dir + + +def main(): + parser = argparse.ArgumentParser( + prog="skill-seekers-opencode-split", + description="Split large skills into OpenCode-compatible sub-skills", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Auto-split a large skill + skill-seekers opencode-split output/react/ + + # Custom size threshold + skill-seekers opencode-split output/react/ --max-size 30000 + + # Custom output directory + skill-seekers opencode-split output/react/ --output-dir output/react-split/ + + # Import an OpenCode skill and convert to Claude format + skill-seekers opencode-convert ~/.opencode/skills/my-skill/ --target claude --output-dir output/ + + # Check if splitting is needed + skill-seekers opencode-split output/react/ --dry-run + """, + ) + + subparsers = parser.add_subparsers(dest="command") + + # Split command + split_parser = subparsers.add_parser("split", help="Split large skill into sub-skills") + split_parser.add_argument("skill_directory", help="Path to skill directory") + split_parser.add_argument( + "--max-size", type=int, default=50000, help="Max chars before splitting (default: 50000)" + ) + split_parser.add_argument("--output-dir", help="Output directory") + split_parser.add_argument( + "--dry-run", action="store_true", help="Check if splitting is needed without making changes" + ) + + # Convert command + convert_parser = subparsers.add_parser("convert", help="Convert between skill formats") + convert_parser.add_argument("source_directory", help="Path to source skill directory") + convert_parser.add_argument( + "--target", required=True, help="Target platform (claude, gemini, openai, markdown, etc.)" + ) + convert_parser.add_argument("--output-dir", required=True, help="Output directory") + + args = parser.parse_args() + + if args.command == "split" or (not hasattr(args, "command") or args.command is None): + # Default to split if no subcommand but has positional arg + if not hasattr(args, "skill_directory"): + parser.print_help() + return 1 + + splitter = OpenCodeSkillSplitter(args.skill_directory, args.max_size) + + if args.dry_run: + if splitter.needs_splitting(): + print(f"Skill needs splitting (exceeds {args.max_size} chars)") + else: + print(f"Skill does not need splitting (under {args.max_size} chars)") + return 0 + + result = splitter.split(args.output_dir) + return 0 if result else 1 + + elif args.command == "convert": + try: + skill_data = OpenCodeSkillConverter.import_opencode_skill(args.source_directory) + result = OpenCodeSkillConverter.export_to_target( + skill_data, args.target, args.output_dir + ) + print(f"Converted skill to {args.target} format: {result}") + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/templates/github-actions/update-skills.yml b/templates/github-actions/update-skills.yml new file mode 100644 index 0000000..c798f9a --- /dev/null +++ b/templates/github-actions/update-skills.yml @@ -0,0 +1,153 @@ +# GitHub Actions template for auto-updating Skill Seekers skills +# +# This workflow periodically re-scrapes documentation sources and updates +# the generated skills in your repository. +# +# Usage: +# 1. Copy this file to .github/workflows/update-skills.yml +# 2. Configure the SKILLS matrix below with your documentation sources +# 3. Set ANTHROPIC_API_KEY secret (optional, for AI enhancement) +# 4. Commit and push +# +# The workflow runs weekly by default (configurable via cron schedule). + +name: Update AI Skills + +on: + schedule: + # Run weekly on Monday at 6:00 AM UTC + - cron: '0 6 * * 1' + workflow_dispatch: + inputs: + skill_name: + description: 'Specific skill to update (leave empty for all)' + required: false + type: string + target: + description: 'Target platform' + required: false + default: 'claude' + type: choice + options: + - claude + - opencode + - gemini + - openai + - markdown + - kimi + - deepseek + - qwen + - openrouter + - together + - fireworks + agent: + description: 'Install to agent (leave empty to skip install)' + required: false + type: choice + options: + - '' + - claude + - cursor + - opencode + - all + +env: + PYTHON_VERSION: '3.12' + +jobs: + update-skills: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # ============================================================ + # CONFIGURE YOUR SKILLS HERE + # Each entry defines a documentation source to scrape. + # ============================================================ + skill: + # Example: Web documentation + # - name: react + # source: https://react.dev/reference + # target: claude + + # Example: GitHub repository + # - name: fastapi + # source: tiangolo/fastapi + # target: opencode + + # Example: PDF documentation + # - name: rfc-http + # source: ./docs/rfc9110.pdf + # target: markdown + + # Placeholder - replace with your skills + - name: placeholder + source: https://example.com/docs + target: claude + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Skill Seekers + run: pip install skill-seekers + + - name: Check if specific skill requested + id: check + run: | + if [ -n "${{ github.event.inputs.skill_name }}" ]; then + if [ "${{ matrix.skill.name }}" != "${{ github.event.inputs.skill_name }}" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + fi + fi + + - name: Generate skill + if: steps.check.outputs.skip != 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TARGET="${{ github.event.inputs.target || matrix.skill.target || 'claude' }}" + skill-seekers create "${{ matrix.skill.source }}" \ + --name "${{ matrix.skill.name }}" \ + --target "$TARGET" \ + --output-dir "output/${{ matrix.skill.name }}" + + - name: Install to agent + if: > + steps.check.outputs.skip != 'true' && + github.event.inputs.agent != '' + run: | + skill-seekers install-agent \ + "output/${{ matrix.skill.name }}" \ + --agent "${{ github.event.inputs.agent }}" \ + --force + + - name: Check for changes + if: steps.check.outputs.skip != 'true' + id: changes + run: | + if [ -n "$(git status --porcelain output/)" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Create PR with updated skills + if: steps.changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "chore: update ${{ matrix.skill.name }} skill" + title: "Update ${{ matrix.skill.name }} skill" + body: | + Automated skill update for **${{ matrix.skill.name }}**. + + Source: `${{ matrix.skill.source }}` + Target: `${{ github.event.inputs.target || matrix.skill.target || 'claude' }}` + + Generated by [Skill Seekers](https://github.com/yusufkaraaslan/Skill_Seekers) + branch: "skill-update/${{ matrix.skill.name }}" + delete-branch: true diff --git a/tests/test_adaptors/test_deepseek_adaptor.py b/tests/test_adaptors/test_deepseek_adaptor.py new file mode 100644 index 0000000..851f544 --- /dev/null +++ b/tests/test_adaptors/test_deepseek_adaptor.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Tests for DeepSeek AI adaptor""" + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available + + +class TestDeepSeekAdaptor(unittest.TestCase): + def setUp(self): + self.adaptor = get_adaptor("deepseek") + + def test_platform_info(self): + self.assertEqual(self.adaptor.PLATFORM, "deepseek") + self.assertEqual(self.adaptor.PLATFORM_NAME, "DeepSeek AI") + self.assertIn("deepseek", self.adaptor.DEFAULT_API_ENDPOINT) + self.assertEqual(self.adaptor.DEFAULT_MODEL, "deepseek-chat") + + def test_platform_available(self): + self.assertTrue(is_platform_available("deepseek")) + + def test_env_var_name(self): + self.assertEqual(self.adaptor.get_env_var_name(), "DEEPSEEK_API_KEY") + + def test_supports_enhancement(self): + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_package_metadata(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + pkg = self.adaptor.package(skill_dir, output_dir) + self.assertIn("deepseek", pkg.name) + + with zipfile.ZipFile(pkg) as zf: + meta = json.loads(zf.read("deepseek_metadata.json")) + self.assertEqual(meta["platform"], "deepseek") + self.assertIn("deepseek", meta["api_base"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptors/test_fireworks_adaptor.py b/tests/test_adaptors/test_fireworks_adaptor.py new file mode 100644 index 0000000..9c70e5c --- /dev/null +++ b/tests/test_adaptors/test_fireworks_adaptor.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Tests for Fireworks AI adaptor""" + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available + + +class TestFireworksAdaptor(unittest.TestCase): + def setUp(self): + self.adaptor = get_adaptor("fireworks") + + def test_platform_info(self): + self.assertEqual(self.adaptor.PLATFORM, "fireworks") + self.assertEqual(self.adaptor.PLATFORM_NAME, "Fireworks AI") + self.assertIn("fireworks", self.adaptor.DEFAULT_API_ENDPOINT) + self.assertIn("llama", self.adaptor.DEFAULT_MODEL.lower()) + + def test_platform_available(self): + self.assertTrue(is_platform_available("fireworks")) + + def test_env_var_name(self): + self.assertEqual(self.adaptor.get_env_var_name(), "FIREWORKS_API_KEY") + + def test_supports_enhancement(self): + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_package_metadata(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + pkg = self.adaptor.package(skill_dir, output_dir) + self.assertIn("fireworks", pkg.name) + + with zipfile.ZipFile(pkg) as zf: + meta = json.loads(zf.read("fireworks_metadata.json")) + self.assertEqual(meta["platform"], "fireworks") + self.assertIn("fireworks", meta["api_base"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptors/test_kimi_adaptor.py b/tests/test_adaptors/test_kimi_adaptor.py new file mode 100644 index 0000000..2986ded --- /dev/null +++ b/tests/test_adaptors/test_kimi_adaptor.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Tests for Kimi (Moonshot AI) adaptor""" + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available + + +class TestKimiAdaptor(unittest.TestCase): + def setUp(self): + self.adaptor = get_adaptor("kimi") + + def test_platform_info(self): + self.assertEqual(self.adaptor.PLATFORM, "kimi") + self.assertEqual(self.adaptor.PLATFORM_NAME, "Kimi (Moonshot AI)") + self.assertIn("moonshot", self.adaptor.DEFAULT_API_ENDPOINT) + self.assertEqual(self.adaptor.DEFAULT_MODEL, "moonshot-v1-128k") + + def test_platform_available(self): + self.assertTrue(is_platform_available("kimi")) + + def test_env_var_name(self): + self.assertEqual(self.adaptor.get_env_var_name(), "MOONSHOT_API_KEY") + + def test_supports_enhancement(self): + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_package_metadata(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + pkg = self.adaptor.package(skill_dir, output_dir) + self.assertIn("kimi", pkg.name) + + with zipfile.ZipFile(pkg) as zf: + meta = json.loads(zf.read("kimi_metadata.json")) + self.assertEqual(meta["platform"], "kimi") + self.assertIn("moonshot", meta["api_base"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptors/test_openai_compatible_base.py b/tests/test_adaptors/test_openai_compatible_base.py new file mode 100644 index 0000000..611ccb4 --- /dev/null +++ b/tests/test_adaptors/test_openai_compatible_base.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Tests for OpenAI-compatible base adaptor class. + +Tests shared behavior across all OpenAI-compatible platforms. +""" + +import json +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from skill_seekers.cli.adaptors.openai_compatible import OpenAICompatibleAdaptor +from skill_seekers.cli.adaptors.base import SkillMetadata + + +class ConcreteTestAdaptor(OpenAICompatibleAdaptor): + """Concrete subclass for testing the base class.""" + + PLATFORM = "testplatform" + PLATFORM_NAME = "Test Platform" + DEFAULT_API_ENDPOINT = "https://api.test.example.com/v1" + DEFAULT_MODEL = "test-model-v1" + ENV_VAR_NAME = "TEST_PLATFORM_API_KEY" + PLATFORM_URL = "https://test.example.com/" + + +class TestOpenAICompatibleBase(unittest.TestCase): + """Test shared OpenAI-compatible base behavior""" + + def setUp(self): + self.adaptor = ConcreteTestAdaptor() + + def test_constants_used_in_env_var(self): + self.assertEqual(self.adaptor.get_env_var_name(), "TEST_PLATFORM_API_KEY") + + def test_supports_enhancement(self): + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_validate_api_key_valid(self): + self.assertTrue(self.adaptor.validate_api_key("sk-some-long-api-key-string")) + + def test_validate_api_key_invalid(self): + self.assertFalse(self.adaptor.validate_api_key("")) + self.assertFalse(self.adaptor.validate_api_key(" ")) + self.assertFalse(self.adaptor.validate_api_key("short")) + + def test_format_skill_md_no_frontmatter(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test") + + metadata = SkillMetadata(name="test-skill", description="Test description") + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + self.assertFalse(formatted.startswith("---")) + self.assertIn("You are an expert assistant", formatted) + self.assertIn("test-skill", formatted) + + def test_format_skill_md_with_existing_content(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + existing = "# Existing\n\n" + "x" * 200 + (skill_dir / "SKILL.md").write_text(existing) + + metadata = SkillMetadata(name="test", description="Test") + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + self.assertIn("You are an expert assistant", formatted) + + def test_package_creates_zip_with_platform_name(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test instructions") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "guide.md").write_text("# Guide") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue(package_path.exists()) + self.assertTrue(str(package_path).endswith(".zip")) + self.assertIn("testplatform", package_path.name) + + def test_package_metadata_uses_constants(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "guide.md").write_text("# Guide") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + with zipfile.ZipFile(package_path, "r") as zf: + metadata_content = zf.read("testplatform_metadata.json").decode("utf-8") + metadata = json.loads(metadata_content) + self.assertEqual(metadata["platform"], "testplatform") + self.assertEqual(metadata["model"], "test-model-v1") + self.assertEqual(metadata["api_base"], "https://api.test.example.com/v1") + + def test_package_zip_structure(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + + with zipfile.ZipFile(package_path, "r") as zf: + names = zf.namelist() + self.assertIn("system_instructions.txt", names) + self.assertIn("testplatform_metadata.json", names) + self.assertTrue(any("knowledge_files" in n for n in names)) + + def test_upload_missing_file(self): + result = self.adaptor.upload(Path("/nonexistent/file.zip"), "test-key") + self.assertFalse(result["success"]) + self.assertIn("not found", result["message"].lower()) + + def test_upload_wrong_format(self): + with tempfile.NamedTemporaryFile(suffix=".tar.gz") as tmp: + result = self.adaptor.upload(Path(tmp.name), "test-key") + self.assertFalse(result["success"]) + self.assertIn("not a zip", result["message"].lower()) + + def test_upload_missing_library(self): + with tempfile.NamedTemporaryFile(suffix=".zip") as tmp: + with patch.dict(sys.modules, {"openai": None}): + result = self.adaptor.upload(Path(tmp.name), "test-key") + self.assertFalse(result["success"]) + self.assertIn("openai", result["message"]) + + @patch("openai.OpenAI") + def test_upload_success_mocked(self, mock_openai_class): + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Ready" + mock_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_client + + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + package_path = self.adaptor.package(skill_dir, output_dir) + result = self.adaptor.upload(package_path, "test-long-api-key-string") + + self.assertTrue(result["success"]) + self.assertEqual(result["url"], "https://test.example.com/") + self.assertIn("validated", result["message"]) + + def test_read_reference_files(self): + with tempfile.TemporaryDirectory() as temp_dir: + refs_dir = Path(temp_dir) + (refs_dir / "guide.md").write_text("# Guide\nContent") + (refs_dir / "api.md").write_text("# API\nDocs") + + refs = self.adaptor._read_reference_files(refs_dir) + self.assertEqual(len(refs), 2) + + def test_read_reference_files_truncation(self): + with tempfile.TemporaryDirectory() as temp_dir: + (Path(temp_dir) / "large.md").write_text("x" * 50000) + refs = self.adaptor._read_reference_files(Path(temp_dir)) + self.assertIn("truncated", refs["large.md"]) + self.assertLessEqual(len(refs["large.md"]), 31000) + + def test_build_enhancement_prompt_uses_platform_name(self): + refs = {"test.md": "# Test\nContent"} + prompt = self.adaptor._build_enhancement_prompt("skill", refs, None) + self.assertIn("Test Platform", prompt) + + @patch("openai.OpenAI") + def test_enhance_success_mocked(self, mock_openai_class): + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Enhanced content" + mock_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_client + + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + refs_dir = skill_dir / "references" + refs_dir.mkdir() + (refs_dir / "test.md").write_text("# Test\nContent") + (skill_dir / "SKILL.md").write_text("Original") + + success = self.adaptor.enhance(skill_dir, "test-api-key") + + self.assertTrue(success) + self.assertEqual((skill_dir / "SKILL.md").read_text(), "Enhanced content") + self.assertTrue((skill_dir / "SKILL.md.backup").exists()) + + def test_enhance_missing_references(self): + with tempfile.TemporaryDirectory() as temp_dir: + self.assertFalse(self.adaptor.enhance(Path(temp_dir), "key")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptors/test_opencode_adaptor.py b/tests/test_adaptors/test_opencode_adaptor.py new file mode 100644 index 0000000..4dce595 --- /dev/null +++ b/tests/test_adaptors/test_opencode_adaptor.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Tests for OpenCode adaptor +""" + +import tempfile +import unittest +from pathlib import Path + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available +from skill_seekers.cli.adaptors.base import SkillMetadata +from skill_seekers.cli.adaptors.opencode import OpenCodeAdaptor + + +class TestOpenCodeAdaptor(unittest.TestCase): + """Test OpenCode adaptor functionality""" + + def setUp(self): + self.adaptor = get_adaptor("opencode") + + def test_platform_info(self): + self.assertEqual(self.adaptor.PLATFORM, "opencode") + self.assertEqual(self.adaptor.PLATFORM_NAME, "OpenCode") + self.assertIsNone(self.adaptor.DEFAULT_API_ENDPOINT) + + def test_platform_available(self): + self.assertTrue(is_platform_available("opencode")) + + def test_validate_api_key_always_true(self): + self.assertTrue(self.adaptor.validate_api_key("")) + self.assertTrue(self.adaptor.validate_api_key("anything")) + + def test_no_enhancement_support(self): + self.assertFalse(self.adaptor.supports_enhancement()) + + def test_upload_returns_local_path(self): + result = self.adaptor.upload(Path("/some/path"), "") + self.assertTrue(result["success"]) + self.assertIn("local", result["message"].lower()) + + # --- Kebab-case conversion --- + + def test_kebab_case_spaces(self): + self.assertEqual(OpenCodeAdaptor._to_kebab_case("My Cool Skill"), "my-cool-skill") + + def test_kebab_case_underscores(self): + self.assertEqual(OpenCodeAdaptor._to_kebab_case("my_cool_skill"), "my-cool-skill") + + def test_kebab_case_special_chars(self): + self.assertEqual(OpenCodeAdaptor._to_kebab_case("My Skill! (v2.0)"), "my-skill-v2-0") + + def test_kebab_case_uppercase(self): + self.assertEqual(OpenCodeAdaptor._to_kebab_case("ALLCAPS"), "allcaps") + + def test_kebab_case_truncation(self): + long_name = "a" * 100 + result = OpenCodeAdaptor._to_kebab_case(long_name) + self.assertLessEqual(len(result), 64) + + def test_kebab_case_empty(self): + self.assertEqual(OpenCodeAdaptor._to_kebab_case("!!!"), "skill") + + def test_kebab_case_valid_regex(self): + """All converted names must match OpenCode's regex""" + test_names = [ + "My Skill", + "test_skill_v2", + "UPPERCASE NAME", + "special!@#chars", + "dots.and.periods", + "a", + ] + for name in test_names: + result = OpenCodeAdaptor._to_kebab_case(name) + self.assertRegex(result, r"^[a-z0-9]+(-[a-z0-9]+)*$", f"Failed for: {name}") + + # --- Format --- + + def test_format_skill_md_has_frontmatter(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + (skill_dir / "references").mkdir() + (skill_dir / "references" / "test.md").write_text("# Test content") + + metadata = SkillMetadata(name="test-skill", description="Test description") + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + self.assertTrue(formatted.startswith("---")) + self.assertIn("name: test-skill", formatted) + self.assertIn("compatibility: opencode", formatted) + self.assertIn("generated-by: skill-seekers", formatted) + + def test_format_description_truncation(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + long_desc = "x" * 2000 + metadata = SkillMetadata(name="test", description=long_desc) + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + # The description in frontmatter should be truncated to 1024 chars + # (plus YAML quotes around it) + lines = formatted.split("\n") + for line in lines: + if line.startswith("description:"): + desc_value = line[len("description:") :].strip() + # Strip surrounding quotes for length check + inner = desc_value.strip('"') + self.assertLessEqual(len(inner), 1024) + break + + def test_format_with_existing_content(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) + existing = "# Existing Content\n\n" + "x" * 200 + (skill_dir / "SKILL.md").write_text(existing) + + metadata = SkillMetadata(name="test", description="Test") + formatted = self.adaptor.format_skill_md(skill_dir, metadata) + + self.assertTrue(formatted.startswith("---")) + self.assertIn("Existing Content", formatted) + + # --- Package --- + + def test_package_creates_directory(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Test") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "guide.md").write_text("# Guide") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + result_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue(result_path.exists()) + self.assertTrue(result_path.is_dir()) + self.assertIn("opencode", result_path.name) + + def test_package_contains_skill_md(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Test content") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + result_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue((result_path / "SKILL.md").exists()) + content = (result_path / "SKILL.md").read_text() + self.assertEqual(content, "# Test content") + + def test_package_copies_references(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Test") + refs = skill_dir / "references" + refs.mkdir() + (refs / "guide.md").write_text("# Guide") + (refs / "api.md").write_text("# API") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + result_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue((result_path / "references" / "guide.md").exists()) + self.assertTrue((result_path / "references" / "api.md").exists()) + + def test_package_excludes_backup_files(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Test") + refs = skill_dir / "references" + refs.mkdir() + (refs / "guide.md").write_text("# Guide") + (refs / "guide.md.backup").write_text("# Old") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + result_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue((result_path / "references" / "guide.md").exists()) + self.assertFalse((result_path / "references" / "guide.md.backup").exists()) + + def test_package_without_references(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + result_path = self.adaptor.package(skill_dir, output_dir) + + self.assertTrue(result_path.exists()) + self.assertTrue((result_path / "SKILL.md").exists()) + self.assertFalse((result_path / "references").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptors/test_openrouter_adaptor.py b/tests/test_adaptors/test_openrouter_adaptor.py new file mode 100644 index 0000000..c9ed87b --- /dev/null +++ b/tests/test_adaptors/test_openrouter_adaptor.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Tests for OpenRouter adaptor""" + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available + + +class TestOpenRouterAdaptor(unittest.TestCase): + def setUp(self): + self.adaptor = get_adaptor("openrouter") + + def test_platform_info(self): + self.assertEqual(self.adaptor.PLATFORM, "openrouter") + self.assertEqual(self.adaptor.PLATFORM_NAME, "OpenRouter") + self.assertIn("openrouter", self.adaptor.DEFAULT_API_ENDPOINT) + self.assertEqual(self.adaptor.DEFAULT_MODEL, "openrouter/auto") + + def test_platform_available(self): + self.assertTrue(is_platform_available("openrouter")) + + def test_env_var_name(self): + self.assertEqual(self.adaptor.get_env_var_name(), "OPENROUTER_API_KEY") + + def test_supports_enhancement(self): + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_package_metadata(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + pkg = self.adaptor.package(skill_dir, output_dir) + self.assertIn("openrouter", pkg.name) + + with zipfile.ZipFile(pkg) as zf: + meta = json.loads(zf.read("openrouter_metadata.json")) + self.assertEqual(meta["platform"], "openrouter") + self.assertIn("openrouter", meta["api_base"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptors/test_qwen_adaptor.py b/tests/test_adaptors/test_qwen_adaptor.py new file mode 100644 index 0000000..c7924bb --- /dev/null +++ b/tests/test_adaptors/test_qwen_adaptor.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Tests for Qwen (Alibaba) adaptor""" + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available + + +class TestQwenAdaptor(unittest.TestCase): + def setUp(self): + self.adaptor = get_adaptor("qwen") + + def test_platform_info(self): + self.assertEqual(self.adaptor.PLATFORM, "qwen") + self.assertEqual(self.adaptor.PLATFORM_NAME, "Qwen (Alibaba)") + self.assertIn("dashscope", self.adaptor.DEFAULT_API_ENDPOINT) + self.assertEqual(self.adaptor.DEFAULT_MODEL, "qwen-max") + + def test_platform_available(self): + self.assertTrue(is_platform_available("qwen")) + + def test_env_var_name(self): + self.assertEqual(self.adaptor.get_env_var_name(), "DASHSCOPE_API_KEY") + + def test_supports_enhancement(self): + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_package_metadata(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + pkg = self.adaptor.package(skill_dir, output_dir) + self.assertIn("qwen", pkg.name) + + with zipfile.ZipFile(pkg) as zf: + meta = json.loads(zf.read("qwen_metadata.json")) + self.assertEqual(meta["platform"], "qwen") + self.assertIn("dashscope", meta["api_base"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adaptors/test_together_adaptor.py b/tests/test_adaptors/test_together_adaptor.py new file mode 100644 index 0000000..8185c29 --- /dev/null +++ b/tests/test_adaptors/test_together_adaptor.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Tests for Together AI adaptor""" + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from skill_seekers.cli.adaptors import get_adaptor, is_platform_available + + +class TestTogetherAdaptor(unittest.TestCase): + def setUp(self): + self.adaptor = get_adaptor("together") + + def test_platform_info(self): + self.assertEqual(self.adaptor.PLATFORM, "together") + self.assertEqual(self.adaptor.PLATFORM_NAME, "Together AI") + self.assertIn("together", self.adaptor.DEFAULT_API_ENDPOINT) + self.assertIn("llama", self.adaptor.DEFAULT_MODEL.lower()) + + def test_platform_available(self): + self.assertTrue(is_platform_available("together")) + + def test_env_var_name(self): + self.assertEqual(self.adaptor.get_env_var_name(), "TOGETHER_API_KEY") + + def test_supports_enhancement(self): + self.assertTrue(self.adaptor.supports_enhancement()) + + def test_package_metadata(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("Test") + + output_dir = Path(temp_dir) / "output" + output_dir.mkdir() + + pkg = self.adaptor.package(skill_dir, output_dir) + self.assertIn("together", pkg.name) + + with zipfile.ZipFile(pkg) as zf: + meta = json.loads(zf.read("together_metadata.json")) + self.assertEqual(meta["platform"], "together") + self.assertIn("together", meta["api_base"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_agent.py b/tests/test_install_agent.py index 49f80d4..fe8c7c1 100644 --- a/tests/test_install_agent.py +++ b/tests/test_install_agent.py @@ -66,17 +66,38 @@ class TestAgentPathMapping: get_agent_path("invalid_agent") def test_get_available_agents(self): - """Test that all 11 agents are listed.""" + """Test that all 18 agents are listed.""" agents = get_available_agents() - assert len(agents) == 11 + assert len(agents) == 18 assert "claude" in agents assert "cursor" in agents assert "vscode" in agents assert "amp" in agents assert "goose" in agents assert "neovate" in agents + assert "roo" in agents + assert "cline" in agents + assert "aider" in agents + assert "bolt" in agents + assert "kilo" in agents + assert "continue" in agents + assert "kimi-code" in agents assert sorted(agents) == agents # Should be sorted + def test_new_agents_project_relative(self): + """Test that project-relative new agents resolve correctly.""" + for agent in ["roo", "cline", "bolt", "kilo"]: + path = get_agent_path(agent) + assert path.is_absolute() + assert str(Path.cwd()) in str(path) + + def test_new_agents_global(self): + """Test that global new agents resolve to home directory.""" + for agent in ["aider", "continue", "kimi-code"]: + path = get_agent_path(agent) + assert path.is_absolute() + assert str(path).startswith(str(Path.home())) + def test_agent_path_case_insensitive(self): """Test that agent names are case-insensitive.""" path_lower = get_agent_path("claude") @@ -340,7 +361,7 @@ class TestInstallToAllAgents: shutil.rmtree(self.tmpdir, ignore_errors=True) def test_install_to_all_success(self): - """Test that install_to_all_agents attempts all 11 agents.""" + """Test that install_to_all_agents attempts all 18 agents.""" with tempfile.TemporaryDirectory() as agent_tmpdir: def mock_get_agent_path(agent_name, _project_root=None): @@ -352,7 +373,7 @@ class TestInstallToAllAgents: ): results = install_to_all_agents(self.skill_dir, force=True) - assert len(results) == 11 + assert len(results) == 18 assert "claude" in results assert "cursor" in results @@ -362,7 +383,7 @@ class TestInstallToAllAgents: results = install_to_all_agents(self.skill_dir, dry_run=True) # All should succeed in dry-run mode - assert len(results) == 11 + assert len(results) == 18 for _agent_name, (success, message) in results.items(): assert success is True assert "DRY RUN" in message @@ -399,7 +420,7 @@ class TestInstallToAllAgents: results = install_to_all_agents(self.skill_dir, dry_run=True) assert isinstance(results, dict) - assert len(results) == 11 + assert len(results) == 18 for agent_name, (success, message) in results.items(): assert isinstance(success, bool) diff --git a/tests/test_opencode_skill_splitter.py b/tests/test_opencode_skill_splitter.py new file mode 100644 index 0000000..2256834 --- /dev/null +++ b/tests/test_opencode_skill_splitter.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Tests for OpenCode skill splitter and converter. +""" + +import tempfile +import unittest +from pathlib import Path + +from skill_seekers.cli.opencode_skill_splitter import ( + OpenCodeSkillConverter, + OpenCodeSkillSplitter, +) + + +class TestOpenCodeSkillSplitter(unittest.TestCase): + """Test skill splitting for OpenCode""" + + def _create_skill(self, temp_dir, name="test-skill", content=None, refs=None): + """Helper to create a test skill directory.""" + skill_dir = Path(temp_dir) / name + skill_dir.mkdir() + + if content is None: + content = "# Test Skill\n\n## Section A\n\nContent A\n\n## Section B\n\nContent B\n\n## Section C\n\nContent C" + (skill_dir / "SKILL.md").write_text(content) + + if refs: + refs_dir = skill_dir / "references" + refs_dir.mkdir() + for fname, fcontent in refs.items(): + (refs_dir / fname).write_text(fcontent) + + return skill_dir + + def test_needs_splitting_small(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp, content="Small content") + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=50000) + self.assertFalse(splitter.needs_splitting()) + + def test_needs_splitting_large(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp, content="x" * 60000) + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=50000) + self.assertTrue(splitter.needs_splitting()) + + def test_extract_sections(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp) + splitter = OpenCodeSkillSplitter(skill_dir) + content = (skill_dir / "SKILL.md").read_text() + sections = splitter._extract_sections(content) + # Should have: overview + Section A + Section B + Section C + self.assertGreaterEqual(len(sections), 3) + + def test_extract_sections_strips_frontmatter(self): + with tempfile.TemporaryDirectory() as tmp: + content = "---\nname: test\n---\n\n## Section A\n\nContent A" + skill_dir = self._create_skill(tmp, content=content) + splitter = OpenCodeSkillSplitter(skill_dir) + sections = splitter._extract_sections(content) + self.assertEqual(len(sections), 1) + self.assertEqual(sections[0]["title"], "Section A") + + def test_split_creates_sub_skills(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp) + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10) + + output_dir = Path(tmp) / "output" + result = splitter.split(output_dir) + + # Should create router + sub-skills + self.assertGreater(len(result), 1) + + # Each should have SKILL.md + for d in result: + self.assertTrue((d / "SKILL.md").exists()) + + def test_split_router_has_frontmatter(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp) + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10) + + output_dir = Path(tmp) / "output" + result = splitter.split(output_dir) + + # Router is first + router_content = (result[0] / "SKILL.md").read_text() + self.assertTrue(router_content.startswith("---")) + self.assertIn("is-router: true", router_content) + + def test_split_sub_skills_have_frontmatter(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp) + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10) + + output_dir = Path(tmp) / "output" + result = splitter.split(output_dir) + + # Sub-skills (skip router at index 0) + for d in result[1:]: + content = (d / "SKILL.md").read_text() + self.assertTrue(content.startswith("---")) + self.assertIn("compatibility: opencode", content) + self.assertIn("parent-skill:", content) + + def test_split_by_references(self): + with tempfile.TemporaryDirectory() as tmp: + # Skill with no H2 sections but multiple reference files + skill_dir = self._create_skill( + tmp, + content="# Simple Skill\n\nJust one paragraph.", + refs={ + "getting-started.md": "# Getting Started\n\nContent here", + "api-reference.md": "# API Reference\n\nAPI docs", + "advanced-topics.md": "# Advanced Topics\n\nAdvanced content", + }, + ) + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=10) + + output_dir = Path(tmp) / "output" + result = splitter.split(output_dir) + + # Should split by references: router + 3 sub-skills + self.assertEqual(len(result), 4) + + def test_no_split_needed(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp, content="# Simple\n\nSmall content") + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=100000) + + output_dir = Path(tmp) / "output" + result = splitter.split(output_dir) + + # Should return original skill dir (no split) + self.assertEqual(len(result), 1) + + def test_group_small_sections(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = self._create_skill(tmp) + splitter = OpenCodeSkillSplitter(skill_dir, max_chars=100000) + + sections = [ + {"title": "a", "content": "short"}, + {"title": "b", "content": "also short"}, + {"title": "c", "content": "x" * 50000}, + ] + grouped = splitter._group_small_sections(sections) + + # a and b should be merged, c stays separate + self.assertEqual(len(grouped), 2) + + +class TestOpenCodeSkillConverter(unittest.TestCase): + """Test bi-directional skill format converter""" + + def test_import_opencode_skill(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = Path(tmp) / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test skill\nversion: 2.0.0\n---\n\n# Content\n\nHello" + ) + refs = skill_dir / "references" + refs.mkdir() + (refs / "guide.md").write_text("# Guide") + + data = OpenCodeSkillConverter.import_opencode_skill(skill_dir) + + self.assertEqual(data["name"], "my-skill") + self.assertEqual(data["description"], "Test skill") + self.assertEqual(data["version"], "2.0.0") + self.assertIn("# Content", data["content"]) + self.assertIn("guide.md", data["references"]) + self.assertEqual(data["source_format"], "opencode") + + def test_import_opencode_skill_no_frontmatter(self): + with tempfile.TemporaryDirectory() as tmp: + skill_dir = Path(tmp) / "plain-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Plain content\n\nNo frontmatter") + + data = OpenCodeSkillConverter.import_opencode_skill(skill_dir) + + self.assertEqual(data["name"], "plain-skill") + self.assertIn("Plain content", data["content"]) + + def test_import_missing_skill(self): + with self.assertRaises(FileNotFoundError): + OpenCodeSkillConverter.import_opencode_skill("/nonexistent/path") + + def test_export_to_claude(self): + with tempfile.TemporaryDirectory() as tmp: + # Create source skill + source = Path(tmp) / "source" + source.mkdir() + (source / "SKILL.md").write_text("---\nname: test\ndescription: Test\n---\n\n# Content") + + # Import and export + data = OpenCodeSkillConverter.import_opencode_skill(source) + output = Path(tmp) / "output" + result = OpenCodeSkillConverter.export_to_target(data, "claude", output) + + self.assertTrue(result.exists()) + self.assertTrue((result / "SKILL.md").exists()) + + def test_export_to_markdown(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + source.mkdir() + (source / "SKILL.md").write_text("# Simple content") + + data = OpenCodeSkillConverter.import_opencode_skill(source) + output = Path(tmp) / "output" + result = OpenCodeSkillConverter.export_to_target(data, "markdown", output) + + self.assertTrue(result.exists()) + self.assertTrue((result / "SKILL.md").exists()) + + def test_roundtrip_opencode(self): + """Test import from OpenCode -> export to OpenCode preserves content.""" + with tempfile.TemporaryDirectory() as tmp: + # Create original + original = Path(tmp) / "original" + original.mkdir() + original_content = "---\nname: roundtrip-test\ndescription: Roundtrip test\n---\n\n# Roundtrip Content\n\nImportant data here." + (original / "SKILL.md").write_text(original_content) + refs = original / "references" + refs.mkdir() + (refs / "ref.md").write_text("# Reference") + + # Import + data = OpenCodeSkillConverter.import_opencode_skill(original) + + # Export to opencode + output = Path(tmp) / "output" + result = OpenCodeSkillConverter.export_to_target(data, "opencode", output) + + # Verify + exported = (result / "SKILL.md").read_text() + self.assertIn("roundtrip-test", exported) + self.assertIn("compatibility: opencode", exported) + + +class TestGitHubActionsTemplate(unittest.TestCase): + """Test that GitHub Actions template exists and is valid YAML.""" + + def test_template_exists(self): + template = ( + Path(__file__).parent.parent / "templates" / "github-actions" / "update-skills.yml" + ) + self.assertTrue(template.exists(), f"Template not found at {template}") + + def test_template_has_required_keys(self): + template = ( + Path(__file__).parent.parent / "templates" / "github-actions" / "update-skills.yml" + ) + content = template.read_text() + + self.assertIn("name:", content) + self.assertIn("on:", content) + self.assertIn("jobs:", content) + self.assertIn("skill-seekers", content) + self.assertIn("schedule:", content) + self.assertIn("workflow_dispatch:", content) + + def test_template_lists_all_targets(self): + template = ( + Path(__file__).parent.parent / "templates" / "github-actions" / "update-skills.yml" + ) + content = template.read_text() + + for target in ["claude", "opencode", "gemini", "openai", "kimi", "deepseek", "qwen"]: + self.assertIn(target, content, f"Target '{target}' not found in template") + + +if __name__ == "__main__": + unittest.main() From 6bb7078fbc6d2deea7ec91ace46dbf1caeb26623 Mon Sep 17 00:00:00 2001 From: yusyus Date: Sat, 21 Mar 2026 20:42:31 +0300 Subject: [PATCH 09/21] docs: update all documentation for 12 LLM platforms and 18 agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md + 11 i18n READMEs: 5→12 LLM platforms, 11→18 agents, new platform/agent tables - CLAUDE.md: updated --target list, adaptor directory tree - CHANGELOG.md: added v3.4.0 entry with all Phase 1-4 changes - docs/reference/CLI_REFERENCE.md: new --target and --agent options - docs/reference/FEATURE_MATRIX.md: updated all platform counts and tables - docs/user-guide/04-packaging.md: new platform and agent rows - docs/FAQ.md: expanded platform/agent answers - docs/zh-CN/*: synchronized Chinese documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 16 +++++++ CLAUDE.md | 12 +++++- README.ar.md | 15 +++++-- README.de.md | 15 +++++-- README.es.md | 15 +++++-- README.fr.md | 15 +++++-- README.hi.md | 15 +++++-- README.ja.md | 15 +++++-- README.ko.md | 15 +++++-- README.md | 15 +++++-- README.pt-BR.md | 15 +++++-- README.ru.md | 15 +++++-- README.tr.md | 15 +++++-- README.zh-CN.md | 15 +++++-- docs/FAQ.md | 51 ++++++++++++++-------- docs/README.md | 4 +- docs/getting-started/04-next-steps.md | 2 +- docs/reference/CLI_REFERENCE.md | 10 ++++- docs/reference/FEATURE_MATRIX.md | 54 +++++++++++++---------- docs/user-guide/04-packaging.md | 14 ++++++ docs/zh-CN/README.md | 8 ++-- docs/zh-CN/reference/CLI_REFERENCE.md | 15 +++++-- docs/zh-CN/reference/FEATURE_MATRIX.md | 60 +++++++++++++++----------- docs/zh-CN/user-guide/04-packaging.md | 9 +++- 24 files changed, 306 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc7dbe..c5f4826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.4.0] - 2026-03-21 + +### Added +- **OpenCode adaptor** (`--target opencode`) - Directory-based packaging with dual-format YAML frontmatter +- **OpenAI-compatible base class** - Shared base for all OpenAI-compatible LLM platforms +- **6 new LLM platform adaptors**: Kimi (`--target kimi`), DeepSeek (`--target deepseek`), Qwen (`--target qwen`), OpenRouter (`--target openrouter`), Together AI (`--target together`), Fireworks AI (`--target fireworks`) +- **7 new CLI agent install paths**: roo, cline, aider, bolt, kilo, continue, kimi-code (total: 18 agents) +- **OpenCode skill splitter** - Auto-split large docs into focused sub-skills with router +- **Bi-directional skill converter** - Import/export between OpenCode and any platform format +- **GitHub Actions template** for automated skill updates (`templates/github-actions/update-skills.yml`) + +### Changed +- Refactored MiniMax adaptor to inherit from shared OpenAI-compatible base class +- Platform count: 5 → 12 LLM targets +- Agent count: 11 → 18 install paths + ## [3.3.0] - 2026-03-16 **Theme:** 10 new source types (17 total), EPUB unified integration, sync-config command, performance optimizations, 12 README translations, and 19 bug fixes. 117 files changed, +41,588 lines since v3.2.0. diff --git a/CLAUDE.md b/CLAUDE.md index 185526c..fad3866 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Skill Seekers** converts documentation from 17 source types into production-ready formats for 16+ AI platforms (LLM platforms, RAG frameworks, vector databases, AI coding assistants). Published on PyPI as `skill-seekers`. +**Skill Seekers** converts documentation from 17 source types into production-ready formats for 24+ AI platforms (LLM platforms, RAG frameworks, vector databases, AI coding assistants). Published on PyPI as `skill-seekers`. **Version:** 3.3.0 | **Python:** 3.10+ | **Website:** https://skillseekersweb.com/ @@ -57,7 +57,7 @@ Entry point `src/skill_seekers/cli/main.py` maps subcommands to modules. The `cr ``` skill-seekers create # Auto-detect: URL, owner/repo, ./path, file.pdf, etc. skill-seekers [options] # Direct: scrape, github, pdf, word, epub, video, jupyter, html, openapi, asciidoc, pptx, rss, manpage, confluence, notion, chat -skill-seekers package # Package for platform (--target claude/gemini/openai/markdown, --format langchain/llama-index/haystack/chroma/faiss/weaviate/qdrant) +skill-seekers package # Package for platform (--target claude/gemini/openai/markdown/minimax/opencode/kimi/deepseek/qwen/openrouter/together/fireworks, --format langchain/llama-index/haystack/chroma/faiss/weaviate/qdrant) ``` ### Data Flow (5 phases) @@ -78,6 +78,14 @@ src/skill_seekers/cli/adaptors/ ├── gemini_adaptor.py # --target gemini ├── openai_adaptor.py # --target openai ├── markdown_adaptor.py # --target markdown +├── minimax_adaptor.py # --target minimax +├── opencode_adaptor.py # --target opencode +├── kimi_adaptor.py # --target kimi +├── deepseek_adaptor.py # --target deepseek +├── qwen_adaptor.py # --target qwen +├── openrouter_adaptor.py # --target openrouter +├── together_adaptor.py # --target together +├── fireworks_adaptor.py # --target fireworks ├── langchain.py # --format langchain ├── llama_index.py # --format llama-index ├── haystack.py # --format haystack diff --git a/README.ar.md b/README.ar.md index ba3b175..31fe1a7 100644 --- a/README.ar.md +++ b/README.ar.md @@ -192,7 +192,7 @@ Skill Seekers هو **طبقة البيانات لأنظمة الذكاء الا - ✅ **التوافق مع الإصدارات السابقة** - إعدادات المصدر الواحد القديمة تعمل بشكل طبيعي ### 🤖 دعم منصات LLM المتعددة -- ✅ **4 منصات LLM** - Claude AI وGoogle Gemini وOpenAI ChatGPT وMarkdown العام +- ✅ **12 منصة LLM** - Claude AI وGoogle Gemini وOpenAI ChatGPT وMiniMax AI وMarkdown العام وOpenCode وKimi وDeepSeek وQwen وOpenRouter وTogether AI وFireworks AI - ✅ **استخراج عام** - نفس التوثيق يعمل لجميع المنصات - ✅ **تعبئة خاصة بكل منصة** - تنسيقات محسّنة لكل نموذج لغوي - ✅ **تصدير بأمر واحد** - علامة `--target` لاختيار المنصة @@ -579,9 +579,9 @@ skill-seekers install --config react --dry-run ## 📊 مصفوفة الميزات -يدعم Skill Seekers **4 منصات LLM** و**17 نوعًا من المصادر** مع تكافؤ كامل في الميزات عبر جميع الأهداف. +يدعم Skill Seekers **12 منصة LLM** و**17 نوعًا من المصادر** مع تكافؤ كامل في الميزات عبر جميع الأهداف. -**المنصات:** Claude AI وGoogle Gemini وOpenAI ChatGPT وMarkdown العام +**المنصات:** Claude AI وGoogle Gemini وOpenAI ChatGPT وMiniMax AI وMarkdown العام وOpenCode وKimi وDeepSeek وQwen وOpenRouter وTogether AI وFireworks AI **أنواع المصادر:** مواقع التوثيق ومستودعات GitHub وPDF وWord (.docx) وEPUB والفيديو وقواعد الكود المحلية ودفاتر Jupyter وHTML المحلي وOpenAPI/Swagger وAsciiDoc وPowerPoint (.pptx) وخلاصات RSS/Atom وصفحات Man وويكي Confluence وصفحات Notion ومحادثات Slack/Discord انظر [مصفوفة الميزات الكاملة](docs/FEATURE_MATRIX.md) لدعم المنصات والميزات بالتفصيل. @@ -817,7 +817,7 @@ skill-seekers package output/react/ ## 🤖 التثبيت في وكلاء الذكاء الاصطناعي -يمكن لـ Skill Seekers تثبيت المهارات تلقائيًا في أكثر من 10 وكلاء برمجة بالذكاء الاصطناعي. +يمكن لـ Skill Seekers تثبيت المهارات تلقائيًا في 18 وكيل برمجة بالذكاء الاصطناعي. ```bash # التثبيت في وكيل محدد @@ -841,6 +841,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | عام | | **OpenCode** | `~/.opencode/skills/` | عام | | **Windsurf** | `~/.windsurf/skills/` | عام | +| **Roo Code** | `.roo/skills/` | مشروع | +| **Cline** | `.cline/skills/` | مشروع | +| **Aider** | `~/.aider/skills/` | عام | +| **Bolt** | `.bolt/skills/` | مشروع | +| **Kilo Code** | `.kilo/skills/` | مشروع | +| **Continue** | `~/.continue/skills/` | عام | +| **Kimi Code** | `~/.kimi/skills/` | عام | --- diff --git a/README.de.md b/README.de.md index f575353..8136051 100644 --- a/README.de.md +++ b/README.de.md @@ -194,7 +194,7 @@ Anstatt tagelange manuelle Vorverarbeitung durchzuführen, erledigt Skill Seeker - **Abwärtskompatibel** - Bestehende Einzelquellen-Konfigurationen funktionieren weiterhin ### Multi-LLM-Plattformunterstützung -- **4 LLM-Plattformen** - Claude AI, Google Gemini, OpenAI ChatGPT, Generisches Markdown +- **12 LLM-Plattformen** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generisches Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - **Universelles Scraping** - Dieselbe Dokumentation funktioniert für alle Plattformen - **Plattformspezifische Paketierung** - Optimierte Formate für jedes LLM - **Ein-Befehl-Export** - `--target`-Flag wählt die Plattform @@ -581,9 +581,9 @@ Phase 5: Zu Claude hochladen (optional, erfordert API Key) ## Funktionsmatrix -Skill Seekers unterstützt **4 LLM-Plattformen**, **17 Quelltypen** und vollständige Funktionsparität für alle Ziele. +Skill Seekers unterstützt **12 LLM-Plattformen**, **17 Quelltypen** und vollständige Funktionsparität für alle Ziele. -**Plattformen:** Claude AI, Google Gemini, OpenAI ChatGPT, Generisches Markdown +**Plattformen:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generisches Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **Quelltypen:** Dokumentationswebsites, GitHub-Repos, PDFs, Word (.docx), EPUB, Video, lokale Codebasen, Jupyter-Notebooks, lokales HTML, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), RSS-/Atom-Feeds, Man-Pages, Confluence-Wikis, Notion-Seiten, Slack-/Discord-Chatexporte Vollständige Informationen finden Sie in der [vollständigen Funktionsmatrix](docs/FEATURE_MATRIX.md). @@ -819,7 +819,7 @@ In Claude Code einfach fragen: ## Installation für KI-Agenten -Skill Seekers kann Skills automatisch für über 10 KI-Programmieragenten installieren. +Skill Seekers kann Skills automatisch für 18 KI-Programmieragenten installieren. ```bash # Für einen bestimmten Agenten installieren @@ -843,6 +843,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | Global | | **OpenCode** | `~/.opencode/skills/` | Global | | **Windsurf** | `~/.windsurf/skills/` | Global | +| **Roo Code** | `.roo/skills/` | Projekt | +| **Cline** | `.cline/skills/` | Projekt | +| **Aider** | `~/.aider/skills/` | Global | +| **Bolt** | `.bolt/skills/` | Projekt | +| **Kilo Code** | `.kilo/skills/` | Projekt | +| **Continue** | `~/.continue/skills/` | Global | +| **Kimi Code** | `~/.kimi/skills/` | Global | --- diff --git a/README.es.md b/README.es.md index 6547b95..6d29672 100644 --- a/README.es.md +++ b/README.es.md @@ -239,7 +239,7 @@ En lugar de pasar días en preprocesamiento manual, Skill Seekers: - ✅ **Compatible con versiones anteriores** - Las configuraciones de fuente única legacy siguen funcionando ### 🤖 Soporte para múltiples plataformas LLM -- ✅ **4 plataformas LLM** - Claude AI, Google Gemini, OpenAI ChatGPT, Markdown genérico +- ✅ **12 plataformas LLM** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Markdown genérico, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - ✅ **Extracción universal** - La misma documentación funciona para todas las plataformas - ✅ **Empaquetado específico por plataforma** - Formatos optimizados para cada LLM - ✅ **Exportación con un solo comando** - El flag `--target` selecciona la plataforma @@ -689,9 +689,9 @@ skill-seekers install --config react --dry-run ## 📊 Matriz de funcionalidades -Skill Seekers soporta **4 plataformas LLM**, **17 tipos de fuentes** y paridad total de funcionalidades en todos los destinos. +Skill Seekers soporta **12 plataformas LLM**, **17 tipos de fuentes** y paridad total de funcionalidades en todos los destinos. -**Plataformas:** Claude AI, Google Gemini, OpenAI ChatGPT, Markdown genérico +**Plataformas:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Markdown genérico, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **Tipos de fuentes:** Sitios web de documentación, repos de GitHub, PDFs, Word (.docx), EPUB, Video, Bases de código locales, Jupyter Notebooks, HTML local, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), feeds RSS/Atom, páginas de manual, wikis de Confluence, páginas de Notion, exportaciones de chat de Slack/Discord Consulta la [Matriz completa de funcionalidades](docs/FEATURE_MATRIX.md) para información detallada de soporte por plataforma y funcionalidad. @@ -929,7 +929,7 @@ En Claude Code, simplemente pide: ## 🤖 Instalación en agentes de IA -Skill Seekers puede instalar automáticamente skills en más de 10 agentes de programación con IA. +Skill Seekers puede instalar automáticamente skills en 18 agentes de programación con IA. ```bash # Instalar en un agente específico @@ -953,6 +953,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | Global | | **OpenCode** | `~/.opencode/skills/` | Global | | **Windsurf** | `~/.windsurf/skills/` | Global | +| **Roo Code** | `.roo/skills/` | Proyecto | +| **Cline** | `.cline/skills/` | Proyecto | +| **Aider** | `~/.aider/skills/` | Global | +| **Bolt** | `.bolt/skills/` | Proyecto | +| **Kilo Code** | `.kilo/skills/` | Proyecto | +| **Continue** | `~/.continue/skills/` | Global | +| **Kimi Code** | `~/.kimi/skills/` | Global | --- diff --git a/README.fr.md b/README.fr.md index 1d8a129..06ed97f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -254,7 +254,7 @@ Au lieu de passer des jours en prétraitement manuel, Skill Seekers : - ✅ **Rétrocompatibilité** - Les configurations à source unique héritées fonctionnent toujours ### 🤖 Support multi-plateformes LLM -- ✅ **4 plateformes LLM** - Claude AI, Google Gemini, OpenAI ChatGPT, Markdown générique +- ✅ **12 plateformes LLM** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Markdown générique, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - ✅ **Scraping universel** - La même documentation fonctionne pour toutes les plateformes - ✅ **Empaquetage spécifique** - Formats optimisés pour chaque LLM - ✅ **Export en une commande** - Le flag `--target` sélectionne la plateforme @@ -704,9 +704,9 @@ skill-seekers install --config react --dry-run ## 📊 Matrice de fonctionnalités -Skill Seekers prend en charge **4 plateformes LLM**, **17 types de sources** et une parité fonctionnelle complète sur toutes les cibles. +Skill Seekers prend en charge **12 plateformes LLM**, **17 types de sources** et une parité fonctionnelle complète sur toutes les cibles. -**Plateformes :** Claude AI, Google Gemini, OpenAI ChatGPT, Markdown générique +**Plateformes :** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Markdown générique, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **Types de sources :** Sites de documentation, dépôts GitHub, PDF, Word (.docx), EPUB, Vidéo, Bases de code locales, Notebooks Jupyter, HTML local, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), Flux RSS/Atom, Pages de manuel, Wikis Confluence, Pages Notion, Exports chat Slack/Discord Consultez la [matrice complète des fonctionnalités](docs/FEATURE_MATRIX.md) pour le support détaillé par plateforme et fonctionnalité. @@ -944,7 +944,7 @@ Dans Claude Code, demandez simplement : ## 🤖 Installation dans les agents IA -Skill Seekers peut installer automatiquement des compétences dans plus de 10 agents de codage IA. +Skill Seekers peut installer automatiquement des compétences dans 18 agents de codage IA. ```bash # Installer dans un agent spécifique @@ -968,6 +968,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | Global | | **OpenCode** | `~/.opencode/skills/` | Global | | **Windsurf** | `~/.windsurf/skills/` | Global | +| **Roo Code** | `.roo/skills/` | Projet | +| **Cline** | `.cline/skills/` | Projet | +| **Aider** | `~/.aider/skills/` | Global | +| **Bolt** | `.bolt/skills/` | Projet | +| **Kilo Code** | `.kilo/skills/` | Projet | +| **Continue** | `~/.continue/skills/` | Global | +| **Kimi Code** | `~/.kimi/skills/` | Global | --- diff --git a/README.hi.md b/README.hi.md index 375a163..e924b23 100644 --- a/README.hi.md +++ b/README.hi.md @@ -254,7 +254,7 @@ Skill Seekers **AI सिस्टम के लिए डेटा लेयर - ✅ **पश्चगामी संगत** - पुराने एकल-स्रोत कॉन्फ़िग अभी भी काम करते हैं ### 🤖 बहु-LLM प्लेटफ़ॉर्म समर्थन -- ✅ **4 LLM प्लेटफ़ॉर्म** - Claude AI, Google Gemini, OpenAI ChatGPT, जेनेरिक Markdown +- ✅ **12 LLM प्लेटफ़ॉर्म** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, जेनेरिक Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - ✅ **सार्वभौमिक स्क्रैपिंग** - समान दस्तावेज़ सभी प्लेटफ़ॉर्म के लिए काम करते हैं - ✅ **प्लेटफ़ॉर्म-विशिष्ट पैकेजिंग** - प्रत्येक LLM के लिए अनुकूलित प्रारूप - ✅ **एक-कमांड निर्यात** - `--target` फ़्लैग प्लेटफ़ॉर्म चुनता है @@ -699,9 +699,9 @@ skill-seekers install --config react --dry-run ## 📊 फ़ीचर मैट्रिक्स -Skill Seekers **4 LLM प्लेटफ़ॉर्म**, **17 स्रोत प्रकार** और सभी लक्ष्यों पर पूर्ण फ़ीचर समानता का समर्थन करता है। +Skill Seekers **12 LLM प्लेटफ़ॉर्म**, **17 स्रोत प्रकार** और सभी लक्ष्यों पर पूर्ण फ़ीचर समानता का समर्थन करता है। -**प्लेटफ़ॉर्म:** Claude AI, Google Gemini, OpenAI ChatGPT, जेनेरिक Markdown +**प्लेटफ़ॉर्म:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, जेनेरिक Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **स्रोत प्रकार:** डॉक्यूमेंटेशन वेबसाइट, GitHub रिपो, PDF, Word (.docx), EPUB, वीडियो, स्थानीय कोडबेस, Jupyter Notebook, स्थानीय HTML, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), RSS/Atom फ़ीड, Man पेज, Confluence विकी, Notion पेज, Slack/Discord चैट एक्सपोर्ट विस्तृत प्लेटफ़ॉर्म और फ़ीचर समर्थन के लिए [पूर्ण फ़ीचर मैट्रिक्स](docs/FEATURE_MATRIX.md) देखें। @@ -939,7 +939,7 @@ Claude Code में, बस पूछें: ## 🤖 AI एजेंट में इंस्टॉल करना -Skill Seekers स्वचालित रूप से 10+ AI कोडिंग एजेंट में कौशल इंस्टॉल कर सकता है। +Skill Seekers स्वचालित रूप से 18 AI कोडिंग एजेंट में कौशल इंस्टॉल कर सकता है। ```bash # विशिष्ट एजेंट में इंस्टॉल करें @@ -963,6 +963,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | वैश्विक | | **OpenCode** | `~/.opencode/skills/` | वैश्विक | | **Windsurf** | `~/.windsurf/skills/` | वैश्विक | +| **Roo Code** | `.roo/skills/` | प्रोजेक्ट | +| **Cline** | `.cline/skills/` | प्रोजेक्ट | +| **Aider** | `~/.aider/skills/` | वैश्विक | +| **Bolt** | `.bolt/skills/` | प्रोजेक्ट | +| **Kilo Code** | `.kilo/skills/` | प्रोजेक्ट | +| **Continue** | `~/.continue/skills/` | वैश्विक | +| **Kimi Code** | `~/.kimi/skills/` | वैश्विक | --- diff --git a/README.ja.md b/README.ja.md index ab76ae8..ea9f6c8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -192,7 +192,7 @@ Skill Seekers は以下のステップで数日の手動前処理作業を代替 - ✅ **後方互換性** - レガシーの単一ソース設定は引き続き動作 ### 🤖 マルチ LLM プラットフォームサポート -- ✅ **4 つの LLM プラットフォーム** - Claude AI、Google Gemini、OpenAI ChatGPT、汎用 Markdown +- ✅ **12 の LLM プラットフォーム** - Claude AI、Google Gemini、OpenAI ChatGPT、MiniMax AI、汎用 Markdown、OpenCode、Kimi、DeepSeek、Qwen、OpenRouter、Together AI、Fireworks AI - ✅ **汎用スクレイピング** - 同じドキュメントがすべてのプラットフォームで使用可能 - ✅ **プラットフォーム固有のパッケージング** - 各 LLM に最適化されたフォーマット - ✅ **ワンコマンドエクスポート** - `--target` フラグでプラットフォームを選択 @@ -576,9 +576,9 @@ skill-seekers install --config react --dry-run ## 📊 機能マトリックス -Skill Seekers は **4 つの LLM プラットフォーム**、**17 種類のソースタイプ**、**5 つのスキルモード**をサポートし、機能は完全に同等です。 +Skill Seekers は **12 の LLM プラットフォーム**、**17 種類のソースタイプ**、**5 つのスキルモード**をサポートし、機能は完全に同等です。 -**プラットフォーム:** Claude AI、Google Gemini、OpenAI ChatGPT、汎用 Markdown +**プラットフォーム:** Claude AI、Google Gemini、OpenAI ChatGPT、MiniMax AI、汎用 Markdown、OpenCode、Kimi、DeepSeek、Qwen、OpenRouter、Together AI、Fireworks AI **ソースタイプ:** ドキュメントサイト、GitHub リポジトリ、PDF、Word、EPUB、動画、ローカルコードベース、Jupyter Notebook、ローカル HTML、OpenAPI/Swagger 仕様、AsciiDoc ドキュメント、PowerPoint プレゼンテーション、RSS/Atom フィード、Man ページ、Confluence Wiki、Notion ページ、Slack/Discord チャットエクスポート **スキルモード:** ドキュメント、GitHub、PDF、統合マルチソース、ローカルリポジトリ @@ -815,7 +815,7 @@ Claude Code で直接聞くだけ: ## 🤖 AI エージェントへのインストール -Skill Seekers は 10 以上の AI コーディングエージェントにスキルを自動インストールできます。 +Skill Seekers は 18 の AI コーディングエージェントにスキルを自動インストールできます。 ```bash # 特定のエージェントにインストール @@ -839,6 +839,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | グローバル | | **OpenCode** | `~/.opencode/skills/` | グローバル | | **Windsurf** | `~/.windsurf/skills/` | グローバル | +| **Roo Code** | `.roo/skills/` | プロジェクト | +| **Cline** | `.cline/skills/` | プロジェクト | +| **Aider** | `~/.aider/skills/` | グローバル | +| **Bolt** | `.bolt/skills/` | プロジェクト | +| **Kilo Code** | `.kilo/skills/` | プロジェクト | +| **Continue** | `~/.continue/skills/` | グローバル | +| **Kimi Code** | `~/.kimi/skills/` | グローバル | --- diff --git a/README.ko.md b/README.ko.md index ff523dd..e86216a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -194,7 +194,7 @@ Skill Seekers는 수일간의 수동 전처리 작업을 대체합니다: - ✅ **하위 호환** - 레거시 단일 소스 설정 계속 작동 ### 🤖 다중 LLM 플랫폼 지원 -- ✅ **4개 LLM 플랫폼** - Claude AI, Google Gemini, OpenAI ChatGPT, 범용 Markdown +- ✅ **12개 LLM 플랫폼** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, 범용 Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - ✅ **범용 스크래핑** - 동일한 문서가 모든 플랫폼에 적용 - ✅ **플랫폼별 패키징** - 각 LLM에 최적화된 형식 - ✅ **원커맨드 내보내기** - `--target` 플래그로 플랫폼 선택 @@ -581,9 +581,9 @@ skill-seekers install --config react --dry-run ## 📊 기능 매트릭스 -Skill Seekers는 **4개 LLM 플랫폼**, **17가지 소스 유형**을 지원하며 모든 대상에서 완전한 기능 동등성을 제공합니다. +Skill Seekers는 **12개 LLM 플랫폼**, **17가지 소스 유형**을 지원하며 모든 대상에서 완전한 기능 동등성을 제공합니다. -**플랫폼:** Claude AI, Google Gemini, OpenAI ChatGPT, 범용 Markdown +**플랫폼:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, 범용 Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **소스 유형:** 문서 사이트, GitHub 저장소, PDF, Word (.docx), EPUB, 동영상, 로컬 코드베이스, Jupyter 노트북, 로컬 HTML, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), RSS/Atom 피드, Man 페이지, Confluence 위키, Notion 페이지, Slack/Discord 채팅 내보내기 전체 내용은 [전체 기능 매트릭스](docs/FEATURE_MATRIX.md)를 참조하세요. @@ -819,7 +819,7 @@ Claude Code에서 직접 요청: ## 🤖 AI 에이전트에 설치 -Skill Seekers는 10개 이상의 AI 코딩 에이전트에 스킬을 자동으로 설치할 수 있습니다. +Skill Seekers는 18개의 AI 코딩 에이전트에 스킬을 자동으로 설치할 수 있습니다. ```bash # 특정 에이전트에 설치 @@ -843,6 +843,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | 전역 | | **OpenCode** | `~/.opencode/skills/` | 전역 | | **Windsurf** | `~/.windsurf/skills/` | 전역 | +| **Roo Code** | `.roo/skills/` | 프로젝트 | +| **Cline** | `.cline/skills/` | 프로젝트 | +| **Aider** | `~/.aider/skills/` | 전역 | +| **Bolt** | `.bolt/skills/` | 프로젝트 | +| **Kilo Code** | `.kilo/skills/` | 프로젝트 | +| **Continue** | `~/.continue/skills/` | 전역 | +| **Kimi Code** | `~/.kimi/skills/` | 전역 | --- diff --git a/README.md b/README.md index d2640e1..036a25c 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ Instead of spending days on manual preprocessing, Skill Seekers: - ✅ **Backward Compatible** - Legacy single-source configs still work ### 🤖 Multi-LLM Platform Support -- ✅ **5 LLM Platforms** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generic Markdown +- ✅ **12 LLM Platforms** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generic Markdown, OpenCode, Kimi (Moonshot AI), DeepSeek AI, Qwen (Alibaba), OpenRouter, Together AI, Fireworks AI - ✅ **Universal Scraping** - Same documentation works for all platforms - ✅ **Platform-Specific Packaging** - Optimized formats for each LLM - ✅ **One-Command Export** - `--target` flag selects platform @@ -707,9 +707,9 @@ skill-seekers install --config react --dry-run ## 📊 Feature Matrix -Skill Seekers supports **5 LLM platforms**, **17 source types**, and full feature parity across all targets. +Skill Seekers supports **12 LLM platforms**, **17 source types**, and full feature parity across all targets. -**Platforms:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generic Markdown +**Platforms:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Generic Markdown, OpenCode, Kimi (Moonshot AI), DeepSeek AI, Qwen (Alibaba), OpenRouter, Together AI, Fireworks AI **Source Types:** Documentation websites, GitHub repos, PDFs, Word (.docx), EPUB, Video, Local codebases, Jupyter Notebooks, Local HTML, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), RSS/Atom feeds, Man pages, Confluence wikis, Notion pages, Slack/Discord chat exports See [Complete Feature Matrix](docs/FEATURE_MATRIX.md) for detailed platform and feature support. @@ -947,7 +947,7 @@ In Claude Code, just ask: ## 🤖 Installing to AI Agents -Skill Seekers can automatically install skills to 10+ AI coding agents. +Skill Seekers can automatically install skills to 18 AI coding agents. ```bash # Install to specific agent @@ -971,6 +971,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | Global | | **OpenCode** | `~/.opencode/skills/` | Global | | **Windsurf** | `~/.windsurf/skills/` | Global | +| **Roo Code** | `.roo/skills/` | Project | +| **Cline** | `.cline/skills/` | Project | +| **Aider** | `~/.aider/skills/` | Global | +| **Bolt** | `.bolt/skills/` | Project | +| **Kilo Code** | `.kilo/skills/` | Project | +| **Continue** | `~/.continue/skills/` | Global | +| **Kimi Code** | `~/.kimi/skills/` | Global | --- diff --git a/README.pt-BR.md b/README.pt-BR.md index b9ed56d..692137c 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -239,7 +239,7 @@ O Skill Seekers substitui dias de pré-processamento manual com os seguintes pas - ✅ **Retrocompatível** - Configurações legadas de fonte única continuam funcionando ### 🤖 Suporte a Múltiplas Plataformas LLM -- ✅ **4 Plataformas LLM** - Claude AI, Google Gemini, OpenAI ChatGPT, Markdown Genérico +- ✅ **12 Plataformas LLM** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Markdown Genérico, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - ✅ **Coleta Universal** - A mesma documentação funciona para todas as plataformas - ✅ **Empacotamento Específico por Plataforma** - Formatos otimizados para cada LLM - ✅ **Exportação com Um Comando** - Flag `--target` seleciona a plataforma @@ -689,9 +689,9 @@ skill-seekers install --config react --dry-run ## 📊 Matriz de Funcionalidades -O Skill Seekers suporta **4 plataformas LLM**, **17 tipos de fontes** e paridade completa de funcionalidades em todos os destinos. +O Skill Seekers suporta **12 plataformas LLM**, **17 tipos de fontes** e paridade completa de funcionalidades em todos os destinos. -**Plataformas:** Claude AI, Google Gemini, OpenAI ChatGPT, Markdown Genérico +**Plataformas:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Markdown Genérico, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **Tipos de Fontes:** Sites de documentação, repositórios GitHub, PDFs, Word (.docx), EPUB, Vídeo, Codebases locais, Jupyter Notebooks, HTML local, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), feeds RSS/Atom, Man pages, wikis Confluence, páginas Notion, exportações de chat Slack/Discord Consulte a [Matriz Completa de Funcionalidades](docs/FEATURE_MATRIX.md) para suporte detalhado por plataforma e funcionalidade. @@ -929,7 +929,7 @@ No Claude Code, basta pedir: ## 🤖 Instalando em Agentes de IA -O Skill Seekers pode instalar automaticamente skills em mais de 10 agentes de programação com IA. +O Skill Seekers pode instalar automaticamente skills em 18 agentes de programação com IA. ```bash # Instalar em agente específico @@ -953,6 +953,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | Global | | **OpenCode** | `~/.opencode/skills/` | Global | | **Windsurf** | `~/.windsurf/skills/` | Global | +| **Roo Code** | `.roo/skills/` | Projeto | +| **Cline** | `.cline/skills/` | Projeto | +| **Aider** | `~/.aider/skills/` | Global | +| **Bolt** | `.bolt/skills/` | Projeto | +| **Kilo Code** | `.kilo/skills/` | Projeto | +| **Continue** | `~/.continue/skills/` | Global | +| **Kimi Code** | `~/.kimi/skills/` | Global | --- diff --git a/README.ru.md b/README.ru.md index 32ade4d..0608e5e 100644 --- a/README.ru.md +++ b/README.ru.md @@ -192,7 +192,7 @@ Skill Seekers заменяет дни ручной предобработки с - ✅ **Обратная совместимость** — устаревшие одноисточниковые конфигурации продолжают работать ### 🤖 Поддержка нескольких LLM-платформ -- ✅ **4 LLM-платформы** — Claude AI, Google Gemini, OpenAI ChatGPT, универсальный Markdown +- ✅ **12 LLM-платформ** — Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, универсальный Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - ✅ **Универсальное сканирование** — одна и та же документация для всех платформ - ✅ **Платформоспецифичная упаковка** — оптимизированные форматы для каждой LLM - ✅ **Экспорт одной командой** — флаг `--target` для выбора платформы @@ -579,9 +579,9 @@ skill-seekers install --config react --dry-run ## 📊 Матрица функций -Skill Seekers поддерживает **4 LLM-платформы**, **17 типов источников** и полный паритет функций по всем целевым платформам. +Skill Seekers поддерживает **12 LLM-платформ**, **17 типов источников** и полный паритет функций по всем целевым платформам. -**Платформы:** Claude AI, Google Gemini, OpenAI ChatGPT, универсальный Markdown +**Платформы:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, универсальный Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **Типы источников:** Документация сайтов, репозитории GitHub, PDF, Word (.docx), EPUB, видео, локальные кодовые базы, Jupyter-ноутбуки, локальный HTML, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), RSS/Atom-ленты, man-страницы, вики Confluence, страницы Notion, экспорты чатов Slack/Discord Подробности см. в [Полной матрице функций](docs/FEATURE_MATRIX.md). @@ -817,7 +817,7 @@ skill-seekers package output/react/ ## 🤖 Установка в ИИ-агенты -Skill Seekers может автоматически устанавливать навыки в 10+ ИИ-агентов для программирования. +Skill Seekers может автоматически устанавливать навыки в 18 ИИ-агентов для программирования. ```bash # Установка в конкретный агент @@ -841,6 +841,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | Глобальный | | **OpenCode** | `~/.opencode/skills/` | Глобальный | | **Windsurf** | `~/.windsurf/skills/` | Глобальный | +| **Roo Code** | `.roo/skills/` | Проектный | +| **Cline** | `.cline/skills/` | Проектный | +| **Aider** | `~/.aider/skills/` | Глобальный | +| **Bolt** | `.bolt/skills/` | Проектный | +| **Kilo Code** | `.kilo/skills/` | Проектный | +| **Continue** | `~/.continue/skills/` | Глобальный | +| **Kimi Code** | `~/.kimi/skills/` | Глобальный | --- diff --git a/README.tr.md b/README.tr.md index 8344f6a..65e8582 100644 --- a/README.tr.md +++ b/README.tr.md @@ -254,7 +254,7 @@ Skill Seekers, günlerce süren manuel ön işleme çalışması yerine şunlar - ✅ **Geriye Dönük Uyumluluk** - Eski tek kaynaklı yapılandırmalar çalışmaya devam eder ### 🤖 Çoklu LLM Platform Desteği -- ✅ **4 LLM Platformu** - Claude AI, Google Gemini, OpenAI ChatGPT, Genel Markdown +- ✅ **12 LLM Platformu** - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Genel Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI - ✅ **Evrensel Tarama** - Aynı dokümantasyon tüm platformlar için çalışır - ✅ **Platforma Özel Paketleme** - Her LLM için optimize edilmiş formatlar - ✅ **Tek Komutla Dışa Aktarma** - `--target` bayrağı ile platform seçimi @@ -703,9 +703,9 @@ skill-seekers install --config react --dry-run ## 📊 Özellik Matrisi -Skill Seekers **4 LLM platformu**, **17 kaynak türü** ve tüm hedeflerde tam özellik eşitliğini destekler. +Skill Seekers **12 LLM platformu**, **17 kaynak türü** ve tüm hedeflerde tam özellik eşitliğini destekler. -**Platformlar:** Claude AI, Google Gemini, OpenAI ChatGPT, Genel Markdown +**Platformlar:** Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, Genel Markdown, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI **Kaynak Türleri:** Dokümantasyon siteleri, GitHub depoları, PDF'ler, Word (.docx), EPUB, Video, Yerel kod tabanları, Jupyter Not Defterleri, Yerel HTML, OpenAPI/Swagger, AsciiDoc, PowerPoint (.pptx), RSS/Atom beslemeleri, Man sayfaları, Confluence vikileri, Notion sayfaları, Slack/Discord sohbet dışa aktarımları Ayrıntılı platform ve özellik desteği için [Tam Özellik Matrisi](docs/FEATURE_MATRIX.md) bölümüne bakın. @@ -943,7 +943,7 @@ Claude Code'da şunu sorun: ## 🤖 AI Ajanlara Yükleme -Skill Seekers, yetenekleri 10+ AI kodlama ajanına otomatik olarak yükleyebilir. +Skill Seekers, yetenekleri 18 AI kodlama ajanına otomatik olarak yükleyebilir. ```bash # Belirli bir ajana yükle @@ -967,6 +967,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | Global | | **OpenCode** | `~/.opencode/skills/` | Global | | **Windsurf** | `~/.windsurf/skills/` | Global | +| **Roo Code** | `.roo/skills/` | Proje | +| **Cline** | `.cline/skills/` | Proje | +| **Aider** | `~/.aider/skills/` | Global | +| **Bolt** | `.bolt/skills/` | Proje | +| **Kilo Code** | `.kilo/skills/` | Proje | +| **Continue** | `~/.continue/skills/` | Global | +| **Kimi Code** | `~/.kimi/skills/` | Global | --- diff --git a/README.zh-CN.md b/README.zh-CN.md index fe3fe95..79437e2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -192,7 +192,7 @@ Skill Seekers 通过以下步骤代替数天的手动预处理工作: - ✅ **向后兼容** - 遗留单源配置继续有效 ### 🤖 多 LLM 平台支持 -- ✅ **4 个 LLM 平台** - Claude AI、Google Gemini、OpenAI ChatGPT、通用 Markdown +- ✅ **12 个 LLM 平台** - Claude AI、Google Gemini、OpenAI ChatGPT、MiniMax AI、通用 Markdown、OpenCode、Kimi、DeepSeek、Qwen、OpenRouter、Together AI、Fireworks AI - ✅ **通用抓取** - 相同文档适用于所有平台 - ✅ **平台专用打包** - 针对每个 LLM 的优化格式 - ✅ **一键导出** - `--target` 标志选择平台 @@ -576,9 +576,9 @@ skill-seekers install --config react --dry-run ## 📊 功能矩阵 -Skill Seekers 支持 **4 个 LLM 平台**、**17 种来源类型**和 **5 种技能模式**,功能完全对等。 +Skill Seekers 支持 **12 个 LLM 平台**、**17 种来源类型**和 **5 种技能模式**,功能完全对等。 -**平台:** Claude AI、Google Gemini、OpenAI ChatGPT、通用 Markdown +**平台:** Claude AI、Google Gemini、OpenAI ChatGPT、MiniMax AI、通用 Markdown、OpenCode、Kimi、DeepSeek、Qwen、OpenRouter、Together AI、Fireworks AI **来源类型:** 文档网站、GitHub 仓库、PDF、Word、EPUB、视频、本地代码库、Jupyter 笔记本、本地 HTML、OpenAPI/Swagger 规范、AsciiDoc 文档、PowerPoint 演示文稿、RSS/Atom 订阅源、Man 手册页、Confluence 维基、Notion 页面、Slack/Discord 聊天记录 **技能模式:** 文档、GitHub、PDF、统一多源、本地仓库 @@ -815,7 +815,7 @@ skill-seekers package output/react/ ## 🤖 安装到 AI 代理 -Skill Seekers 可自动将技能安装到 10+ 个 AI 编程代理。 +Skill Seekers 可自动将技能安装到 18 个 AI 编程代理。 ```bash # 安装到特定代理 @@ -839,6 +839,13 @@ skill-seekers install-agent output/react/ --agent cursor --dry-run | **Goose** | `~/.config/goose/skills/` | 全局 | | **OpenCode** | `~/.opencode/skills/` | 全局 | | **Windsurf** | `~/.windsurf/skills/` | 全局 | +| **Roo Code** | `.roo/skills/` | 项目 | +| **Cline** | `.cline/skills/` | 项目 | +| **Aider** | `~/.aider/skills/` | 全局 | +| **Bolt** | `.bolt/skills/` | 项目 | +| **Kilo Code** | `.kilo/skills/` | 项目 | +| **Continue** | `~/.continue/skills/` | 全局 | +| **Kimi Code** | `~/.kimi/skills/` | 全局 | --- diff --git a/docs/FAQ.md b/docs/FAQ.md index d5a47ed..8d3298c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -9,7 +9,7 @@ ### What is Skill Seekers? -Skill Seekers is a Python tool that converts 17 source types — documentation websites, GitHub repos, PDFs, videos, Word docs, EPUB books, Jupyter notebooks, local HTML files, OpenAPI specs, AsciiDoc, PowerPoint, RSS/Atom feeds, man pages, Confluence wikis, Notion pages, Slack/Discord exports, and local codebases — into AI-ready formats for 16+ platforms: LLM platforms (Claude, Gemini, OpenAI), RAG frameworks (LangChain, LlamaIndex, Haystack), vector databases (ChromaDB, FAISS, Weaviate, Qdrant, Pinecone), and AI coding assistants (Cursor, Windsurf, Cline, Continue.dev). +Skill Seekers is a Python tool that converts 17 source types — documentation websites, GitHub repos, PDFs, videos, Word docs, EPUB books, Jupyter notebooks, local HTML files, OpenAPI specs, AsciiDoc, PowerPoint, RSS/Atom feeds, man pages, Confluence wikis, Notion pages, Slack/Discord exports, and local codebases — into AI-ready formats for 30+ platforms: LLM platforms (Claude, Gemini, OpenAI, MiniMax, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, Markdown), RAG frameworks (LangChain, LlamaIndex, Haystack), vector databases (ChromaDB, FAISS, Weaviate, Qdrant, Pinecone), and AI coding assistants (Cursor, Windsurf, Cline, Continue.dev, Roo, Aider, Bolt, Kilo, Kimi Code). **Use Cases:** - Create custom documentation skills for your favorite frameworks @@ -23,31 +23,44 @@ Skill Seekers is a Python tool that converts 17 source types — documentation w ### Which platforms are supported? -**Supported Platforms (16+):** +**Supported Platforms (30+):** -*LLM Platforms:* +*LLM Platforms (12):* 1. **Claude AI** - ZIP format with YAML frontmatter 2. **Google Gemini** - tar.gz format for Grounded Generation 3. **OpenAI ChatGPT** - ZIP format for Vector Stores -4. **Generic Markdown** - ZIP format with markdown files +4. **MiniMax** - ZIP format +5. **OpenCode** - ZIP format +6. **Kimi** - ZIP format +7. **DeepSeek** - ZIP format +8. **Qwen** - ZIP format +9. **OpenRouter** - ZIP format for multi-model routing +10. **Together AI** - ZIP format for open-source models +11. **Fireworks AI** - ZIP format for fast inference +12. **Generic Markdown** - ZIP format with markdown files *RAG Frameworks:* -5. **LangChain** - Document objects for QA chains and agents -6. **LlamaIndex** - TextNodes for query engines -7. **Haystack** - Document objects for enterprise RAG +13. **LangChain** - Document objects for QA chains and agents +14. **LlamaIndex** - TextNodes for query engines +15. **Haystack** - Document objects for enterprise RAG *Vector Databases:* -8. **ChromaDB** - Direct collection upload -9. **FAISS** - Index files for local similarity search -10. **Weaviate** - Vector objects with schema creation -11. **Qdrant** - Points with payload indexing -12. **Pinecone** - Ready-to-upsert format +16. **ChromaDB** - Direct collection upload +17. **FAISS** - Index files for local similarity search +18. **Weaviate** - Vector objects with schema creation +19. **Qdrant** - Points with payload indexing +20. **Pinecone** - Ready-to-upsert format -*AI Coding Assistants:* -13. **Cursor** - .cursorrules persistent context -14. **Windsurf** - .windsurfrules AI coding rules -15. **Cline** - .clinerules + MCP integration -16. **Continue.dev** - HTTP context server (all IDEs) +*AI Coding Assistants (9):* +21. **Cursor** - .cursorrules persistent context +22. **Windsurf** - .windsurfrules AI coding rules +23. **Cline** - .clinerules + MCP integration +24. **Continue.dev** - HTTP context server (all IDEs) +25. **Roo** - .roorules AI coding rules +26. **Aider** - Terminal AI coding assistant +27. **Bolt** - Web IDE AI context +28. **Kilo** - IDE AI context +29. **Kimi Code** - IDE AI context Each platform has a dedicated adaptor for optimal formatting and upload. @@ -404,6 +417,8 @@ skill-seekers install react --target claude --upload - Claude AI: Best for Claude Code integration - Google Gemini: Best for Grounded Generation in Gemini - OpenAI ChatGPT: Best for ChatGPT Custom GPTs +- MiniMax/Kimi/DeepSeek/Qwen: Best for Chinese LLM ecosystem +- OpenRouter/Together/Fireworks: Best for multi-model routing or open-source model access - Markdown: Generic export for other tools ### Can I use multiple platforms at once? @@ -412,7 +427,7 @@ Yes! Package and upload to all platforms: ```bash # Package for all platforms -for platform in claude gemini openai markdown; do +for platform in claude gemini openai minimax kimi deepseek qwen openrouter together fireworks markdown; do skill-seekers package output/react/ --target $platform done diff --git a/docs/README.md b/docs/README.md index a656cbc..9397504 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ ## Welcome! -This is the official documentation for **Skill Seekers** - the universal tool for converting **17 source types** (documentation sites, GitHub repos, PDFs, videos, Word docs, EPUB books, Jupyter notebooks, local HTML, OpenAPI specs, AsciiDoc, PowerPoint, RSS/Atom feeds, man pages, Confluence, Notion, Slack/Discord, and local codebases) into AI-ready skills for 16+ platforms. +This is the official documentation for **Skill Seekers** - the universal tool for converting **17 source types** (documentation sites, GitHub repos, PDFs, videos, Word docs, EPUB books, Jupyter notebooks, local HTML, OpenAPI specs, AsciiDoc, PowerPoint, RSS/Atom feeds, man pages, Confluence, Notion, Slack/Discord, and local codebases) into AI-ready skills for 30+ platforms. --- @@ -172,7 +172,7 @@ For LangChain, LlamaIndex, vector DBs: ### I Want AI Coding Assistance -For Cursor, Windsurf, Cline: +For Cursor, Windsurf, Cline, Roo, Aider, Bolt, Kilo, Continue, Kimi Code: 1. [Your First Skill](getting-started/03-your-first-skill.md) 2. [Local Codebase Analysis](user-guide/02-scraping.md#local-codebase-analysis) diff --git a/docs/getting-started/04-next-steps.md b/docs/getting-started/04-next-steps.md index f5b87d3..438aa2f 100644 --- a/docs/getting-started/04-next-steps.md +++ b/docs/getting-started/04-next-steps.md @@ -192,7 +192,7 @@ skill-seekers package output/my-skill/ --target langchain ### For AI Coding Assistant Users -Using Cursor, Windsurf, or Cline? +Using Cursor, Windsurf, Cline, Roo, Aider, Bolt, Kilo, Continue, or Kimi Code? **Learn:** - Local codebase analysis diff --git a/docs/reference/CLI_REFERENCE.md b/docs/reference/CLI_REFERENCE.md index f8abe76..94cfec0 100644 --- a/docs/reference/CLI_REFERENCE.md +++ b/docs/reference/CLI_REFERENCE.md @@ -692,7 +692,7 @@ skill-seekers install-agent SKILL_DIRECTORY --agent AGENT [options] | Name | Required | Description | |------|----------|-------------| | `SKILL_DIRECTORY` | Yes | Path to skill directory | -| `--agent AGENT` | Yes | Target agent: cursor, windsurf, cline, continue | +| `--agent AGENT` | Yes | Target agent: cursor, windsurf, cline, continue, roo, aider, bolt, kilo, kimi-code | **Flags:** @@ -923,6 +923,14 @@ skill-seekers package SKILL_DIRECTORY [options] | Claude AI | ZIP + YAML | `--target claude` | | Google Gemini | tar.gz | `--target gemini` | | OpenAI | ZIP + Vector | `--target openai` | +| MiniMax | ZIP | `--target minimax` | +| OpenCode | ZIP | `--target opencode` | +| Kimi | ZIP | `--target kimi` | +| DeepSeek | ZIP | `--target deepseek` | +| Qwen | ZIP | `--target qwen` | +| OpenRouter | ZIP | `--target openrouter` | +| Together AI | ZIP | `--target together` | +| Fireworks AI | ZIP | `--target fireworks` | | LangChain | Documents | `--target langchain` | | LlamaIndex | TextNodes | `--target llama-index` | | Haystack | Documents | `--target haystack` | diff --git a/docs/reference/FEATURE_MATRIX.md b/docs/reference/FEATURE_MATRIX.md index 7036f34..8d35ff7 100644 --- a/docs/reference/FEATURE_MATRIX.md +++ b/docs/reference/FEATURE_MATRIX.md @@ -9,30 +9,38 @@ Complete feature support across all platforms and skill modes. | **Claude AI** | ZIP | ✅ Anthropic API | ✅ Sonnet 4 | ANTHROPIC_API_KEY | | **Google Gemini** | tar.gz | ✅ Files API | ✅ Gemini 2.0 | GOOGLE_API_KEY | | **OpenAI ChatGPT** | ZIP | ✅ Assistants API | ✅ GPT-4o | OPENAI_API_KEY | +| **MiniMax** | ZIP | ❌ Manual | ❌ None | None | +| **OpenCode** | ZIP | ❌ Manual | ❌ None | None | +| **Kimi** | ZIP | ❌ Manual | ❌ None | None | +| **DeepSeek** | ZIP | ❌ Manual | ❌ None | None | +| **Qwen** | ZIP | ❌ Manual | ❌ None | None | +| **OpenRouter** | ZIP | ❌ Manual | ❌ None | None | +| **Together AI** | ZIP | ❌ Manual | ❌ None | None | +| **Fireworks AI** | ZIP | ❌ Manual | ❌ None | None | | **Generic Markdown** | ZIP | ❌ Manual | ❌ None | None | ## Skill Mode Support | Mode | Description | Platforms | CLI Command | `create` Detection | |------|-------------|-----------|-------------|-------------------| -| **Documentation** | Scrape HTML docs | All 4 | `scrape` | `https://...` URLs | -| **GitHub** | Analyze repositories | All 4 | `github` | `owner/repo` or github.com URLs | -| **PDF** | Extract from PDFs | All 4 | `pdf` | `.pdf` extension | -| **Word** | Extract from DOCX | All 4 | `word` | `.docx` extension | -| **EPUB** | Extract from EPUB | All 4 | `epub` | `.epub` extension | -| **Video** | Video transcription | All 4 | `video` | YouTube/Vimeo URLs, video extensions | -| **Local Repo** | Local codebase analysis | All 4 | `analyze` | Directory paths | -| **Jupyter** | Extract from notebooks | All 4 | `jupyter` | `.ipynb` extension | -| **HTML** | Extract local HTML files | All 4 | `html` | `.html`/`.htm` extension | -| **OpenAPI** | Extract API specs | All 4 | `openapi` | `.yaml`/`.yml` with OpenAPI content | -| **AsciiDoc** | Extract AsciiDoc files | All 4 | `asciidoc` | `.adoc`/`.asciidoc` extension | -| **PowerPoint** | Extract from PPTX | All 4 | `pptx` | `.pptx` extension | -| **RSS/Atom** | Extract from feeds | All 4 | `rss` | `.rss`/`.atom` extension | -| **Man Pages** | Extract man pages | All 4 | `manpage` | `.1`-`.8`/`.man` extension | -| **Confluence** | Extract from Confluence | All 4 | `confluence` | API or export directory | -| **Notion** | Extract from Notion | All 4 | `notion` | API or export directory | -| **Chat** | Extract Slack/Discord | All 4 | `chat` | Export directory or API | -| **Unified** | Multi-source combination | All 4 | `unified` | N/A (config-driven) | +| **Documentation** | Scrape HTML docs | All 12 | `scrape` | `https://...` URLs | +| **GitHub** | Analyze repositories | All 12 | `github` | `owner/repo` or github.com URLs | +| **PDF** | Extract from PDFs | All 12 | `pdf` | `.pdf` extension | +| **Word** | Extract from DOCX | All 12 | `word` | `.docx` extension | +| **EPUB** | Extract from EPUB | All 12 | `epub` | `.epub` extension | +| **Video** | Video transcription | All 12 | `video` | YouTube/Vimeo URLs, video extensions | +| **Local Repo** | Local codebase analysis | All 12 | `analyze` | Directory paths | +| **Jupyter** | Extract from notebooks | All 12 | `jupyter` | `.ipynb` extension | +| **HTML** | Extract local HTML files | All 12 | `html` | `.html`/`.htm` extension | +| **OpenAPI** | Extract API specs | All 12 | `openapi` | `.yaml`/`.yml` with OpenAPI content | +| **AsciiDoc** | Extract AsciiDoc files | All 12 | `asciidoc` | `.adoc`/`.asciidoc` extension | +| **PowerPoint** | Extract from PPTX | All 12 | `pptx` | `.pptx` extension | +| **RSS/Atom** | Extract from feeds | All 12 | `rss` | `.rss`/`.atom` extension | +| **Man Pages** | Extract man pages | All 12 | `manpage` | `.1`-`.8`/`.man` extension | +| **Confluence** | Extract from Confluence | All 12 | `confluence` | API or export directory | +| **Notion** | Extract from Notion | All 12 | `notion` | API or export directory | +| **Chat** | Extract Slack/Discord | All 12 | `chat` | Export directory or API | +| **Unified** | Multi-source combination | All 12 | `unified` | N/A (config-driven) | ## CLI Command Support @@ -127,21 +135,21 @@ Complete feature support across all platforms and skill modes. ``` Config → Scrape → Build → [Enhance] → Package --target X → [Upload --target X] ``` -**Platforms:** All 4 +**Platforms:** All 12 **Modes:** Docs, GitHub, PDF ### Unified Multi-Source Workflow ``` Config → Scrape All → Detect Conflicts → Merge → Build → [Enhance] → Package --target X → [Upload --target X] ``` -**Platforms:** All 4 +**Platforms:** All 12 **Modes:** Unified only ### Complete Installation Workflow ``` install --target X → Fetch → Scrape → Enhance → Package → Upload ``` -**Platforms:** All 4 +**Platforms:** All 12 **Modes:** All (via config type detection) ## API Key Requirements @@ -342,6 +350,8 @@ A: - **Claude:** Best default choice, excellent MCP integration - **Gemini:** Choose if you need long context (1M tokens) or grounding - **OpenAI:** Choose if you need vector search and semantic retrieval +- **MiniMax/Kimi/DeepSeek/Qwen:** Choose for Chinese LLM ecosystem compatibility +- **OpenRouter/Together/Fireworks:** Choose for multi-model routing or open-source model access - **Markdown:** Choose for universal compatibility or offline use **Q: Can I enhance a skill for different platforms?** @@ -351,7 +361,7 @@ A: Yes! Enhancement adds platform-specific formatting: - OpenAI: Plain text assistant instructions **Q: Do all skill modes work with all platforms?** -A: Yes! All 17 source types work with all 4 platforms (Claude, Gemini, OpenAI, Markdown). +A: Yes! All 17 source types work with all 12 LLM platforms (Claude, Gemini, OpenAI, MiniMax, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, Markdown). ## See Also diff --git a/docs/user-guide/04-packaging.md b/docs/user-guide/04-packaging.md index ab067df..5642c20 100644 --- a/docs/user-guide/04-packaging.md +++ b/docs/user-guide/04-packaging.md @@ -26,6 +26,14 @@ output/my-skill/ ──▶ Packager ──▶ output/my-skill-{platform}.{format | **Claude AI** | ZIP + YAML | `.zip` | Claude Code, Claude API | | **Google Gemini** | tar.gz | `.tar.gz` | Gemini skills | | **OpenAI ChatGPT** | ZIP + Vector | `.zip` | Custom GPTs | +| **MiniMax** | ZIP | `.zip` | MiniMax platform | +| **OpenCode** | ZIP | `.zip` | OpenCode platform | +| **Kimi** | ZIP | `.zip` | Kimi platform | +| **DeepSeek** | ZIP | `.zip` | DeepSeek platform | +| **Qwen** | ZIP | `.zip` | Qwen platform | +| **OpenRouter** | ZIP | `.zip` | Multi-model routing | +| **Together AI** | ZIP | `.zip` | Open-source models | +| **Fireworks AI** | ZIP | `.zip` | Fast inference | | **LangChain** | Documents | directory | RAG pipelines | | **LlamaIndex** | TextNodes | directory | Query engines | | **Haystack** | Documents | directory | Enterprise RAG | @@ -38,6 +46,12 @@ output/my-skill/ ──▶ Packager ──▶ output/my-skill-{platform}.{format | **Cursor** | .cursorrules | file | IDE AI context | | **Windsurf** | .windsurfrules | file | IDE AI context | | **Cline** | .clinerules | file | VS Code AI | +| **Roo** | .roorules | file | VS Code AI | +| **Aider** | .aider | file | Terminal AI coding | +| **Bolt** | bolt context | file | Web IDE AI | +| **Kilo** | kilo context | file | IDE AI context | +| **Continue** | .continue | file | IDE AI context | +| **Kimi Code** | kimi context | file | IDE AI context | --- diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 4fd123c..121b6e6 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -1,6 +1,6 @@ # Skill Seekers Documentation -> **Complete documentation for Skill Seekers v3.2.0** +> **Complete documentation for Skill Seekers v3.4.0** --- @@ -38,7 +38,7 @@ Look up specific information: - [CLI Reference](reference/CLI_REFERENCE.md) - All 30+ commands - [MCP Reference](reference/MCP_REFERENCE.md) - 27 MCP tools -- [Feature Matrix](reference/FEATURE_MATRIX.md) - 17 source types × 4 platforms +- [Feature Matrix](reference/FEATURE_MATRIX.md) - 17 source types × 12 platforms - [Config Format](reference/CONFIG_FORMAT.md) - JSON specification - [Environment Variables](reference/ENVIRONMENT_VARIABLES.md) - All env vars @@ -176,8 +176,8 @@ For Cursor, Windsurf, Cline: ## Version Information -- **Current Version:** 3.2.0 -- **Last Updated:** 2026-03-15 +- **Current Version:** 3.4.0 +- **Last Updated:** 2026-03-21 - **Python Required:** 3.10+ --- diff --git a/docs/zh-CN/reference/CLI_REFERENCE.md b/docs/zh-CN/reference/CLI_REFERENCE.md index 9f291e1..ad1bf25 100644 --- a/docs/zh-CN/reference/CLI_REFERENCE.md +++ b/docs/zh-CN/reference/CLI_REFERENCE.md @@ -1,7 +1,7 @@ # CLI Reference - Skill Seekers -> **Version:** 3.2.0 -> **Last Updated:** 2026-03-15 +> **Version:** 3.4.0 +> **Last Updated:** 2026-03-21 > **Complete reference for all 30+ CLI commands** --- @@ -520,7 +520,7 @@ skill-seekers install --config react --dry-run ### install-agent -Install skill to AI agent directories (Cursor, Windsurf, Cline). +Install skill to AI agent directories (Cursor, Windsurf, Cline, Roo, Aider, Bolt, Kilo, Continue, Kimi Code). **Purpose:** Direct installation to IDE AI assistant context directories. @@ -534,7 +534,7 @@ skill-seekers install-agent SKILL_DIRECTORY --agent AGENT [options] | Name | Required | Description | |------|----------|-------------| | `SKILL_DIRECTORY` | Yes | Path to skill directory | -| `--agent AGENT` | Yes | Target agent: cursor, windsurf, cline, continue | +| `--agent AGENT` | Yes | Target agent: cursor, windsurf, cline, continue, roo, aider, bolt, kilo, kimi-code | **Flags:** @@ -630,6 +630,13 @@ skill-seekers package SKILL_DIRECTORY [options] | Claude AI | ZIP + YAML | `--target claude` | | Google Gemini | tar.gz | `--target gemini` | | OpenAI | ZIP + Vector | `--target openai` | +| OpenCode | Directory | `--target opencode` | +| Kimi | ZIP | `--target kimi` | +| DeepSeek | ZIP | `--target deepseek` | +| Qwen | ZIP | `--target qwen` | +| OpenRouter | ZIP | `--target openrouter` | +| Together AI | ZIP | `--target together` | +| Fireworks AI | ZIP | `--target fireworks` | | LangChain | Documents | `--target langchain` | | LlamaIndex | TextNodes | `--target llama-index` | | Haystack | Documents | `--target haystack` | diff --git a/docs/zh-CN/reference/FEATURE_MATRIX.md b/docs/zh-CN/reference/FEATURE_MATRIX.md index 889c1d8..95f279d 100644 --- a/docs/zh-CN/reference/FEATURE_MATRIX.md +++ b/docs/zh-CN/reference/FEATURE_MATRIX.md @@ -9,39 +9,47 @@ Complete feature support across all platforms and skill modes. | **Claude AI** | ZIP | ✅ Anthropic API | ✅ Sonnet 4 | ANTHROPIC_API_KEY | | **Google Gemini** | tar.gz | ✅ Files API | ✅ Gemini 2.0 | GOOGLE_API_KEY | | **OpenAI ChatGPT** | ZIP | ✅ Assistants API | ✅ GPT-4o | OPENAI_API_KEY | +| **OpenCode** | Directory | ❌ Manual | ❌ None | None | +| **Kimi** | ZIP | ❌ Manual | ❌ None | None | +| **DeepSeek** | ZIP | ❌ Manual | ❌ None | None | +| **Qwen** | ZIP | ❌ Manual | ❌ None | None | +| **OpenRouter** | ZIP | ❌ Manual | ❌ None | None | +| **Together AI** | ZIP | ❌ Manual | ❌ None | None | +| **Fireworks AI** | ZIP | ❌ Manual | ❌ None | None | +| **MiniMax** | ZIP | ❌ Manual | ❌ None | None | | **Generic Markdown** | ZIP | ❌ Manual | ❌ None | None | ## Source Type Support (17 Types) | Source Type | CLI Command | Platforms | Detection | |-------------|------------|-----------|-----------| -| **Documentation (web)** | `scrape` / `create ` | All 4 | HTTP/HTTPS URLs | -| **GitHub repo** | `github` / `create owner/repo` | All 4 | `owner/repo` or github.com URLs | -| **PDF** | `pdf` / `create file.pdf` | All 4 | `.pdf` extension | -| **Word (.docx)** | `word` / `create file.docx` | All 4 | `.docx` extension | -| **EPUB** | `epub` / `create file.epub` | All 4 | `.epub` extension | -| **Video** | `video` / `create ` | All 4 | YouTube/Vimeo URLs, video extensions | -| **Local codebase** | `analyze` / `create ./path` | All 4 | Directory paths | -| **Jupyter Notebook** | `jupyter` / `create file.ipynb` | All 4 | `.ipynb` extension | -| **Local HTML** | `html` / `create file.html` | All 4 | `.html`/`.htm` extensions | -| **OpenAPI/Swagger** | `openapi` / `create spec.yaml` | All 4 | `.yaml`/`.yml` with OpenAPI content | -| **AsciiDoc** | `asciidoc` / `create file.adoc` | All 4 | `.adoc`/`.asciidoc` extensions | -| **PowerPoint** | `pptx` / `create file.pptx` | All 4 | `.pptx` extension | -| **RSS/Atom** | `rss` / `create feed.rss` | All 4 | `.rss`/`.atom` extensions | -| **Man pages** | `manpage` / `create cmd.1` | All 4 | `.1`–`.8`/`.man` extensions | -| **Confluence** | `confluence` | All 4 | API or export directory | -| **Notion** | `notion` | All 4 | API or export directory | -| **Slack/Discord** | `chat` | All 4 | Export directory or API | +| **Documentation (web)** | `scrape` / `create ` | All 12 | HTTP/HTTPS URLs | +| **GitHub repo** | `github` / `create owner/repo` | All 12 | `owner/repo` or github.com URLs | +| **PDF** | `pdf` / `create file.pdf` | All 12 | `.pdf` extension | +| **Word (.docx)** | `word` / `create file.docx` | All 12 | `.docx` extension | +| **EPUB** | `epub` / `create file.epub` | All 12 | `.epub` extension | +| **Video** | `video` / `create ` | All 12 | YouTube/Vimeo URLs, video extensions | +| **Local codebase** | `analyze` / `create ./path` | All 12 | Directory paths | +| **Jupyter Notebook** | `jupyter` / `create file.ipynb` | All 12 | `.ipynb` extension | +| **Local HTML** | `html` / `create file.html` | All 12 | `.html`/`.htm` extensions | +| **OpenAPI/Swagger** | `openapi` / `create spec.yaml` | All 12 | `.yaml`/`.yml` with OpenAPI content | +| **AsciiDoc** | `asciidoc` / `create file.adoc` | All 12 | `.adoc`/`.asciidoc` extensions | +| **PowerPoint** | `pptx` / `create file.pptx` | All 12 | `.pptx` extension | +| **RSS/Atom** | `rss` / `create feed.rss` | All 12 | `.rss`/`.atom` extensions | +| **Man pages** | `manpage` / `create cmd.1` | All 12 | `.1`–`.8`/`.man` extensions | +| **Confluence** | `confluence` | All 12 | API or export directory | +| **Notion** | `notion` | All 12 | API or export directory | +| **Slack/Discord** | `chat` | All 12 | Export directory or API | ## Skill Mode Support | Mode | Description | Platforms | Example Configs | |------|-------------|-----------|-----------------| -| **Documentation** | Scrape HTML docs | All 4 | react.json, django.json (14 total) | -| **GitHub** | Analyze repositories | All 4 | react_github.json, godot_github.json | -| **PDF** | Extract from PDFs | All 4 | example_pdf.json | -| **Unified** | Multi-source (docs+GitHub+PDF+more) | All 4 | react_unified.json (5 total) | -| **Local Repo** | Unlimited local analysis | All 4 | deck_deck_go_local.json | +| **Documentation** | Scrape HTML docs | All 12 | react.json, django.json (14 total) | +| **GitHub** | Analyze repositories | All 12 | react_github.json, godot_github.json | +| **PDF** | Extract from PDFs | All 12 | example_pdf.json | +| **Unified** | Multi-source (docs+GitHub+PDF+more) | All 12 | react_unified.json (5 total) | +| **Local Repo** | Unlimited local analysis | All 12 | deck_deck_go_local.json | ## CLI Command Support @@ -136,21 +144,21 @@ Complete feature support across all platforms and skill modes. ``` Config → Scrape → Build → [Enhance] → Package --target X → [Upload --target X] ``` -**Platforms:** All 4 +**Platforms:** All 12 **Modes:** Docs, GitHub, PDF ### Unified Multi-Source Workflow ``` Config → Scrape All → Detect Conflicts → Merge → Build → [Enhance] → Package --target X → [Upload --target X] ``` -**Platforms:** All 4 +**Platforms:** All 12 **Modes:** Unified only ### Complete Installation Workflow ``` install --target X → Fetch → Scrape → Enhance → Package → Upload ``` -**Platforms:** All 4 +**Platforms:** All 12 **Modes:** All (via config type detection) ## API Key Requirements @@ -347,7 +355,7 @@ A: Yes! Enhancement adds platform-specific formatting: - OpenAI: Plain text assistant instructions **Q: Do all skill modes work with all platforms?** -A: Yes! All 17 source types and all 5 skill modes (Docs, GitHub, PDF, Unified, Local Repo) work with all 4 platforms. +A: Yes! All 17 source types and all 5 skill modes (Docs, GitHub, PDF, Unified, Local Repo) work with all 12 platforms. ## See Also diff --git a/docs/zh-CN/user-guide/04-packaging.md b/docs/zh-CN/user-guide/04-packaging.md index f343f94..d413847 100644 --- a/docs/zh-CN/user-guide/04-packaging.md +++ b/docs/zh-CN/user-guide/04-packaging.md @@ -1,6 +1,6 @@ # Packaging Guide -> **Skill Seekers v3.1.0** +> **Skill Seekers v3.4.0** > **Export skills to AI platforms and vector databases** --- @@ -26,6 +26,13 @@ output/my-skill/ ──▶ Packager ──▶ output/my-skill-{platform}.{format | **Claude AI** | ZIP + YAML | `.zip` | Claude Code, Claude API | | **Google Gemini** | tar.gz | `.tar.gz` | Gemini skills | | **OpenAI ChatGPT** | ZIP + Vector | `.zip` | Custom GPTs | +| **OpenCode** | Directory | directory | OpenCode agent | +| **Kimi** | ZIP | `.zip` | Kimi platform | +| **DeepSeek** | ZIP | `.zip` | DeepSeek platform | +| **Qwen** | ZIP | `.zip` | Qwen platform | +| **OpenRouter** | ZIP | `.zip` | OpenRouter | +| **Together AI** | ZIP | `.zip` | Together AI | +| **Fireworks AI** | ZIP | `.zip` | Fireworks AI | | **LangChain** | Documents | directory | RAG pipelines | | **LlamaIndex** | TextNodes | directory | Query engines | | **Haystack** | Documents | directory | Enterprise RAG | From eb13f96ece64a938b73ce780dd83555ec3c12913 Mon Sep 17 00:00:00 2001 From: yusyus Date: Sat, 21 Mar 2026 20:50:50 +0300 Subject: [PATCH 10/21] docs: update remaining docs for 12 LLM platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update platform counts (4→12) in: - docs/reference/CLAUDE_INTEGRATION.md (EN + zh-CN) - docs/guides/MCP_SETUP.md, UPLOAD_GUIDE.md, MIGRATION_GUIDE.md - docs/strategy/INTEGRATION_STRATEGY.md, DEEPWIKI_ANALYSIS.md, KIMI_ANALYSIS_COMPARISON.md - docs/archive/historical/HTTPX_SKILL_GRADING.md Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/archive/historical/HTTPX_SKILL_GRADING.md | 2 +- docs/guides/MCP_SETUP.md | 2 +- docs/guides/MIGRATION_GUIDE.md | 4 ++-- docs/guides/UPLOAD_GUIDE.md | 2 +- docs/reference/CLAUDE_INTEGRATION.md | 2 +- docs/strategy/DEEPWIKI_ANALYSIS.md | 4 ++-- docs/strategy/INTEGRATION_STRATEGY.md | 2 +- docs/strategy/KIMI_ANALYSIS_COMPARISON.md | 2 +- docs/zh-CN/reference/CLAUDE_INTEGRATION.md | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/archive/historical/HTTPX_SKILL_GRADING.md b/docs/archive/historical/HTTPX_SKILL_GRADING.md index ceae3c7..ad37bd6 100644 --- a/docs/archive/historical/HTTPX_SKILL_GRADING.md +++ b/docs/archive/historical/HTTPX_SKILL_GRADING.md @@ -809,7 +809,7 @@ platforms: 1. Add `platforms: [claude, gemini, openai, markdown]` to YAML 2. Add `version: 1.0.0` to YAML -3. Test skill loading on all 4 platforms +3. Test skill loading on all 12 platforms 4. Document any platform-specific quirks 5. Add `skill.yaml` file (optional, mirrors frontmatter) diff --git a/docs/guides/MCP_SETUP.md b/docs/guides/MCP_SETUP.md index 2025b97..97077fd 100644 --- a/docs/guides/MCP_SETUP.md +++ b/docs/guides/MCP_SETUP.md @@ -762,7 +762,7 @@ Agent: [Scraping internal documentation...] ### Example 4: Multi-Platform Support -Skill Seekers supports packaging and uploading to 4 LLM platforms: Claude AI, Google Gemini, OpenAI ChatGPT, and Generic Markdown. +Skill Seekers supports packaging and uploading to 12 LLM platforms: Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, and Generic Markdown. ``` User: Scrape docs using configs/react.json diff --git a/docs/guides/MIGRATION_GUIDE.md b/docs/guides/MIGRATION_GUIDE.md index df497d9..f66f7f3 100644 --- a/docs/guides/MIGRATION_GUIDE.md +++ b/docs/guides/MIGRATION_GUIDE.md @@ -190,7 +190,7 @@ python -m skill_seekers.mcp.server | Config files | ✅ | ✅ | No | | Codebase flags | `--build-*` | `--skip-*` | Yes (but backward compatible) | | MCP tools | 9 tools | 18 tools | No (additive) | -| Platform support | Claude only | 4 platforms | No (opt-in) | +| Platform support | Claude only | 12 platforms | No (opt-in) | --- @@ -422,7 +422,7 @@ skill-seekers install react --target claude --upload | CLI commands | Separate | Unified | Update scripts | | Config format | Basic | Unified | Old still works | | MCP server | 9 tools | 18 tools | Update config | -| Platforms | Claude only | 4 platforms | Opt-in | +| Platforms | Claude only | 12 platforms | Opt-in | --- diff --git a/docs/guides/UPLOAD_GUIDE.md b/docs/guides/UPLOAD_GUIDE.md index d11063c..d80b805 100644 --- a/docs/guides/UPLOAD_GUIDE.md +++ b/docs/guides/UPLOAD_GUIDE.md @@ -1,6 +1,6 @@ # Multi-Platform Upload Guide -Skill Seekers supports uploading to **4 LLM platforms**: Claude AI, Google Gemini, OpenAI ChatGPT, and Generic Markdown export. +Skill Seekers supports uploading to **12 LLM platforms**: Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, and Generic Markdown export. ## Quick Platform Selection diff --git a/docs/reference/CLAUDE_INTEGRATION.md b/docs/reference/CLAUDE_INTEGRATION.md index 5c0dc17..85d8b0d 100644 --- a/docs/reference/CLAUDE_INTEGRATION.md +++ b/docs/reference/CLAUDE_INTEGRATION.md @@ -22,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Recent Updates (December 2025): **🎉 MAJOR RELEASE: Multi-Platform Feature Parity! (v2.5.0)** -- **🌐 Multi-LLM Support**: Full support for 4 platforms - Claude AI, Google Gemini, OpenAI ChatGPT, Generic Markdown +- **🌐 Multi-LLM Support**: Full support for 12 platforms - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, Generic Markdown - **🔄 Complete Feature Parity**: All skill modes work with all platforms - **🏗️ Platform Adaptors**: Clean architecture with platform-specific implementations - **✨ 26 MCP Tools**: Enhanced with multi-platform support (package, upload, enhance) diff --git a/docs/strategy/DEEPWIKI_ANALYSIS.md b/docs/strategy/DEEPWIKI_ANALYSIS.md index 9f5c0dc..215db19 100644 --- a/docs/strategy/DEEPWIKI_ANALYSIS.md +++ b/docs/strategy/DEEPWIKI_ANALYSIS.md @@ -78,7 +78,7 @@ Step 3: Success | Code analysis | ✅ File tree | ✅ AST + Patterns + Examples | 90% | | Issues/PRs | ❌ Not using | ✅ Top problems/solutions | 100% | | AI enhancement | ❌ Not using | ✅ Dual mode (API/LOCAL) | 100% | -| Multi-platform | ❌ Claude only | ✅ 4 platforms | 75% | +| Multi-platform | ❌ Claude only | ✅ 12 platforms | 75% | | Router skills | ❌ Not using | ✅ Solves context limits | 100% | | Rate limit mgmt | ❌ Not aware | ✅ Multi-token system | 100% | @@ -167,7 +167,7 @@ skill-seekers config --github **They Only Know:** Claude AI -**We Support:** 4 platforms +**We Support:** 12 platforms - Claude AI (ZIP + YAML) - Google Gemini (tar.gz) - OpenAI ChatGPT (ZIP + Vector Store) diff --git a/docs/strategy/INTEGRATION_STRATEGY.md b/docs/strategy/INTEGRATION_STRATEGY.md index 21f4406..ab69fa6 100644 --- a/docs/strategy/INTEGRATION_STRATEGY.md +++ b/docs/strategy/INTEGRATION_STRATEGY.md @@ -33,7 +33,7 @@ We can replicate this positioning with dozens of other tools/platforms to create | **MCP integration** | ✅ Aware | ✅ 26 tools available | **Medium** | | **Context limits** | ⚠️ Problem | ✅ Router skills solve | **Large** | | **AI enhancement** | ❌ Not mentioned | ✅ Dual mode (API/LOCAL) | **Large** | -| **Multi-platform** | ❌ Claude only | ✅ 4 platforms | **Medium** | +| **Multi-platform** | ❌ Claude only | ✅ 12 platforms | **Medium** | | **Rate limits** | ❌ Not mentioned | ✅ Smart management | **Medium** | | **Quality** | Basic | Production-ready | **Large** | diff --git a/docs/strategy/KIMI_ANALYSIS_COMPARISON.md b/docs/strategy/KIMI_ANALYSIS_COMPARISON.md index 2cefce8..5958fd8 100644 --- a/docs/strategy/KIMI_ANALYSIS_COMPARISON.md +++ b/docs/strategy/KIMI_ANALYSIS_COMPARISON.md @@ -98,7 +98,7 @@ CodeSee, Sourcery, Stepsize, Swimm - medium priority ### 1. **New Output Formats** (HIGH PRIORITY) -**Current:** `--target claude|gemini|openai|markdown` +**Current:** `--target claude|gemini|openai|minimax|opencode|kimi|deepseek|qwen|openrouter|together|fireworks|markdown` **Add:** ```bash diff --git a/docs/zh-CN/reference/CLAUDE_INTEGRATION.md b/docs/zh-CN/reference/CLAUDE_INTEGRATION.md index 5c0dc17..85d8b0d 100644 --- a/docs/zh-CN/reference/CLAUDE_INTEGRATION.md +++ b/docs/zh-CN/reference/CLAUDE_INTEGRATION.md @@ -22,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Recent Updates (December 2025): **🎉 MAJOR RELEASE: Multi-Platform Feature Parity! (v2.5.0)** -- **🌐 Multi-LLM Support**: Full support for 4 platforms - Claude AI, Google Gemini, OpenAI ChatGPT, Generic Markdown +- **🌐 Multi-LLM Support**: Full support for 12 platforms - Claude AI, Google Gemini, OpenAI ChatGPT, MiniMax AI, OpenCode, Kimi, DeepSeek, Qwen, OpenRouter, Together AI, Fireworks AI, Generic Markdown - **🔄 Complete Feature Parity**: All skill modes work with all platforms - **🏗️ Platform Adaptors**: Clean architecture with platform-specific implementations - **✨ 26 MCP Tools**: Enhanced with multi-platform support (package, upload, enhance) From 0fa99641aa687a2e79a72966db8506d9e4bc5892 Mon Sep 17 00:00:00 2001 From: yusyus Date: Sat, 21 Mar 2026 21:24:21 +0300 Subject: [PATCH 11/21] style: fix pre-existing ruff format issues in 5 files Co-Authored-By: Claude Opus 4.6 (1M context) --- src/skill_seekers/cli/doc_scraper.py | 12 ++++++++++-- tests/test_issue_277_discord_e2e.py | 6 ++---- tests/test_issue_277_real_world.py | 4 +--- tests/test_unified_scraper_orchestration.py | 12 ++---------- tests/test_url_conversion.py | 8 ++++++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/skill_seekers/cli/doc_scraper.py b/src/skill_seekers/cli/doc_scraper.py index 3490cc6..502ab30 100755 --- a/src/skill_seekers/cli/doc_scraper.py +++ b/src/skill_seekers/cli/doc_scraper.py @@ -471,7 +471,11 @@ class DocToSkillConverter: else: continue full_url = full_url.split("#")[0] - if self._has_md_extension(full_url) and self.is_valid_url(full_url) and full_url not in links: + if ( + self._has_md_extension(full_url) + and self.is_valid_url(full_url) + and full_url not in links + ): links.append(full_url) return { @@ -560,7 +564,11 @@ class DocToSkillConverter: # Strip anchor fragments full_url = full_url.split("#")[0] # Only include .md URLs to avoid client-side rendered HTML pages - if self._has_md_extension(full_url) and self.is_valid_url(full_url) and full_url not in page["links"]: + if ( + self._has_md_extension(full_url) + and self.is_valid_url(full_url) + and full_url not in page["links"] + ): page["links"].append(full_url) return page diff --git a/tests/test_issue_277_discord_e2e.py b/tests/test_issue_277_discord_e2e.py index f7b01a3..1d48646 100644 --- a/tests/test_issue_277_discord_e2e.py +++ b/tests/test_issue_277_discord_e2e.py @@ -88,8 +88,7 @@ class TestIssue277DiscordDocsE2E(unittest.TestCase): len(bad_urls), 0, f"Found {len(bad_urls)} URLs with /index.html.md appended " - f"(would cause 404s):\n" - + "\n".join(bad_urls[:10]), + f"(would cause 404s):\n" + "\n".join(bad_urls[:10]), ) # Step 6: Verify no anchor fragments leaked through @@ -97,8 +96,7 @@ class TestIssue277DiscordDocsE2E(unittest.TestCase): self.assertEqual( len(anchor_urls), 0, - f"Found {len(anchor_urls)} URLs with anchor fragments:\n" - + "\n".join(anchor_urls[:10]), + f"Found {len(anchor_urls)} URLs with anchor fragments:\n" + "\n".join(anchor_urls[:10]), ) # Step 7: Verify we got a reasonable number of URLs diff --git a/tests/test_issue_277_real_world.py b/tests/test_issue_277_real_world.py index da65d7f..9cbc280 100644 --- a/tests/test_issue_277_real_world.py +++ b/tests/test_issue_277_real_world.py @@ -49,9 +49,7 @@ class TestIssue277RealWorld(unittest.TestCase): self.assertNotIn("#", url, f"URL should not contain anchor: {url}") # No /index.html.md should be appended to non-.md URLs if not url.endswith(".md"): - self.assertNotIn( - "index.html.md", url, f"Should not append /index.html.md: {url}" - ) + self.assertNotIn("index.html.md", url, f"Should not append /index.html.md: {url}") # .md URLs preserved, non-.md URLs preserved as-is, anchors deduplicated self.assertIn("https://mikro-orm.io/docs/reference.md", result) diff --git a/tests/test_unified_scraper_orchestration.py b/tests/test_unified_scraper_orchestration.py index 02309fc..ec57855 100644 --- a/tests/test_unified_scraper_orchestration.py +++ b/tests/test_unified_scraper_orchestration.py @@ -224,11 +224,7 @@ class TestScrapeDocumentation: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") scraper._scrape_documentation(source) - assert any( - "llms_txt_url" in s - for c in written_configs - for s in c.get("sources", [c]) - ) + assert any("llms_txt_url" in s for c in written_configs for s in c.get("sources", [c])) def test_start_urls_forwarded_to_doc_config(self, tmp_path): """start_urls from source is forwarded to the temporary doc config.""" @@ -251,11 +247,7 @@ class TestScrapeDocumentation: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") scraper._scrape_documentation(source) - assert any( - "start_urls" in s - for c in written_configs - for s in c.get("sources", [c]) - ) + assert any("start_urls" in s for c in written_configs for s in c.get("sources", [c])) # =========================================================================== diff --git a/tests/test_url_conversion.py b/tests/test_url_conversion.py index 49a3c38..e9be026 100644 --- a/tests/test_url_conversion.py +++ b/tests/test_url_conversion.py @@ -280,10 +280,14 @@ class TestHasMdExtension(unittest.TestCase): def test_md_in_middle_of_path(self): """.md in middle of path should not match""" - self.assertFalse(DocToSkillConverter._has_md_extension("https://example.com/page.md/subpage")) + self.assertFalse( + DocToSkillConverter._has_md_extension("https://example.com/page.md/subpage") + ) def test_index_html_md(self): - self.assertTrue(DocToSkillConverter._has_md_extension("https://example.com/page/index.html.md")) + self.assertTrue( + DocToSkillConverter._has_md_extension("https://example.com/page/index.html.md") + ) if __name__ == "__main__": From d0d7d5a9397c77d63eed68f6fc3f4a8fb2d12926 Mon Sep 17 00:00:00 2001 From: yusyus Date: Sat, 21 Mar 2026 21:39:22 +0300 Subject: [PATCH 12/21] chore: remove stale root-level test scripts and junk files Remove files that should never have been committed: - test_api.py, test_httpx_quick.sh, test_httpx_skill.sh (ad-hoc test scripts) - test_week2_features.py (one-off validation script) - test_results.log (log file) - =0.24.0 (accidental pip error output) - demo_conflicts.py (demo script) - ruff_errors.txt (stale lint output) - TESTING_GAP_REPORT.md (stale one-time report) Co-Authored-By: Claude Opus 4.6 (1M context) --- =0.24.0 | 18 -- TESTING_GAP_REPORT.md | 345 -------------------------------- demo_conflicts.py | 204 ------------------- ruff_errors.txt | 439 ----------------------------------------- test_api.py | 43 ---- test_httpx_quick.sh | 62 ------ test_httpx_skill.sh | 249 ----------------------- test_results.log | 65 ------ test_week2_features.py | 273 ------------------------- 9 files changed, 1698 deletions(-) delete mode 100644 =0.24.0 delete mode 100644 TESTING_GAP_REPORT.md delete mode 100644 demo_conflicts.py delete mode 100644 ruff_errors.txt delete mode 100644 test_api.py delete mode 100644 test_httpx_quick.sh delete mode 100755 test_httpx_skill.sh delete mode 100644 test_results.log delete mode 100755 test_week2_features.py diff --git a/=0.24.0 b/=0.24.0 deleted file mode 100644 index 83a1f95..0000000 --- a/=0.24.0 +++ /dev/null @@ -1,18 +0,0 @@ -error: externally-managed-environment - -× This environment is externally managed -╰─> To install Python packages system-wide, try 'pacman -S - python-xyz', where xyz is the package you are trying to - install. - - If you wish to install a non-Arch-packaged Python package, - create a virtual environment using 'python -m venv path/to/venv'. - Then use path/to/venv/bin/python and path/to/venv/bin/pip. - - If you wish to install a non-Arch packaged Python application, - it may be easiest to use 'pipx install xyz', which will manage a - virtual environment for you. Make sure you have python-pipx - installed via pacman. - -note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages. -hint: See PEP 668 for the detailed specification. diff --git a/TESTING_GAP_REPORT.md b/TESTING_GAP_REPORT.md deleted file mode 100644 index 2277fb0..0000000 --- a/TESTING_GAP_REPORT.md +++ /dev/null @@ -1,345 +0,0 @@ -# Comprehensive Testing Gap Report - -**Project:** Skill Seekers v3.1.0 -**Date:** 2026-02-22 -**Total Test Files:** 113 -**Total Test Functions:** ~208+ (collected: 2173 tests) - ---- - -## Executive Summary - -### Overall Test Health: 🟡 GOOD with Gaps - -| Category | Status | Coverage | Key Gaps | -|----------|--------|----------|----------| -| CLI Arguments | ✅ Good | 85% | Some edge cases | -| Workflow System | ✅ Excellent | 90% | Inline stage parsing edge cases | -| Scrapers | 🟡 Moderate | 70% | Missing real HTTP/PDF tests | -| Enhancement | 🟡 Partial | 60% | Core logic not tested | -| MCP Tools | 🟡 Good | 75% | 8 tools not covered | -| Integration/E2E | 🟡 Moderate | 65% | Heavy mocking | -| Adaptors | ✅ Good | 80% | Good coverage per platform | - ---- - -## Detailed Findings by Category - -### 1. CLI Argument Tests ✅ GOOD - -**Files Reviewed:** -- `test_analyze_command.py` (269 lines, 26 tests) -- `test_unified.py` - TestUnifiedCLIArguments class (6 tests) -- `test_pdf_scraper.py` - TestPDFCLIArguments class (4 tests) -- `test_create_arguments.py` (399 lines) -- `test_create_integration_basic.py` (310 lines, 23 tests) - -**Strengths:** -- All new workflow flags are tested (`--enhance-workflow`, `--enhance-stage`, `--var`, `--workflow-dry-run`) -- Argument parsing thoroughly tested -- Default values verified -- Complex command combinations tested - -**Gaps:** -- `test_create_integration_basic.py`: 2 tests skipped (source auto-detection not fully tested) -- No tests for invalid argument combinations beyond basic parsing errors - ---- - -### 2. Workflow Tests ✅ EXCELLENT - -**Files Reviewed:** -- `test_workflow_runner.py` (445 lines, 30+ tests) -- `test_workflows_command.py` (571 lines, 40+ tests) -- `test_workflow_tools_mcp.py` (295 lines, 20+ tests) - -**Strengths:** -- Comprehensive workflow execution tests -- Variable substitution thoroughly tested -- Dry-run mode tested -- Workflow chaining tested -- All 6 workflow subcommands tested (list, show, copy, add, remove, validate) -- MCP workflow tools tested - -**Minor Gaps:** -- No tests for `_build_inline_engine` edge cases -- No tests for malformed stage specs (empty, invalid format) - ---- - -### 3. Scraper Tests 🟡 MODERATE with Significant Gaps - -**Files Reviewed:** -- `test_scraper_features.py` (524 lines) - Doc scraper features -- `test_codebase_scraper.py` (478 lines) - Codebase analysis -- `test_pdf_scraper.py` (558 lines) - PDF scraper -- `test_github_scraper.py` (1015 lines) - GitHub scraper -- `test_unified_analyzer.py` (428 lines) - Unified analyzer - -**Critical Gaps:** - -#### A. Missing Real External Resource Tests -| Resource | Test Type | Status | -|----------|-----------|--------| -| HTTP Requests (docs) | Mocked only | ❌ Gap | -| PDF Extraction | Mocked only | ❌ Gap | -| GitHub API | Mocked only | ❌ Gap (acceptable) | -| Local Files | Real tests | ✅ Good | - -#### B. Missing Core Function Tests -| Function | Location | Priority | -|----------|----------|----------| -| `UnifiedScraper.run()` | unified_scraper.py | 🔴 High | -| `UnifiedScraper._scrape_documentation()` | unified_scraper.py | 🔴 High | -| `UnifiedScraper._scrape_github()` | unified_scraper.py | 🔴 High | -| `UnifiedScraper._scrape_pdf()` | unified_scraper.py | 🔴 High | -| `UnifiedScraper._scrape_local()` | unified_scraper.py | 🟡 Medium | -| `DocToSkillConverter.scrape()` | doc_scraper.py | 🔴 High | -| `PDFToSkillConverter.extract_pdf()` | pdf_scraper.py | 🔴 High | - -#### C. PDF Scraper Limited Coverage -- No actual PDF parsing tests (only mocked) -- OCR functionality not tested -- Page range extraction not tested - ---- - -### 4. Enhancement Tests 🟡 PARTIAL - MAJOR GAPS - -**Files Reviewed:** -- `test_enhance_command.py` (367 lines, 25+ tests) -- `test_enhance_skill_local.py` (163 lines, 14 tests) - -**Critical Gap in `test_enhance_skill_local.py`:** - -| Function | Lines | Tested? | Priority | -|----------|-------|---------|----------| -| `summarize_reference()` | ~50 | ❌ No | 🔴 High | -| `create_enhancement_prompt()` | ~200 | ❌ No | 🔴 High | -| `run()` | ~100 | ❌ No | 🔴 High | -| `_run_headless()` | ~130 | ❌ No | 🔴 High | -| `_run_background()` | ~80 | ❌ No | 🟡 Medium | -| `_run_daemon()` | ~60 | ❌ No | 🟡 Medium | -| `write_status()` | ~30 | ❌ No | 🟡 Medium | -| `read_status()` | ~40 | ❌ No | 🟡 Medium | -| `detect_terminal_app()` | ~80 | ❌ No | 🟡 Medium | - -**Current Tests Only Cover:** -- Agent presets configuration -- Command building -- Agent name normalization -- Environment variable handling - -**Recommendation:** Add comprehensive tests for the core enhancement logic. - ---- - -### 5. MCP Tool Tests 🟡 GOOD with Coverage Gaps - -**Files Reviewed:** -- `test_mcp_fastmcp.py` (868 lines) -- `test_mcp_server.py` (715 lines) -- `test_mcp_vector_dbs.py` (259 lines) -- `test_real_world_fastmcp.py` (558 lines) - -**Coverage Analysis:** - -| Tool Category | Tools | Tested | Coverage | -|---------------|-------|--------|----------| -| Config Tools | 3 | 3 | ✅ 100% | -| Scraping Tools | 8 | 4 | 🟡 50% | -| Packaging Tools | 4 | 4 | ✅ 100% | -| Splitting Tools | 2 | 2 | ✅ 100% | -| Source Tools | 5 | 5 | ✅ 100% | -| Vector DB Tools | 4 | 4 | ✅ 100% | -| Workflow Tools | 5 | 0 | ❌ 0% | -| **Total** | **31** | **22** | **🟡 71%** | - -**Untested Tools:** -1. `detect_patterns` -2. `extract_test_examples` -3. `build_how_to_guides` -4. `extract_config_patterns` -5. `list_workflows` -6. `get_workflow` -7. `create_workflow` -8. `update_workflow` -9. `delete_workflow` - -**Note:** `test_mcp_server.py` tests legacy server, `test_mcp_fastmcp.py` tests modern server. - ---- - -### 6. Integration/E2E Tests 🟡 MODERATE - -**Files Reviewed:** -- `test_create_integration_basic.py` (310 lines) -- `test_e2e_three_stream_pipeline.py` (598 lines) -- `test_analyze_e2e.py` (344 lines) -- `test_install_skill_e2e.py` (533 lines) -- `test_c3_integration.py` (362 lines) - -**Issues Found:** - -1. **Skipped Tests:** - - `test_create_detects_web_url` - Source auto-detection incomplete - - `test_create_invalid_source_shows_error` - Error handling incomplete - - `test_cli_via_unified_command` - Asyncio issues - -2. **Heavy Mocking:** - - Most GitHub API tests use mocking - - No real HTTP tests for doc scraping - - Integration tests don't test actual integration - -3. **Limited Scope:** - - Only `--quick` preset tested (not `--comprehensive`) - - C3.x tests use mock data only - - Most E2E tests are unit tests with mocks - ---- - -### 7. Adaptor Tests ✅ GOOD - -**Files Reviewed:** -- `test_adaptors/test_adaptors_e2e.py` (893 lines) -- `test_adaptors/test_claude_adaptor.py` (314 lines) -- `test_adaptors/test_gemini_adaptor.py` (146 lines) -- `test_adaptors/test_openai_adaptor.py` (188 lines) -- Plus 8 more platform adaptors - -**Strengths:** -- Each adaptor has dedicated tests -- Package format testing -- Upload success/failure scenarios -- Platform-specific features tested - -**Minor Gaps:** -- Some adaptors only test 1-2 scenarios -- Error handling coverage varies by platform - ---- - -### 8. Config/Validation Tests ✅ GOOD - -**Files Reviewed:** -- `test_config_validation.py` (270 lines) -- `test_config_extractor.py` (629 lines) -- `test_config_fetcher.py` (340 lines) - -**Strengths:** -- Unified vs legacy format detection -- Field validation comprehensive -- Error message quality tested - ---- - -## Summary of Critical Testing Gaps - -### 🔴 HIGH PRIORITY (Must Fix) - -1. **Enhancement Core Logic** - - File: `test_enhance_skill_local.py` - - Missing: 9 major functions - - Impact: Core feature untested - -2. **Unified Scraper Main Flow** - - File: New tests needed - - Missing: `_scrape_*()` methods, `run()` orchestration - - Impact: Multi-source scraping untested - -3. **Actual HTTP/PDF/GitHub Integration** - - Missing: Real external resource tests - - Impact: Only mock tests exist - -### 🟡 MEDIUM PRIORITY (Should Fix) - -4. **MCP Workflow Tools** - - Missing: 5 workflow tools (0% coverage) - - Impact: MCP workflow features untested - -5. **Skipped Integration Tests** - - 3 tests skipped - - Impact: Source auto-detection incomplete - -6. **PDF Real Extraction** - - Missing: Actual PDF parsing - - Impact: PDF feature quality unknown - -### 🟢 LOW PRIORITY (Nice to Have) - -7. **Additional Scraping Tools** - - Missing: 4 scraping tool tests - - Impact: Low (core tools covered) - -8. **Edge Case Coverage** - - Missing: Invalid argument combinations - - Impact: Low (happy path covered) - ---- - -## Recommendations - -### Immediate Actions (Next Sprint) - -1. **Add Enhancement Logic Tests** (~400 lines) - - Test `summarize_reference()` - - Test `create_enhancement_prompt()` - - Test `run()` method - - Test status read/write - -2. **Fix Skipped Tests** (~100 lines) - - Fix asyncio issues in `test_cli_via_unified_command` - - Complete source auto-detection tests - -3. **Add MCP Workflow Tool Tests** (~200 lines) - - Test all 5 workflow tools - -### Short Term (Next Month) - -4. **Add Unified Scraper Integration Tests** (~300 lines) - - Test main orchestration flow - - Test individual source scraping - -5. **Add Real PDF Tests** (~150 lines) - - Test with actual PDF files - - Test OCR if available - -### Long Term (Next Quarter) - -6. **HTTP Integration Tests** (~200 lines) - - Test with real websites (use test sites) - - Mock server approach - -7. **Complete E2E Pipeline** (~300 lines) - - Full workflow from scrape to upload - - Real GitHub repo (fork test repo) - ---- - -## Test Quality Metrics - -| Metric | Score | Notes | -|--------|-------|-------| -| Test Count | 🟢 Good | 2173+ tests | -| Coverage | 🟡 Moderate | ~75% estimated | -| Real Tests | 🟡 Moderate | Many mocked | -| Documentation | 🟢 Good | Most tests documented | -| Maintenance | 🟢 Good | Tests recently updated | - ---- - -## Conclusion - -The Skill Seekers test suite is **comprehensive in quantity** (2173+ tests) but has **quality gaps** in critical areas: - -1. **Core enhancement logic** is largely untested -2. **Multi-source scraping** orchestration lacks integration tests -3. **MCP workflow tools** have zero coverage -4. **Real external resource** testing is minimal - -**Priority:** Fix the 🔴 HIGH priority gaps first, as they impact core functionality. - ---- - -*Report generated: 2026-02-22* -*Reviewer: Systematic test review with parallel subagent analysis* diff --git a/demo_conflicts.py b/demo_conflicts.py deleted file mode 100644 index 5ee5f72..0000000 --- a/demo_conflicts.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env python3 -""" -Demo: Conflict Detection and Reporting - -This demonstrates the unified scraper's ability to detect and report -conflicts between documentation and code implementation. -""" - -import json -import sys -from pathlib import Path - -# Add CLI to path -sys.path.insert(0, str(Path(__file__).parent / "cli")) - - -print("=" * 70) -print("UNIFIED SCRAPER - CONFLICT DETECTION DEMO") -print("=" * 70) -print() - -# Load test data -print("📂 Loading test data...") -print(" - Documentation APIs from example docs") -print(" - Code APIs from example repository") -print() - -with open("cli/conflicts.json") as f: - conflicts_data = json.load(f) - -conflicts = conflicts_data["conflicts"] -summary = conflicts_data["summary"] - -print(f"✅ Loaded {summary['total']} conflicts") -print() - -# Display summary -print("=" * 70) -print("CONFLICT SUMMARY") -print("=" * 70) -print() - -print(f"📊 **Total Conflicts**: {summary['total']}") -print() - -print("**By Type:**") -for conflict_type, count in summary["by_type"].items(): - if count > 0: - emoji = ( - "📖" - if conflict_type == "missing_in_docs" - else "💻" - if conflict_type == "missing_in_code" - else "⚠️" - ) - print(f" {emoji} {conflict_type}: {count}") -print() - -print("**By Severity:**") -for severity, count in summary["by_severity"].items(): - if count > 0: - emoji = "🔴" if severity == "high" else "🟡" if severity == "medium" else "🟢" - print(f" {emoji} {severity.upper()}: {count}") -print() - -# Display detailed conflicts -print("=" * 70) -print("DETAILED CONFLICT REPORTS") -print("=" * 70) -print() - -# Group by severity -high = [c for c in conflicts if c["severity"] == "high"] -medium = [c for c in conflicts if c["severity"] == "medium"] -low = [c for c in conflicts if c["severity"] == "low"] - -# Show high severity first -if high: - print("🔴 **HIGH SEVERITY CONFLICTS** (Requires immediate attention)") - print("-" * 70) - for conflict in high: - print() - print(f"**API**: `{conflict['api_name']}`") - print(f"**Type**: {conflict['type']}") - print(f"**Issue**: {conflict['difference']}") - print(f"**Suggestion**: {conflict['suggestion']}") - - if conflict["docs_info"]: - print("\n**Documented as**:") - print(f" Signature: {conflict['docs_info'].get('raw_signature', 'N/A')}") - - if conflict["code_info"]: - print("\n**Implemented as**:") - params = conflict["code_info"].get("parameters", []) - param_str = ", ".join( - f"{p['name']}: {p.get('type_hint', 'Any')}" for p in params if p["name"] != "self" - ) - print(f" Signature: {conflict['code_info']['name']}({param_str})") - print(f" Return type: {conflict['code_info'].get('return_type', 'None')}") - print( - f" Location: {conflict['code_info'].get('source', 'N/A')}:{conflict['code_info'].get('line', '?')}" - ) - print() - -# Show medium severity -if medium: - print("🟡 **MEDIUM SEVERITY CONFLICTS** (Review recommended)") - print("-" * 70) - for conflict in medium[:3]: # Show first 3 - print() - print(f"**API**: `{conflict['api_name']}`") - print(f"**Type**: {conflict['type']}") - print(f"**Issue**: {conflict['difference']}") - - if conflict["code_info"]: - print(f"**Location**: {conflict['code_info'].get('source', 'N/A')}") - - if len(medium) > 3: - print(f"\n ... and {len(medium) - 3} more medium severity conflicts") - print() - -# Example: How conflicts appear in final skill -print("=" * 70) -print("HOW CONFLICTS APPEAR IN SKILL.MD") -print("=" * 70) -print() - -example_conflict = high[0] if high else medium[0] if medium else conflicts[0] - -print("```markdown") -print("## 🔧 API Reference") -print() -print("### ⚠️ APIs with Conflicts") -print() -print(f"#### `{example_conflict['api_name']}`") -print() -print(f"⚠️ **Conflict**: {example_conflict['difference']}") -print() - -if example_conflict.get("docs_info"): - print("**Documentation says:**") - print("```") - print(example_conflict["docs_info"].get("raw_signature", "N/A")) - print("```") - print() - -if example_conflict.get("code_info"): - print("**Code implementation:**") - print("```python") - params = example_conflict["code_info"].get("parameters", []) - param_strs = [] - for p in params: - if p["name"] == "self": - continue - param_str = p["name"] - if p.get("type_hint"): - param_str += f": {p['type_hint']}" - if p.get("default"): - param_str += f" = {p['default']}" - param_strs.append(param_str) - - sig = f"def {example_conflict['code_info']['name']}({', '.join(param_strs)})" - if example_conflict["code_info"].get("return_type"): - sig += f" -> {example_conflict['code_info']['return_type']}" - - print(sig) - print("```") -print() - -print("*Source: both (conflict)*") -print("```") -print() - -# Key takeaways -print("=" * 70) -print("KEY TAKEAWAYS") -print("=" * 70) -print() - -print("✅ **What the Unified Scraper Does:**") -print(" 1. Extracts APIs from both documentation and code") -print(" 2. Compares them to detect discrepancies") -print(" 3. Classifies conflicts by type and severity") -print(" 4. Provides actionable suggestions") -print(" 5. Shows both versions transparently in the skill") -print() - -print("⚠️ **Common Conflict Types:**") -print(" - **Missing in docs**: Undocumented features in code") -print(" - **Missing in code**: Documented but not implemented") -print(" - **Signature mismatch**: Different parameters/types") -print(" - **Description mismatch**: Different explanations") -print() - -print("🎯 **Value:**") -print(" - Identifies documentation gaps") -print(" - Catches outdated documentation") -print(" - Highlights implementation differences") -print(" - Creates single source of truth showing reality") -print() - -print("=" * 70) -print("END OF DEMO") -print("=" * 70) diff --git a/ruff_errors.txt b/ruff_errors.txt deleted file mode 100644 index cda7875..0000000 --- a/ruff_errors.txt +++ /dev/null @@ -1,439 +0,0 @@ -ARG002 Unused method argument: `config_type` - --> src/skill_seekers/cli/config_extractor.py:294:47 - | -292 | return None -293 | -294 | def _infer_purpose(self, file_path: Path, config_type: str) -> str: - | ^^^^^^^^^^^ -295 | """Infer configuration purpose from file path and name""" -296 | path_lower = str(file_path).lower() - | - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/config_extractor.py:469:17 - | -468 | for node in ast.walk(tree): -469 | / if isinstance(node, ast.Assign): -470 | | # Get variable name and skip private variables -471 | | if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and not node.targets[0].id.startswith("_"): - | |___________________________________________________________________________________________________________________________________^ -472 | key = node.targets[0].id - | -help: Combine `if` statements using `and` - -ARG002 Unused method argument: `node` - --> src/skill_seekers/cli/config_extractor.py:585:41 - | -583 | return "" -584 | -585 | def _extract_python_docstring(self, node: ast.AST) -> str: - | ^^^^ -586 | """Extract docstring/comment for Python node""" -587 | # This is simplified - real implementation would need more context - | - -B904 Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling - --> src/skill_seekers/cli/config_validator.py:60:13 - | -58 | return json.load(f) -59 | except FileNotFoundError: -60 | raise ValueError(f"Config file not found: {self.config_path}") - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -61 | except json.JSONDecodeError as e: -62 | raise ValueError(f"Invalid JSON in config file: {e}") - | - -B904 Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling - --> src/skill_seekers/cli/config_validator.py:62:13 - | -60 | raise ValueError(f"Config file not found: {self.config_path}") -61 | except json.JSONDecodeError as e: -62 | raise ValueError(f"Invalid JSON in config file: {e}") - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -63 | -64 | def _detect_format(self) -> bool: - | - -SIM113 Use `enumerate()` for index variable `completed` in `for` loop - --> src/skill_seekers/cli/doc_scraper.py:1068:25 - | -1066 | logger.warning(" ⚠️ Worker exception: %s", e) -1067 | -1068 | completed += 1 - | ^^^^^^^^^^^^^^ -1069 | -1070 | with self.lock: - | - -B904 Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling - --> src/skill_seekers/cli/github_scraper.py:353:17 - | -351 | except GithubException as e: -352 | if e.status == 404: -353 | raise ValueError(f"Repository not found: {self.repo_name}") - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -354 | raise - | - -E402 Module level import not at top of file - --> src/skill_seekers/cli/llms_txt_downloader.py:5:1 - | -3 | """ABOUTME: Validates markdown content and handles timeouts with exponential backoff""" -4 | -5 | import time - | ^^^^^^^^^^^ -6 | -7 | import requests - | - -E402 Module level import not at top of file - --> src/skill_seekers/cli/llms_txt_downloader.py:7:1 - | -5 | import time -6 | -7 | import requests - | ^^^^^^^^^^^^^^^ - | - -E402 Module level import not at top of file - --> src/skill_seekers/cli/llms_txt_parser.py:5:1 - | -3 | """ABOUTME: Extracts titles, content, code samples, and headings from markdown""" -4 | -5 | import re - | ^^^^^^^^^ -6 | from urllib.parse import urljoin - | - -E402 Module level import not at top of file - --> src/skill_seekers/cli/llms_txt_parser.py:6:1 - | -5 | import re -6 | from urllib.parse import urljoin - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - | - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/pattern_recognizer.py:430:13 - | -428 | # Python: __init__ or __new__ -429 | # Java/C#: private constructor (detected by naming) -430 | / if method.name in ["__new__", "__init__", "constructor"]: -431 | | # Check if it has logic (not just pass) -432 | | if method.docstring or len(method.parameters) > 1: - | |__________________________________________________________________^ -433 | evidence.append(f"Controlled initialization: {method.name}") -434 | confidence += 0.3 - | -help: Combine `if` statements using `and` - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/pattern_recognizer.py:538:13 - | -536 | for method in class_sig.methods: -537 | method_lower = method.name.lower() -538 | / if any(name in method_lower for name in factory_method_names): -539 | | # Check if method returns something (has return type or is not void) -540 | | if method.return_type or "create" in method_lower: - | |__________________________________________________________________^ -541 | return PatternInstance( -542 | pattern_type=self.pattern_type, - | -help: Combine `if` statements using `and` - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/pattern_recognizer.py:916:9 - | -914 | # Check __init__ for composition (takes object parameter) -915 | init_method = next((m for m in class_sig.methods if m.name == "__init__"), None) -916 | / if init_method: -917 | | # Check if takes object parameter (not just self) -918 | | if len(init_method.parameters) > 1: # More than just 'self' - | |_______________________________________________^ -919 | param_names = [p.name for p in init_method.parameters if p.name != "self"] -920 | if any( - | -help: Combine `if` statements using `and` - -F821 Undefined name `l` - --> src/skill_seekers/cli/pdf_extractor_poc.py:302:28 - | -300 | 1 for line in code.split("\n") if line.strip().startswith(("#", "//", "/*", "*", "--")) -301 | ) -302 | total_lines = len([l for line in code.split("\n") if line.strip()]) - | ^ -303 | if total_lines > 0 and comment_lines / total_lines > 0.7: -304 | issues.append("Mostly comments") - | - -F821 Undefined name `l` - --> src/skill_seekers/cli/pdf_extractor_poc.py:330:18 - | -329 | # Factor 3: Number of lines -330 | lines = [l for line in code.split("\n") if line.strip()] - | ^ -331 | if 2 <= len(lines) <= 50: -332 | score += 1.0 - | - -B007 Loop control variable `keywords` not used within loop body - --> src/skill_seekers/cli/pdf_scraper.py:167:30 - | -165 | # Keyword-based categorization -166 | # Initialize categories -167 | for cat_key, keywords in self.categories.items(): - | ^^^^^^^^ -168 | categorized[cat_key] = {"title": cat_key.replace("_", " ").title(), "pages": []} - | -help: Rename unused `keywords` to `_keywords` - -SIM115 Use a context manager for opening files - --> src/skill_seekers/cli/pdf_scraper.py:434:26 - | -432 | f.write("**Generated by Skill Seeker** | PDF Documentation Scraper\n") -433 | -434 | line_count = len(open(filename, encoding="utf-8").read().split("\n")) - | ^^^^ -435 | print(f" Generated: {filename} ({line_count} lines)") - | - -E741 Ambiguous variable name: `l` - --> src/skill_seekers/cli/quality_checker.py:318:44 - | -316 | else: -317 | if links: -318 | internal_links = [l for t, l in links if not l.startswith("http")] - | ^ -319 | if internal_links: -320 | self.report.add_info( - | - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/test_example_extractor.py:364:13 - | -363 | for node in ast.walk(func_node): -364 | / if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call): -365 | | # Check if meaningful instantiation -366 | | if self._is_meaningful_instantiation(node): - | |___________________________________________________________^ -367 | code = ast.unparse(node) - | -help: Combine `if` statements using `and` - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/test_example_extractor.py:412:13 - | -410 | for i, stmt in enumerate(statements): -411 | # Look for method calls -412 | / if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): -413 | | # Check if next statement is an assertion -414 | | if i + 1 < len(statements): - | |___________________________________________^ -415 | next_stmt = statements[i + 1] -416 | if self._is_assertion(next_stmt): - | -help: Combine `if` statements using `and` - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/test_example_extractor.py:460:13 - | -459 | for node in ast.walk(func_node): -460 | / if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict): -461 | | # Must have 2+ keys and be meaningful -462 | | if len(node.value.keys) >= 2: - | |_____________________________________________^ -463 | code = ast.unparse(node) - | -help: Combine `if` statements using `and` - -SIM102 Use a single `if` statement instead of nested `if` statements - --> src/skill_seekers/cli/unified_skill_builder.py:1070:13 - | -1069 | # If no languages from C3.7, try to get from GitHub data -1070 | / if not languages: -1071 | | # github_data already available from method scope -1072 | | if github_data.get("languages"): - | |________________________________________________^ -1073 | # GitHub data has languages as list, convert to dict with count 1 -1074 | languages = dict.fromkeys(github_data["languages"], 1) - | -help: Combine `if` statements using `and` - -ARG001 Unused function argument: `request` - --> src/skill_seekers/mcp/server_fastmcp.py:1159:32 - | -1157 | from starlette.routing import Route -1158 | -1159 | async def health_check(request): - | ^^^^^^^ -1160 | """Health check endpoint.""" -1161 | return JSONResponse( - | - -ARG002 Unused method argument: `tmp_path` - --> tests/test_bootstrap_skill.py:54:56 - | -53 | @pytest.mark.slow -54 | def test_bootstrap_script_runs(self, project_root, tmp_path): - | ^^^^^^^^ -55 | """Test that bootstrap script runs successfully. - | - -B007 Loop control variable `message` not used within loop body - --> tests/test_install_agent.py:374:44 - | -372 | # With force - should succeed -373 | results_with_force = install_to_all_agents(self.skill_dir, force=True) -374 | for _agent_name, (success, message) in results_with_force.items(): - | ^^^^^^^ -375 | assert success is True - | -help: Rename unused `message` to `_message` - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_install_agent.py:418:9 - | -416 | def test_cli_requires_agent_flag(self): -417 | """Test that CLI fails without --agent flag.""" -418 | / with pytest.raises(SystemExit) as exc_info: -419 | | with patch("sys.argv", ["install_agent.py", str(self.skill_dir)]): - | |______________________________________________________________________________^ -420 | main() - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_issue_219_e2e.py:278:9 - | -276 | self.skipTest("anthropic package not installed") -277 | -278 | / with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): -279 | | with patch("skill_seekers.cli.enhance_skill.anthropic.Anthropic") as mock_anthropic: - | |________________________________________________________________________________________________^ -280 | enhancer = SkillEnhancer(self.skill_dir) - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_llms_txt_downloader.py:33:5 - | -31 | downloader = LlmsTxtDownloader("https://example.com/llms.txt", max_retries=2) -32 | -33 | / with patch("requests.get", side_effect=requests.Timeout("Connection timeout")) as mock_get: -34 | | with patch("time.sleep") as mock_sleep: # Mock sleep to speed up test - | |_______________________________________________^ -35 | content = downloader.download() - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_llms_txt_downloader.py:88:5 - | -86 | downloader = LlmsTxtDownloader("https://example.com/llms.txt", max_retries=3) -87 | -88 | / with patch("requests.get", side_effect=requests.Timeout("Connection timeout")): -89 | | with patch("time.sleep") as mock_sleep: - | |_______________________________________________^ -90 | content = downloader.download() - | -help: Combine `with` statements - -F821 Undefined name `l` - --> tests/test_markdown_parsing.py:100:21 - | - 98 | ) - 99 | # Should only include .md links -100 | md_links = [l for line in result["links"] if ".md" in l] - | ^ -101 | self.assertEqual(len(md_links), len(result["links"])) - | - -F821 Undefined name `l` - --> tests/test_markdown_parsing.py:100:63 - | - 98 | ) - 99 | # Should only include .md links -100 | md_links = [l for line in result["links"] if ".md" in l] - | ^ -101 | self.assertEqual(len(md_links), len(result["links"])) - | - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_skip_llms_txt.py:75:17 - | -73 | converter = DocToSkillConverter(config, dry_run=False) -74 | -75 | / with patch.object(converter, "_try_llms_txt", return_value=False) as mock_try: -76 | | with patch.object(converter, "scrape_page"): - | |________________________________________________________________^ -77 | with patch.object(converter, "save_summary"): -78 | converter.scrape_all() - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_skip_llms_txt.py:98:17 - | - 96 | converter = DocToSkillConverter(config, dry_run=False) - 97 | - 98 | / with patch.object(converter, "_try_llms_txt") as mock_try: - 99 | | with patch.object(converter, "scrape_page"): - | |________________________________________________________________^ -100 | with patch.object(converter, "save_summary"): -101 | converter.scrape_all() - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_skip_llms_txt.py:121:17 - | -119 | converter = DocToSkillConverter(config, dry_run=True) -120 | -121 | / with patch.object(converter, "_try_llms_txt") as mock_try: -122 | | with patch.object(converter, "save_summary"): - | |_________________________________________________________________^ -123 | converter.scrape_all() -124 | mock_try.assert_not_called() - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_skip_llms_txt.py:148:17 - | -146 | converter = DocToSkillConverter(config, dry_run=False) -147 | -148 | / with patch.object(converter, "_try_llms_txt", return_value=False) as mock_try: -149 | | with patch.object(converter, "scrape_page_async", return_value=None): - | |_________________________________________________________________________________________^ -150 | with patch.object(converter, "save_summary"): -151 | converter.scrape_all() - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_skip_llms_txt.py:172:17 - | -170 | converter = DocToSkillConverter(config, dry_run=False) -171 | -172 | / with patch.object(converter, "_try_llms_txt") as mock_try: -173 | | with patch.object(converter, "scrape_page_async", return_value=None): - | |_________________________________________________________________________________________^ -174 | with patch.object(converter, "save_summary"): -175 | converter.scrape_all() - | -help: Combine `with` statements - -SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements - --> tests/test_skip_llms_txt.py:304:17 - | -302 | return None -303 | -304 | / with patch.object(converter, "scrape_page", side_effect=mock_scrape): -305 | | with patch.object(converter, "save_summary"): - | |_________________________________________________________________^ -306 | converter.scrape_all() -307 | # Should have attempted to scrape the base URL - | -help: Combine `with` statements - -Found 38 errors. diff --git a/test_api.py b/test_api.py deleted file mode 100644 index c63223d..0000000 --- a/test_api.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -"""Quick test of the config analyzer""" - -import sys - -sys.path.insert(0, "api") - -from pathlib import Path - -from api.config_analyzer import ConfigAnalyzer - -# Initialize analyzer -config_dir = Path("configs") -analyzer = ConfigAnalyzer(config_dir, base_url="https://api.skillseekersweb.com") - -# Test analyzing all configs -print("Testing config analyzer...") -print("-" * 60) - -configs = analyzer.analyze_all_configs() -print(f"\n✅ Found {len(configs)} configs") - -# Show first 3 configs -print("\n📋 Sample Configs:") -for config in configs[:3]: - print(f"\n Name: {config['name']}") - print(f" Type: {config['type']}") - print(f" Category: {config['category']}") - print(f" Tags: {', '.join(config['tags'])}") - print(f" Source: {config['primary_source'][:50]}...") - print(f" File Size: {config['file_size']} bytes") - -# Test category counts -print("\n\n📊 Categories:") -categories = {} -for config in configs: - cat = config["category"] - categories[cat] = categories.get(cat, 0) + 1 - -for cat, count in sorted(categories.items()): - print(f" {cat}: {count} configs") - -print("\n✅ All tests passed!") diff --git a/test_httpx_quick.sh b/test_httpx_quick.sh deleted file mode 100644 index d02c08c..0000000 --- a/test_httpx_quick.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash -# Quick Test - HTTPX Skill (Documentation Only, No GitHub) -# For faster testing without full C3.x analysis - -set -e - -echo "🚀 Quick HTTPX Skill Test (Docs Only)" -echo "======================================" -echo "" - -# Simple config - docs only -CONFIG_FILE="configs/httpx_quick.json" - -# Create quick config (docs only) -cat > "$CONFIG_FILE" << 'EOF' -{ - "name": "httpx_quick", - "description": "HTTPX HTTP client for Python - Quick test version", - "base_url": "https://www.python-httpx.org/", - "selectors": { - "main_content": "article.md-content__inner", - "title": "h1", - "code_blocks": "pre code" - }, - "url_patterns": { - "include": ["/quickstart/", "/advanced/", "/api/"], - "exclude": ["/changelog/", "/contributing/"] - }, - "categories": { - "getting_started": ["quickstart", "install"], - "api": ["api", "reference"], - "advanced": ["async", "http2"] - }, - "rate_limit": 0.3, - "max_pages": 50 -} -EOF - -echo "✓ Created quick config (docs only, max 50 pages)" -echo "" - -# Run scraper -echo "🔍 Scraping documentation..." -START_TIME=$(date +%s) - -skill-seekers scrape --config "$CONFIG_FILE" --output output/httpx_quick - -END_TIME=$(date +%s) -DURATION=$((END_TIME - START_TIME)) - -echo "" -echo "✅ Complete in ${DURATION}s" -echo "" -echo "📊 Results:" -echo " Output: output/httpx_quick/" -echo " SKILL.md: $(wc -l < output/httpx_quick/SKILL.md) lines" -echo " References: $(find output/httpx_quick/references -name "*.md" 2>/dev/null | wc -l) files" -echo "" -echo "🔍 Preview:" -head -30 output/httpx_quick/SKILL.md -echo "" -echo "📦 Next: skill-seekers package output/httpx_quick/" diff --git a/test_httpx_skill.sh b/test_httpx_skill.sh deleted file mode 100755 index 3b11c10..0000000 --- a/test_httpx_skill.sh +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/bash -# Test Script for HTTPX Skill Generation -# Tests all C3.x features and experimental capabilities - -set -e # Exit on error - -echo "==================================" -echo "🧪 HTTPX Skill Generation Test" -echo "==================================" -echo "" -echo "This script will test:" -echo " ✓ Unified multi-source scraping (docs + GitHub)" -echo " ✓ Three-stream GitHub analysis" -echo " ✓ C3.x features (patterns, tests, guides, configs, architecture)" -echo " ✓ AI enhancement (LOCAL mode)" -echo " ✓ Quality metrics" -echo " ✓ Packaging" -echo "" -read -p "Press Enter to start (or Ctrl+C to cancel)..." - -# Configuration -CONFIG_FILE="configs/httpx_comprehensive.json" -OUTPUT_DIR="output/httpx" -SKILL_NAME="httpx" - -# Step 1: Clean previous output -echo "" -echo "📁 Step 1: Cleaning previous output..." -if [ -d "$OUTPUT_DIR" ]; then - rm -rf "$OUTPUT_DIR" - echo " ✓ Cleaned $OUTPUT_DIR" -fi - -# Step 2: Validate config -echo "" -echo "🔍 Step 2: Validating configuration..." -if [ ! -f "$CONFIG_FILE" ]; then - echo " ✗ Config file not found: $CONFIG_FILE" - exit 1 -fi -echo " ✓ Config file found" - -# Show config summary -echo "" -echo "📋 Config Summary:" -echo " Name: httpx" -echo " Sources: Documentation + GitHub (C3.x analysis)" -echo " Analysis Depth: c3x (full analysis)" -echo " Features: API ref, patterns, test examples, guides, architecture" -echo "" - -# Step 3: Run unified scraper -echo "🚀 Step 3: Running unified scraper (this will take 10-20 minutes)..." -echo " This includes:" -echo " - Documentation scraping" -echo " - GitHub repo cloning and analysis" -echo " - C3.1: Design pattern detection" -echo " - C3.2: Test example extraction" -echo " - C3.3: How-to guide generation" -echo " - C3.4: Configuration extraction" -echo " - C3.5: Architectural overview" -echo " - C3.6: AI enhancement preparation" -echo "" - -START_TIME=$(date +%s) - -# Run unified scraper with all features -python -m skill_seekers.cli.unified_scraper \ - --config "$CONFIG_FILE" \ - --output "$OUTPUT_DIR" \ - --verbose - -SCRAPE_END_TIME=$(date +%s) -SCRAPE_DURATION=$((SCRAPE_END_TIME - START_TIME)) - -echo "" -echo " ✓ Scraping completed in ${SCRAPE_DURATION}s" - -# Step 4: Show analysis results -echo "" -echo "📊 Step 4: Analysis Results Summary" -echo "" - -# Check for C3.1 patterns -if [ -f "$OUTPUT_DIR/c3_1_patterns.json" ]; then - PATTERN_COUNT=$(python3 -c "import json; print(len(json.load(open('$OUTPUT_DIR/c3_1_patterns.json', 'r'))))") - echo " C3.1 Design Patterns: $PATTERN_COUNT patterns detected" -fi - -# Check for C3.2 test examples -if [ -f "$OUTPUT_DIR/c3_2_test_examples.json" ]; then - EXAMPLE_COUNT=$(python3 -c "import json; data=json.load(open('$OUTPUT_DIR/c3_2_test_examples.json', 'r')); print(len(data.get('examples', [])))") - echo " C3.2 Test Examples: $EXAMPLE_COUNT examples extracted" -fi - -# Check for C3.3 guides -GUIDE_COUNT=0 -if [ -d "$OUTPUT_DIR/guides" ]; then - GUIDE_COUNT=$(find "$OUTPUT_DIR/guides" -name "*.md" | wc -l) - echo " C3.3 How-To Guides: $GUIDE_COUNT guides generated" -fi - -# Check for C3.4 configs -if [ -f "$OUTPUT_DIR/c3_4_configs.json" ]; then - CONFIG_COUNT=$(python3 -c "import json; print(len(json.load(open('$OUTPUT_DIR/c3_4_configs.json', 'r'))))") - echo " C3.4 Configurations: $CONFIG_COUNT config patterns found" -fi - -# Check for C3.5 architecture -if [ -f "$OUTPUT_DIR/c3_5_architecture.md" ]; then - ARCH_LINES=$(wc -l < "$OUTPUT_DIR/c3_5_architecture.md") - echo " C3.5 Architecture: Overview generated ($ARCH_LINES lines)" -fi - -# Check for API reference -if [ -f "$OUTPUT_DIR/api_reference.md" ]; then - API_LINES=$(wc -l < "$OUTPUT_DIR/api_reference.md") - echo " API Reference: Generated ($API_LINES lines)" -fi - -# Check for dependency graph -if [ -f "$OUTPUT_DIR/dependency_graph.json" ]; then - echo " Dependency Graph: Generated" -fi - -# Check SKILL.md -if [ -f "$OUTPUT_DIR/SKILL.md" ]; then - SKILL_LINES=$(wc -l < "$OUTPUT_DIR/SKILL.md") - echo " SKILL.md: Generated ($SKILL_LINES lines)" -fi - -echo "" - -# Step 5: Quality assessment (pre-enhancement) -echo "📈 Step 5: Quality Assessment (Pre-Enhancement)" -echo "" - -# Count references -if [ -d "$OUTPUT_DIR/references" ]; then - REF_COUNT=$(find "$OUTPUT_DIR/references" -name "*.md" | wc -l) - TOTAL_REF_LINES=$(find "$OUTPUT_DIR/references" -name "*.md" -exec wc -l {} + | tail -1 | awk '{print $1}') - echo " Reference Files: $REF_COUNT files ($TOTAL_REF_LINES total lines)" -fi - -# Estimate quality score (basic heuristics) -QUALITY_SCORE=3 # Base score - -# Add points for features -[ -f "$OUTPUT_DIR/c3_1_patterns.json" ] && QUALITY_SCORE=$((QUALITY_SCORE + 1)) -[ -f "$OUTPUT_DIR/c3_2_test_examples.json" ] && QUALITY_SCORE=$((QUALITY_SCORE + 1)) -[ $GUIDE_COUNT -gt 0 ] && QUALITY_SCORE=$((QUALITY_SCORE + 1)) -[ -f "$OUTPUT_DIR/c3_4_configs.json" ] && QUALITY_SCORE=$((QUALITY_SCORE + 1)) -[ -f "$OUTPUT_DIR/c3_5_architecture.md" ] && QUALITY_SCORE=$((QUALITY_SCORE + 1)) -[ -f "$OUTPUT_DIR/api_reference.md" ] && QUALITY_SCORE=$((QUALITY_SCORE + 1)) - -echo " Estimated Quality (Pre-Enhancement): $QUALITY_SCORE/10" -echo "" - -# Step 6: AI Enhancement (LOCAL mode) -echo "🤖 Step 6: AI Enhancement (LOCAL mode)" -echo "" -echo " This will use Claude Code to enhance the skill" -echo " Expected improvement: $QUALITY_SCORE/10 → 8-9/10" -echo "" - -read -p " Run AI enhancement? (y/n) [y]: " RUN_ENHANCEMENT -RUN_ENHANCEMENT=${RUN_ENHANCEMENT:-y} - -if [ "$RUN_ENHANCEMENT" = "y" ]; then - echo " Running LOCAL enhancement (force mode ON)..." - - python -m skill_seekers.cli.enhance_skill_local \ - "$OUTPUT_DIR" \ - --mode LOCAL \ - --force - - ENHANCE_END_TIME=$(date +%s) - ENHANCE_DURATION=$((ENHANCE_END_TIME - SCRAPE_END_TIME)) - - echo "" - echo " ✓ Enhancement completed in ${ENHANCE_DURATION}s" - - # Post-enhancement quality - POST_QUALITY=9 # Assume significant improvement - echo " Estimated Quality (Post-Enhancement): $POST_QUALITY/10" -else - echo " Skipping enhancement" -fi - -echo "" - -# Step 7: Package skill -echo "📦 Step 7: Packaging Skill" -echo "" - -python -m skill_seekers.cli.package_skill \ - "$OUTPUT_DIR" \ - --target claude \ - --output output/ - -PACKAGE_FILE="output/${SKILL_NAME}.zip" - -if [ -f "$PACKAGE_FILE" ]; then - PACKAGE_SIZE=$(du -h "$PACKAGE_FILE" | cut -f1) - echo " ✓ Package created: $PACKAGE_FILE ($PACKAGE_SIZE)" -else - echo " ✗ Package creation failed" - exit 1 -fi - -echo "" - -# Step 8: Final Summary -END_TIME=$(date +%s) -TOTAL_DURATION=$((END_TIME - START_TIME)) -MINUTES=$((TOTAL_DURATION / 60)) -SECONDS=$((TOTAL_DURATION % 60)) - -echo "==================================" -echo "✅ Test Complete!" -echo "==================================" -echo "" -echo "📊 Summary:" -echo " Total Time: ${MINUTES}m ${SECONDS}s" -echo " Output Directory: $OUTPUT_DIR" -echo " Package: $PACKAGE_FILE ($PACKAGE_SIZE)" -echo "" -echo "📈 Features Tested:" -echo " ✓ Multi-source scraping (docs + GitHub)" -echo " ✓ Three-stream analysis" -echo " ✓ C3.1 Pattern detection" -echo " ✓ C3.2 Test examples" -echo " ✓ C3.3 How-to guides" -echo " ✓ C3.4 Config extraction" -echo " ✓ C3.5 Architecture overview" -if [ "$RUN_ENHANCEMENT" = "y" ]; then - echo " ✓ AI enhancement (LOCAL)" -fi -echo " ✓ Packaging" -echo "" -echo "🔍 Next Steps:" -echo " 1. Review SKILL.md: cat $OUTPUT_DIR/SKILL.md | head -50" -echo " 2. Check patterns: cat $OUTPUT_DIR/c3_1_patterns.json | jq '.'" -echo " 3. Review guides: ls $OUTPUT_DIR/guides/" -echo " 4. Upload to Claude: skill-seekers upload $PACKAGE_FILE" -echo "" -echo "📁 File Structure:" -tree -L 2 "$OUTPUT_DIR" | head -30 -echo "" diff --git a/test_results.log b/test_results.log deleted file mode 100644 index 9f11615..0000000 --- a/test_results.log +++ /dev/null @@ -1,65 +0,0 @@ -============================= test session starts ============================== -platform linux -- Python 3.14.2, pytest-8.4.2, pluggy-1.6.0 -- /usr/bin/python -cachedir: .pytest_cache -hypothesis profile 'default' -rootdir: /mnt/1ece809a-2821-4f10-aecb-fcdf34760c0b/Git/Skill_Seekers -configfile: pyproject.toml -plugins: anyio-4.12.1, hypothesis-6.150.0, cov-6.1.1, typeguard-4.4.4 -collecting ... collected 1940 items / 1 error - -==================================== ERRORS ==================================== -_________________ ERROR collecting tests/test_preset_system.py _________________ -ImportError while importing test module '/mnt/1ece809a-2821-4f10-aecb-fcdf34760c0b/Git/Skill_Seekers/tests/test_preset_system.py'. -Hint: make sure your test modules/packages have valid Python names. -Traceback: -/usr/lib/python3.14/site-packages/_pytest/python.py:498: in importtestmodule - mod = import_path( -/usr/lib/python3.14/site-packages/_pytest/pathlib.py:587: in import_path - importlib.import_module(module_name) -/usr/lib/python3.14/importlib/__init__.py:88: in import_module - return _bootstrap._gcd_import(name[level:], package, level) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -:1398: in _gcd_import - ??? -:1371: in _find_and_load - ??? -:1342: in _find_and_load_unlocked - ??? -:938: in _load_unlocked - ??? -/usr/lib/python3.14/site-packages/_pytest/assertion/rewrite.py:186: in exec_module - exec(co, module.__dict__) -tests/test_preset_system.py:9: in - from skill_seekers.cli.presets import PresetManager, PRESETS, AnalysisPreset -E ImportError: cannot import name 'PresetManager' from 'skill_seekers.cli.presets' (/mnt/1ece809a-2821-4f10-aecb-fcdf34760c0b/Git/Skill_Seekers/src/skill_seekers/cli/presets/__init__.py) -=============================== warnings summary =============================== -../../../../usr/lib/python3.14/site-packages/_pytest/config/__init__.py:1474 - /usr/lib/python3.14/site-packages/_pytest/config/__init__.py:1474: PytestConfigWarning: Unknown config option: asyncio_default_fixture_loop_scope - - self._warn_or_fail_if_strict(f"Unknown config option: {key}\n") - -../../../../usr/lib/python3.14/site-packages/_pytest/config/__init__.py:1474 - /usr/lib/python3.14/site-packages/_pytest/config/__init__.py:1474: PytestConfigWarning: Unknown config option: asyncio_mode - - self._warn_or_fail_if_strict(f"Unknown config option: {key}\n") - -tests/test_mcp_fastmcp.py:21 - /mnt/1ece809a-2821-4f10-aecb-fcdf34760c0b/Git/Skill_Seekers/tests/test_mcp_fastmcp.py:21: DeprecationWarning: The legacy server.py is deprecated and will be removed in v3.0.0. Please update your MCP configuration to use 'server_fastmcp' instead: - OLD: python -m skill_seekers.mcp.server - NEW: python -m skill_seekers.mcp.server_fastmcp - The new server provides the same functionality with improved performance. - from mcp.server import FastMCP - -src/skill_seekers/cli/test_example_extractor.py:50 - /mnt/1ece809a-2821-4f10-aecb-fcdf34760c0b/Git/Skill_Seekers/src/skill_seekers/cli/test_example_extractor.py:50: PytestCollectionWarning: cannot collect test class 'TestExample' because it has a __init__ constructor (from: tests/test_test_example_extractor.py) - @dataclass - -src/skill_seekers/cli/test_example_extractor.py:920 - /mnt/1ece809a-2821-4f10-aecb-fcdf34760c0b/Git/Skill_Seekers/src/skill_seekers/cli/test_example_extractor.py:920: PytestCollectionWarning: cannot collect test class 'TestExampleExtractor' because it has a __init__ constructor (from: tests/test_test_example_extractor.py) - class TestExampleExtractor: - --- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html -=========================== short test summary info ============================ -ERROR tests/test_preset_system.py -!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! -========================= 5 warnings, 1 error in 1.11s ========================= diff --git a/test_week2_features.py b/test_week2_features.py deleted file mode 100755 index c42a98b..0000000 --- a/test_week2_features.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick validation script for Week 2 features. -Run this to verify all new capabilities are working. -""" - -import sys -from pathlib import Path -import tempfile -import shutil - -# Add src to path for testing -sys.path.insert(0, str(Path(__file__).parent / "src")) - -def test_vector_databases(): - """Test all 4 vector database adaptors.""" - from skill_seekers.cli.adaptors import get_adaptor - import json - - print("📦 Testing vector database adaptors...") - - # Create minimal test data - with tempfile.TemporaryDirectory() as tmpdir: - skill_dir = Path(tmpdir) / 'test_skill' - skill_dir.mkdir() - (skill_dir / 'SKILL.md').write_text('# Test\n\nContent.') - - targets = ['weaviate', 'chroma', 'faiss', 'qdrant'] - for target in targets: - try: - adaptor = get_adaptor(target) - package_path = adaptor.package(skill_dir, Path(tmpdir)) - assert package_path.exists(), f"{target} package not created" - print(f" ✅ {target.capitalize()}") - except Exception as e: - print(f" ❌ {target.capitalize()}: {e}") - return False - - return True - - -def test_streaming(): - """Test streaming ingestion.""" - from skill_seekers.cli.streaming_ingest import StreamingIngester - - print("📈 Testing streaming ingestion...") - - try: - large_content = "Test content. " * 500 - ingester = StreamingIngester(chunk_size=1000, chunk_overlap=100) - - chunks = list(ingester.chunk_document( - large_content, - {'source': 'test'} - )) - - assert len(chunks) > 5, "Expected multiple chunks" - assert all(len(chunk[0]) <= 1100 for chunk in chunks), "Chunk too large" - - print(f" ✅ Chunked {len(large_content)} chars into {len(chunks)} chunks") - return True - except Exception as e: - print(f" ❌ Streaming test failed: {e}") - return False - - -def test_incremental(): - """Test incremental updates.""" - from skill_seekers.cli.incremental_updater import IncrementalUpdater - import time - - print("⚡ Testing incremental updates...") - - try: - with tempfile.TemporaryDirectory() as tmpdir: - skill_dir = Path(tmpdir) / 'test_skill' - skill_dir.mkdir() - - # Create references directory - refs_dir = skill_dir / 'references' - refs_dir.mkdir() - - # Create initial version - (skill_dir / 'SKILL.md').write_text('# V1\n\nInitial content.') - (refs_dir / 'guide.md').write_text('# Guide\n\nInitial guide.') - - updater = IncrementalUpdater(skill_dir) - updater.current_versions = updater._scan_documents() # Scan before saving - updater.save_current_versions() - - # Small delay to ensure different timestamps - time.sleep(0.01) - - # Make changes - (skill_dir / 'SKILL.md').write_text('# V2\n\nUpdated content.') - (refs_dir / 'new_ref.md').write_text('# New Reference\n\nNew documentation.') - - # Detect changes (loads previous versions internally) - updater2 = IncrementalUpdater(skill_dir) - changes = updater2.detect_changes() - - # Verify we have changes - assert changes.has_changes, "No changes detected" - assert len(changes.added) > 0, f"New file not detected" - assert len(changes.modified) > 0, f"Modified file not detected" - - print(f" ✅ Detected {len(changes.added)} added, {len(changes.modified)} modified") - return True - except Exception as e: - print(f" ❌ Incremental test failed: {e}") - return False - - -def test_multilang(): - """Test multi-language support.""" - from skill_seekers.cli.multilang_support import ( - LanguageDetector, - MultiLanguageManager - ) - - print("🌍 Testing multi-language support...") - - try: - detector = LanguageDetector() - - # Test language detection - en_text = "This is an English document about programming." - es_text = "Este es un documento en español sobre programación." - - en_detected = detector.detect(en_text) - es_detected = detector.detect(es_text) - - assert en_detected.code == 'en', f"Expected 'en', got '{en_detected.code}'" - assert es_detected.code == 'es', f"Expected 'es', got '{es_detected.code}'" - - # Test filename detection - assert detector.detect_from_filename('README.en.md') == 'en' - assert detector.detect_from_filename('guide.es.md') == 'es' - - # Test manager - manager = MultiLanguageManager() - manager.add_document('doc.md', en_text, {}) - manager.add_document('doc.es.md', es_text, {}) - - languages = manager.get_languages() - assert 'en' in languages and 'es' in languages - - print(f" ✅ Detected {len(languages)} languages") - return True - except Exception as e: - print(f" ❌ Multi-language test failed: {e}") - return False - - -def test_embeddings(): - """Test embedding pipeline.""" - from skill_seekers.cli.embedding_pipeline import ( - EmbeddingPipeline, - EmbeddingConfig - ) - - print("💰 Testing embedding pipeline...") - - try: - with tempfile.TemporaryDirectory() as tmpdir: - config = EmbeddingConfig( - provider='local', - model='test-model', - dimension=64, - batch_size=10, - cache_dir=Path(tmpdir) - ) - - pipeline = EmbeddingPipeline(config) - - # Test generation (first run) - texts = ['doc1', 'doc2', 'doc3'] - result1 = pipeline.generate_batch(texts, show_progress=False) - - assert len(result1.embeddings) == 3, "Expected 3 embeddings" - assert len(result1.embeddings[0]) == 64, "Wrong dimension" - assert result1.generated_count == 3, "Should generate all on first run" - - # Test caching (second run with same texts) - result2 = pipeline.generate_batch(texts, show_progress=False) - - assert result2.cached_count == 3, "Caching not working" - assert result2.generated_count == 0, "Should not generate on second run" - - print(f" ✅ First run: {result1.generated_count} generated") - print(f" ✅ Second run: {result2.cached_count} cached (100% cache hit)") - return True - except Exception as e: - print(f" ❌ Embedding test failed: {e}") - return False - - -def test_quality(): - """Test quality metrics.""" - from skill_seekers.cli.quality_metrics import QualityAnalyzer - - print("📊 Testing quality metrics...") - - try: - with tempfile.TemporaryDirectory() as tmpdir: - skill_dir = Path(tmpdir) / 'test_skill' - skill_dir.mkdir() - - # Create test skill - (skill_dir / 'SKILL.md').write_text('# Test Skill\n\nContent.') - - refs_dir = skill_dir / 'references' - refs_dir.mkdir() - (refs_dir / 'guide.md').write_text('# Guide\n\nGuide content.') - - # Analyze quality - analyzer = QualityAnalyzer(skill_dir) - report = analyzer.generate_report() - - assert report.overall_score.total_score > 0, "Score is 0" - assert report.overall_score.grade in ['A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F'] - assert len(report.metrics) == 4, "Expected 4 metrics" - - print(f" ✅ Grade: {report.overall_score.grade} ({report.overall_score.total_score:.1f}/100)") - return True - except Exception as e: - print(f" ❌ Quality test failed: {e}") - return False - - -def main(): - """Run all tests.""" - print("=" * 70) - print("🧪 Week 2 Feature Validation") - print("=" * 70) - print() - - tests = [ - ("Vector Databases", test_vector_databases), - ("Streaming Ingestion", test_streaming), - ("Incremental Updates", test_incremental), - ("Multi-Language", test_multilang), - ("Embedding Pipeline", test_embeddings), - ("Quality Metrics", test_quality), - ] - - passed = 0 - failed = 0 - - for name, test_func in tests: - try: - if test_func(): - passed += 1 - else: - failed += 1 - except Exception as e: - print(f" ❌ Unexpected error: {e}") - failed += 1 - print() - - print("=" * 70) - print(f"📊 Results: {passed}/{len(tests)} passed") - - if failed == 0: - print("🎉 All Week 2 features validated successfully!") - return 0 - else: - print(f"⚠️ {failed} test(s) failed") - return 1 - - -if __name__ == '__main__': - sys.exit(main()) From 30b877274bde47b7be802b944608915995a45f94 Mon Sep 17 00:00:00 2001 From: yusyus Date: Sun, 22 Mar 2026 12:24:43 +0300 Subject: [PATCH 13/21] docs: add full UML architecture with 14 class diagrams synced from source code - 14 StarUML diagrams covering all 13 modules (8 core + 5 utility) - ~200 classes with operations, attributes, and documentation from actual source - Package overview with 25 verified inter-module dependencies - Exported PNG diagrams in Docs/UML/exports/ - Architecture.md with embedded diagram descriptions - CLAUDE.md updated with architecture reference Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 59 +- Docs/Architecture.md | 114 + Docs/UML/exports/00_package_overview.png | Bin 0 -> 110434 bytes Docs/UML/exports/01_cli_core.png | Bin 0 -> 44324 bytes Docs/UML/exports/02_scrapers.png | Bin 0 -> 497621 bytes Docs/UML/exports/03_adaptors.png | Bin 0 -> 794292 bytes Docs/UML/exports/04_analysis.png | Bin 0 -> 449236 bytes Docs/UML/exports/05_enhancement.png | Bin 0 -> 344486 bytes Docs/UML/exports/06_packaging.png | Bin 0 -> 131849 bytes Docs/UML/exports/07_mcp_server.png | Bin 0 -> 502436 bytes Docs/UML/exports/08_sync.png | Bin 0 -> 219012 bytes Docs/UML/exports/09_parsers.png | Bin 0 -> 224156 bytes Docs/UML/exports/10_storage.png | Bin 0 -> 267650 bytes Docs/UML/exports/11_embedding.png | Bin 0 -> 161042 bytes Docs/UML/exports/12_benchmark.png | Bin 0 -> 130307 bytes Docs/UML/exports/13_utilities.png | Bin 0 -> 228596 bytes Docs/UML/skill_seekers.mdj | 95775 +++++++++++++++++++++ 17 files changed, 95922 insertions(+), 26 deletions(-) create mode 100644 Docs/Architecture.md create mode 100644 Docs/UML/exports/00_package_overview.png create mode 100644 Docs/UML/exports/01_cli_core.png create mode 100644 Docs/UML/exports/02_scrapers.png create mode 100644 Docs/UML/exports/03_adaptors.png create mode 100644 Docs/UML/exports/04_analysis.png create mode 100644 Docs/UML/exports/05_enhancement.png create mode 100644 Docs/UML/exports/06_packaging.png create mode 100644 Docs/UML/exports/07_mcp_server.png create mode 100644 Docs/UML/exports/08_sync.png create mode 100644 Docs/UML/exports/09_parsers.png create mode 100644 Docs/UML/exports/10_storage.png create mode 100644 Docs/UML/exports/11_embedding.png create mode 100644 Docs/UML/exports/12_benchmark.png create mode 100644 Docs/UML/exports/13_utilities.png create mode 100644 Docs/UML/skill_seekers.mdj diff --git a/CLAUDE.md b/CLAUDE.md index fad3866..e16e718 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Version:** 3.3.0 | **Python:** 3.10+ | **Website:** https://skillseekersweb.com/ +**Architecture:** See `Docs/Architecture.md` for UML diagrams and module overview. StarUML project at `Docs/UML/skill_seekers.mdj`. + ## Essential Commands ```bash @@ -57,7 +59,8 @@ Entry point `src/skill_seekers/cli/main.py` maps subcommands to modules. The `cr ``` skill-seekers create # Auto-detect: URL, owner/repo, ./path, file.pdf, etc. skill-seekers [options] # Direct: scrape, github, pdf, word, epub, video, jupyter, html, openapi, asciidoc, pptx, rss, manpage, confluence, notion, chat -skill-seekers package # Package for platform (--target claude/gemini/openai/markdown/minimax/opencode/kimi/deepseek/qwen/openrouter/together/fireworks, --format langchain/llama-index/haystack/chroma/faiss/weaviate/qdrant) +skill-seekers analyze # Analyze local codebase (C3.x pipeline) +skill-seekers package # Package for platform (--target claude/gemini/openai/markdown/minimax/opencode/kimi/deepseek/qwen/openrouter/together/fireworks, --format langchain/llama-index/haystack/chroma/faiss/weaviate/qdrant/pinecone) ``` ### Data Flow (5 phases) @@ -70,33 +73,37 @@ skill-seekers package # Package for platform (--target claude/gemini ### Platform Adaptor Pattern (Strategy + Factory) +Factory: `get_adaptor(platform, config)` in `adaptors/__init__.py` returns a `SkillAdaptor` instance. Base class `SkillAdaptor` + `SkillMetadata` in `adaptors/base.py`. + ``` src/skill_seekers/cli/adaptors/ -├── __init__.py # Factory: get_adaptor(target=..., format=...) -├── base_adaptor.py # Abstract base: package(), upload(), enhance(), export() -├── claude_adaptor.py # --target claude -├── gemini_adaptor.py # --target gemini -├── openai_adaptor.py # --target openai -├── markdown_adaptor.py # --target markdown -├── minimax_adaptor.py # --target minimax -├── opencode_adaptor.py # --target opencode -├── kimi_adaptor.py # --target kimi -├── deepseek_adaptor.py # --target deepseek -├── qwen_adaptor.py # --target qwen -├── openrouter_adaptor.py # --target openrouter -├── together_adaptor.py # --target together -├── fireworks_adaptor.py # --target fireworks -├── langchain.py # --format langchain -├── llama_index.py # --format llama-index -├── haystack.py # --format haystack -├── chroma.py # --format chroma -├── faiss_helpers.py # --format faiss -├── qdrant.py # --format qdrant -├── weaviate.py # --format weaviate -└── streaming_adaptor.py # --format streaming +├── __init__.py # Factory: get_adaptor(platform, config), ADAPTORS registry +├── base.py # Abstract base: SkillAdaptor, SkillMetadata +├── openai_compatible.py # Shared base for OpenAI-compatible platforms +├── claude.py # --target claude +├── gemini.py # --target gemini +├── openai.py # --target openai +├── markdown.py # --target markdown +├── minimax.py # --target minimax +├── opencode.py # --target opencode +├── kimi.py # --target kimi +├── deepseek.py # --target deepseek +├── qwen.py # --target qwen +├── openrouter.py # --target openrouter +├── together.py # --target together +├── fireworks.py # --target fireworks +├── langchain.py # --format langchain +├── llama_index.py # --format llama-index +├── haystack.py # --format haystack +├── chroma.py # --format chroma +├── faiss_helpers.py # --format faiss +├── qdrant.py # --format qdrant +├── weaviate.py # --format weaviate +├── pinecone_adaptor.py # --format pinecone +└── streaming_adaptor.py # --format streaming ``` -`--target` = LLM platforms, `--format` = RAG/vector DBs. +`--target` = LLM platforms, `--format` = RAG/vector DBs. All adaptors are imported with `try/except ImportError` so missing optional deps don't break the registry. ### 17 Source Type Scrapers @@ -208,8 +215,8 @@ GITHUB_TOKEN=ghp_... # Higher GitHub rate limits ## Adding New Features ### New platform adaptor -1. Create `src/skill_seekers/cli/adaptors/{platform}_adaptor.py` inheriting `BaseAdaptor` -2. Register in `adaptors/__init__.py` factory +1. Create `src/skill_seekers/cli/adaptors/{platform}.py` inheriting `SkillAdaptor` from `base.py` +2. Register in `adaptors/__init__.py` (add try/except import + add to `ADAPTORS` dict) 3. Add optional dep to `pyproject.toml` 4. Add tests in `tests/` diff --git a/Docs/Architecture.md b/Docs/Architecture.md new file mode 100644 index 0000000..ecd4784 --- /dev/null +++ b/Docs/Architecture.md @@ -0,0 +1,114 @@ +# Skill Seekers Architecture + +> Generated 2026-03-22 | StarUML project: `Docs/UML/skill_seekers.mdj` + +## Overview + +Skill Seekers converts documentation from 17 source types into production-ready formats for 24+ AI platforms. The architecture follows a layered module design with 8 core modules and 5 utility modules. + +## Package Diagram + +![Package Overview](UML/exports/00_package_overview.png) + +**Core Modules** (upper area): +- **CLICore** -- Git-style command dispatcher, entry point for all `skill-seekers` commands +- **Scrapers** -- 17 source-type extractors (web, GitHub, PDF, Word, EPUB, video, etc.) +- **Adaptors** -- Strategy+Factory pattern for 20+ output platforms (Claude, Gemini, OpenAI, RAG frameworks) +- **Analysis** -- C3.x codebase analysis pipeline (AST parsing, 10 GoF pattern detectors, guide builders) +- **Enhancement** -- AI-powered skill improvement (API mode + LOCAL mode, --enhance-level 0-3) +- **Packaging** -- Package, upload, and install skills to AI agent directories +- **MCP** -- FastMCP server exposing 34 tools via stdio/HTTP transport +- **Sync** -- Documentation change detection and re-scraping triggers + +**Utility Modules** (lower area): +- **Parsers** -- CLI argument parsers (30+ SubcommandParser subclasses) +- **Storage** -- Cloud storage abstraction (S3, GCS, Azure) +- **Embedding** -- Multi-provider vector embedding generation +- **Benchmark** -- Performance measurement framework +- **Utilities** -- Shared helpers (LanguageDetector, RAGChunker, MarkdownCleaner, etc.) + +## Core Module Diagrams + +### CLICore +![CLICore](UML/exports/01_cli_core.png) + +Entry point: `skill-seekers` CLI. `CLIDispatcher` maps subcommands to modules via `COMMAND_MODULES` dict. `CreateCommand` auto-detects source type via `SourceDetector`. + +### Scrapers +![Scrapers](UML/exports/02_scrapers.png) + +18 scraper classes implementing `IScraper`. Each has a `main()` entry point. Notable: `GitHubScraper` (3-stream fetcher) + `GitHubToSkillConverter` (builder), `UnifiedScraper` (multi-source orchestrator). + +### Adaptors +![Adaptors](UML/exports/03_adaptors.png) + +`SkillAdaptor` ABC with 3 abstract methods: `format_skill_md()`, `package()`, `upload()`. Two-level hierarchy: direct subclasses (Claude, Gemini, OpenAI, Markdown, OpenCode, RAG adaptors) and `OpenAICompatibleAdaptor` intermediate (MiniMax, Kimi, DeepSeek, Qwen, OpenRouter, Together, Fireworks). + +### Analysis (C3.x Pipeline) +![Analysis](UML/exports/04_analysis.png) + +`UnifiedCodebaseAnalyzer` controller orchestrates: `CodeAnalyzer` (AST, 9 languages), `PatternRecognizer` (10 GoF detectors via `BasePatternDetector`), `TestExampleExtractor`, `HowToGuideBuilder`, `ConfigExtractor`, `SignalFlowAnalyzer`, `DependencyAnalyzer`, `ArchitecturalPatternDetector`. + +### Enhancement +![Enhancement](UML/exports/05_enhancement.png) + +Two enhancement hierarchies: `AIEnhancer` (API mode, Claude API calls) and `UnifiedEnhancer` (C3.x pipeline enhancers). Each has specialized subclasses for patterns, test examples, guides, and configs. `WorkflowEngine` orchestrates multi-stage `EnhancementWorkflow`. + +### Packaging +![Packaging](UML/exports/06_packaging.png) + +`PackageSkill` delegates to adaptors for format-specific packaging. `UploadSkill` handles platform API uploads. `InstallSkill`/`InstallAgent` install to AI agent directories. `OpenCodeSkillSplitter` handles large file splitting. + +### MCP Server +![MCP Server](UML/exports/07_mcp_server.png) + +`SkillSeekerMCPServer` (FastMCP) with 34 tools in 8 categories. Supporting classes: `SourceManager` (config CRUD), `AgentDetector` (environment detection), `GitConfigRepo` (community configs). + +### Sync +![Sync](UML/exports/08_sync.png) + +`SyncMonitor` controller schedules periodic checks via `ChangeDetector` (SHA-256 hashing, HTTP headers, content diffing). `Notifier` sends alerts when changes are found. Pydantic models: `PageChange`, `ChangeReport`, `SyncConfig`, `SyncState`. + +## Utility Module Diagrams + +### Parsers +![Parsers](UML/exports/09_parsers.png) + +`SubcommandParser` ABC with 27 subclasses -- one per CLI subcommand (Create, Scrape, GitHub, PDF, Word, EPUB, Video, Unified, Analyze, Enhance, Package, Upload, Jupyter, HTML, OpenAPI, AsciiDoc, Pptx, RSS, ManPage, Confluence, Notion, Chat, Config, Estimate, Install, Stream, Quality, SyncConfig). + +### Storage +![Storage](UML/exports/10_storage.png) + +`BaseStorageAdaptor` ABC with `S3StorageAdaptor`, `GCSStorageAdaptor`, `AzureStorageAdaptor`. `StorageObject` dataclass for file metadata. + +### Embedding +![Embedding](UML/exports/11_embedding.png) + +`EmbeddingGenerator` (multi-provider: OpenAI, Sentence Transformers, Voyage AI). `EmbeddingPipeline` coordinates provider, caching, and cost tracking. `EmbeddingProvider` ABC with OpenAI and Local implementations. + +### Benchmark +![Benchmark](UML/exports/12_benchmark.png) + +`BenchmarkRunner` orchestrates `Benchmark` instances. `BenchmarkResult` collects timings/memory/metrics and produces `BenchmarkReport`. Supporting data types: `Metric`, `TimingResult`, `MemoryUsage`, `ComparisonReport`. + +### Utilities +![Utilities](UML/exports/13_utilities.png) + +16 shared helper classes: `LanguageDetector`, `MarkdownCleaner`, `RAGChunker`, `RateLimitHandler`, `ConfigManager`, `ConfigValidator`, `SkillQualityChecker`, `QualityAnalyzer`, `LlmsTxtDetector`/`Downloader`/`Parser`, `ConfigSplitter`, `ConflictDetector`, `IncrementalUpdater`, `MultiLanguageManager`, `StreamingIngester`. + +## Key Design Patterns + +| Pattern | Where | Classes | +|---------|-------|---------| +| Strategy + Factory | Adaptors | `SkillAdaptor` ABC + `get_adaptor()` factory + 20+ implementations | +| Strategy + Factory | Storage | `BaseStorageAdaptor` ABC + S3/GCS/Azure | +| Strategy + Factory | Embedding | `EmbeddingProvider` ABC + OpenAI/Local | +| Command | CLI | `CLIDispatcher` + `COMMAND_MODULES` lazy dispatch | +| Template Method | Pattern Detection | `BasePatternDetector` + 10 GoF detectors | +| Template Method | Parsers | `SubcommandParser` + 27 subclasses | + +## File Locations + +- **StarUML project**: `Docs/UML/skill_seekers.mdj` +- **Diagram exports**: `Docs/UML/exports/*.png` +- **Source code**: `src/skill_seekers/` diff --git a/Docs/UML/exports/00_package_overview.png b/Docs/UML/exports/00_package_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..a842061ee3bffecf947b5be19ae97c533dc3d000 GIT binary patch literal 110434 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