diff --git a/.clineignore b/.clineignore index 2d2ecd6..e69de29 100644 --- a/.clineignore +++ b/.clineignore @@ -1 +0,0 @@ -.git/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a2d97ef..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,222 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - bump-type: - description: 'Bump type for version increment (major/minor/patch)' - type: choice - options: - - major - - minor - - patch - default: patch - version: - description: 'Version to release (e.g., 0.5.0) - leave empty if using bump-type' - required: false - publish-to-testpypi: - description: 'Publish to TestPyPI first' - type: boolean - default: false - dry-run: - description: 'Test build without uploading' - type: boolean - default: false - -jobs: - validate-version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Extract version - id: version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - VERSION="${GITHUB_REF#refs/tags/v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - elif [ -n "${{ github.event.inputs.version }}" ]; then - # Use explicit version if provided - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - else - # Calculate new version based on bump-type - CURRENT_VERSION=$(grep '^version = "' pyproject.toml | cut -d'"' -f2) - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" - case "${{ github.event.inputs.bump-type }}" in - major) - NEW_MAJOR=$((MAJOR + 1)) - echo "version=${NEW_MAJOR}.0.0" >> $GITHUB_OUTPUT - ;; - minor) - NEW_MINOR=$((MINOR + 1)) - echo "version=${MAJOR}.${NEW_MINOR}.0" >> $GITHUB_OUTPUT - ;; - patch) - NEW_PATCH=$((PATCH + 1)) - echo "version=${MAJOR}.${MINOR}.${NEW_PATCH}" >> $GITHUB_OUTPUT - ;; - esac - fi - - - name: Validate version format - run: | - VERSION="${{ steps.version.outputs.version }}" - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Version must be in semantic versioning format (e.g., 0.5.0)" - exit 1 - fi - - - name: Check version consistency - run: | - VERSION="${{ steps.version.outputs.version }}" - PYPROJECT_VERSION=$(grep '^version = "' pyproject.toml | cut -d'"' -f2) - if [ "${{ github.event_name }}" == "push" ]; then - # For tag pushes, version should match the tag (no check needed here) - echo "Tag release: v${VERSION}" - elif [ "$VERSION" != "$PYPROJECT_VERSION" ]; then - echo "Note: Using bump-type to increment version from $PYPROJECT_VERSION to $VERSION" - else - echo "Version matches pyproject.toml: $VERSION" - fi - - - name: Update version in pyproject.toml (if using bump-type) - if: github.event.inputs.version == '' && github.event_name != 'push' - run: | - VERSION="${{ steps.version.outputs.version }}" - sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml - echo "Updated pyproject.toml to version $VERSION" - cat pyproject.toml | head -5 - - test-publish: - runs-on: ubuntu-latest - needs: validate-version - if: github.event.inputs.publish-to-testpypi == 'true' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install hatch twine build - - - name: Build package - run: hatch build - - - name: Upload to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.TESTPYPI_API_TOKEN }} - repository-url: https://test.pypi.org/legacy/ - skip-existing: true - print-hash: true - - publish: - runs-on: ubuntu-latest - needs: [validate-version, test-publish] - if: github.event.inputs.dry-run != 'true' - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install hatch twine build - - - name: Build package - run: hatch build - - - name: Upload to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - skip-existing: true - print-hash: true - - create-release: - runs-on: ubuntu-latest - needs: publish - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') - permissions: - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Generate release notes - id: release_notes - run: | - VERSION="${{ needs.validate-version.outputs.version }}" - - # Get the previous tag - PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - - if [ -n "$PREVIOUS_TAG" ]; then - # Generate release notes from commits between tags - cat < release_notes.md - ## Changelog - - ### Changes since $PREVIOUS_TAG - - $(git log --oneline "${PREVIOUS_TAG}..HEAD" | sed 's/^/- /') - - EOF - else - # First release - use README as base - cat < release_notes.md - ## DocBuddy v${VERSION} - - ### New Project - - This is the first release of DocBuddy. - - For more information, see the [README](https://github.com/pearsonkyle/docbuddy/blob/main/README.md). - - EOF - fi - - echo "body<> $GITHUB_OUTPUT - cat release_notes.md >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ needs.validate-version.outputs.version }} - name: "Release v${{ needs.validate-version.outputs.version }}" - body: | - ${{ steps.release_notes.outputs.body }} - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - notify: - runs-on: ubuntu-latest - needs: [publish, create-release] - if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') - steps: - - name: Notify release completed - run: | - echo "Release v${{ needs.validate-version.outputs.version }} completed!" - echo "PyPI: https://pypi.org/project/docbuddy/${{ needs.validate-version.outputs.version }}" - if [ "${{ github.event.inputs.publish-to-testpypi }}" == "true" ]; then - echo "TestPyPI: https://test.pypi.org/project/docbuddy/${{ needs.validate-version.outputs.version }}" - fi diff --git a/README.md b/README.md index 0ddd56a..f821e46 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ pip install docbuddy ``` -## Quick Start +Run the standalone page locally with the command: + +```bash +docbuddy --port 9000 +``` + +## Python Integration ```python from fastapi import FastAPI @@ -55,10 +61,12 @@ Enable tool calling in the settings to allow the assistant to make API requests ## Standalone Mode -DocBuddy has a standalone webpage (e.g. hosted on GitHub Pages) that connects to any OpenAPI schema and LLM provider. However, due to browser security restrictions (CORS), if you want to use local LLMs, you must run DocBuddy locally instead of from GitHub Pages. -1. Run `python3 -m http.server 8080` from the repo root -2. Visit in your browser [http://localhost:8080/docs/index.html](http://localhost:8080/docs/index.html) +If you prefer manual control, run DocBuddy from the repo root: + +1. Run `python3 -m http.server 8080` from the repo root +2. Visit in your browser [http://localhost:8080/docs/index.html](http://localhost:8080/docs/index.html) +> **Note:** Due to browser security restrictions (CORS), if you want to use local LLMs (Ollama, LM Studio, vLLM), you must run DocBuddy locally instead of from the GitHub Pages hosted version. ## LLM Settings diff --git a/docs/index.html b/docs/index.html index ba7b996..2665a26 100644 --- a/docs/index.html +++ b/docs/index.html @@ -435,15 +435,27 @@

Example APIs

-

Python Plugin

+

πŸš€ Run Locally with CLI

- Replace your FastAPI /docs page with DocBuddy β€” get AI chat, workflows, - and agent tools directly alongside your API explorer. + Launch this page locally in order to connect to local LLMs (Ollama, LM Studio, vLLM).

pip install docbuddy Click to copy
+
+ docbuddy + Click to copy +
+

+ Opens at the address shown in the terminal (default port 8008) +

+ +

Python Plugin

+

+ Replace your FastAPI /docs page with DocBuddy β€” get AI chat, workflows, + and agent tools directly alongside your API explorer. +

from fastapi import FastAPI from docbuddy import setup_docs diff --git a/pyproject.toml b/pyproject.toml index ad4f341..4e39474 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "docbuddy" -version = "0.5.0" +version = "0.6.0" description = "Add an AI Assistant to your `/docs` page." description-content-type = "text/markdown" readme = "README.md" @@ -49,6 +49,9 @@ Repository = "https://github.com/pearsonkyle/docbuddy" [tool.hatch.build.targets.wheel] packages = ["src/docbuddy"] +[project.scripts] +docbuddy = "docbuddy.cli:main" + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/docbuddy/cli.py b/src/docbuddy/cli.py new file mode 100644 index 0000000..874efd5 --- /dev/null +++ b/src/docbuddy/cli.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""CLI entry point for launching DocBuddy standalone webpage.""" + +import argparse +import functools +import http.server +import pathlib +import sys +import threading +import time +import webbrowser + + +def _pkg_dir() -> pathlib.Path: + """Return the directory that contains standalone.html and static/.""" + return pathlib.Path(__file__).parent + + +def main(): + """Launch DocBuddy standalone webpage on port 8008.""" + parser = argparse.ArgumentParser( + prog="docbuddy", + description="Launch the DocBuddy standalone AI-enhanced API documentation page.", + epilog="Example: docbuddy --host 127.0.0.1 --port 9000", + ) + parser.add_argument( + "--host", + type=str, + default="localhost", + help="Host to bind the server to (default: localhost)", + ) + parser.add_argument( + "--port", + "-p", + type=int, + default=8008, + help="Port to run the server on (default: 8008)", + ) + + args = parser.parse_args() + + # Locate the package directory using __file__ – this is the most reliable + # way to find the installed package assets regardless of Python version, + # install method (editable, wheel, sdist), or platform. + pkg_dir = _pkg_dir() + standalone_path = pkg_dir / "standalone.html" + + if not standalone_path.is_file(): + print( + f"Error: Could not find 'standalone.html' in the docbuddy package ({pkg_dir})", + file=sys.stderr, + ) + sys.exit(1) + + # Serve only the package directory – not the whole repo/site-packages root. + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, directory=str(pkg_dir) + ) + + url = f"http://{args.host}:{args.port}/standalone.html" + + print(f"Serving DocBuddy at {url}") + print("Press Ctrl+C to stop the server") + + with http.server.HTTPServer((args.host, args.port), handler) as httpd: + + def open_browser(): + time.sleep(0.5) + webbrowser.open(url) + + thread = threading.Thread(target=open_browser, daemon=True) + thread.start() + + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nServer stopped.") + sys.exit(0) diff --git a/src/docbuddy/standalone.html b/src/docbuddy/standalone.html new file mode 100644 index 0000000..07b05db --- /dev/null +++ b/src/docbuddy/standalone.html @@ -0,0 +1,765 @@ + + + + DocBuddy β€” AI-Enhanced API Documentation + + + + + + + + + + + +
+
+

DocBuddy

+

AI-Enhanced API Documentation

+

+ Load any OpenAPI schema and explore it with Chat, Workflow, and Agent + assistance β€” powered by your local LLM. +

+ + +
+ + +
+

+ Enter a direct openapi.json URL, or paste a /docs page URL + and DocBuddy will auto-detect the schema. +

+

+ +
+ + + +
+

πŸš€ Run Locally with CLI

+

+ Launch this page locally in order to connect to local LLMs (Ollama, LM Studio, vLLM). +

+
+ pip install docbuddy + Click to copy +
+
+ docbuddy + Click to copy +
+

+ Opens at + +

+ +

Python Plugin

+

+ Replace your FastAPI /docs page with DocBuddy β€” get AI chat, workflows, + and agent tools directly alongside your API explorer. +

+
from fastapi import FastAPI +from docbuddy import setup_docs + +app = FastAPI() +setup_docs(app) # replaces default /docs
+ +
+
+
+ + +
+
+ πŸ€– DocBuddy + +
+ +
+ + +
+ + + + + + + + + + + diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 9d2b7f4..1a0ca2a 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1796,3 +1796,98 @@ def test_standalone_page_has_docbuddy_plugin(): html = (Path(__file__).parent.parent / "docs" / "index.html").read_text() assert "DocBuddyPlugin" in html assert "LLMDocsLayout" in html + + +# ── CLI tests ────────────────────────────────────────────────────────────────── + + +def test_cli_standalone_html_is_packaged(): + """Verify standalone.html is shipped inside the docbuddy package.""" + import pathlib + + import docbuddy + + pkg_dir = pathlib.Path(docbuddy.__file__).parent + standalone_path = pkg_dir / "standalone.html" + assert ( + standalone_path.is_file() + ), "standalone.html must be present in the installed package" + + +def test_cli_standalone_html_uses_local_static_path(): + """standalone.html must load JS from ./static (not from the repo root path).""" + import pathlib + + import docbuddy + + pkg_dir = pathlib.Path(docbuddy.__file__).parent + html = (pkg_dir / "standalone.html").read_text(encoding="utf-8") + assert ( + "./static" in html + ), "standalone.html should reference './static' for local assets" + assert ( + "../src/docbuddy/static" not in html + ), "standalone.html must not reference the repo-layout path ../src/docbuddy/static" + + +def test_cli_main_exits_on_missing_standalone(monkeypatch, tmp_path): + """main() must exit with a clear message when standalone.html is missing.""" + import sys + + import pytest + + import docbuddy.cli as cli_module + + # Point _pkg_dir() at a temporary directory with no standalone.html + monkeypatch.setattr(cli_module, "_pkg_dir", lambda: tmp_path) + monkeypatch.setattr(sys, "argv", ["docbuddy"]) + + with pytest.raises(SystemExit) as exc_info: + cli_module.main() + assert exc_info.value.code == 1 + + +def test_cli_uses_directory_not_chdir(monkeypatch, tmp_path): + """main() must not call os.chdir; it must pass directory= to the HTTP handler.""" + import functools + import sys + from unittest.mock import MagicMock, patch + + import docbuddy.cli as cli_module + + # Create a fake standalone.html so the existence check passes + (tmp_path / "standalone.html").write_text("") + monkeypatch.setattr(cli_module, "_pkg_dir", lambda: tmp_path) + monkeypatch.setattr(sys, "argv", ["docbuddy"]) + + # Capture the handler passed to HTTPServer to verify directory= is set + captured_handler = {} + + def fake_http_server(addr, handler): + captured_handler["handler"] = handler + mock_httpd = MagicMock() + mock_httpd.__enter__ = lambda s: s + mock_httpd.__exit__ = MagicMock(return_value=False) + mock_httpd.serve_forever.side_effect = KeyboardInterrupt + return mock_httpd + + with ( + patch("http.server.HTTPServer", side_effect=fake_http_server), + patch("webbrowser.open"), + ): + try: + cli_module.main() + except SystemExit: + pass + + handler = captured_handler.get("handler") + assert handler is not None, "HTTPServer must be called with a handler" + # The handler must be a functools.partial with directory= keyword set + assert isinstance(handler, functools.partial), "handler must be a functools.partial" + assert ( + "directory" in handler.keywords + ), "handler must have directory= keyword argument" + # directory must point to the package dir (tmp_path in this test) + assert handler.keywords["directory"] == str( + tmp_path + ), "directory= must be the package directory"