From 2bc96dcc133472519202078d35faeef59271c752 Mon Sep 17 00:00:00 2001 From: Kyle Pearson Date: Thu, 12 Mar 2026 21:55:50 -0700 Subject: [PATCH 1/8] Fix mypy error and pre-commit hook fixes --- .clineignore | 1 - .github/workflows/release.yml | 222 ---------------------------------- README.md | 33 ++++- docs/index.html | 18 ++- pyproject.toml | 5 +- src/docbuddy/cli.py | 76 ++++++++++++ 6 files changed, 125 insertions(+), 230 deletions(-) delete mode 100644 .github/workflows/release.yml create mode 100644 src/docbuddy/cli.py 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..50f2a4a 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,36 @@ 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) +### Option 1: Using the CLI (Recommended) + +After installing DocBuddy, use the `docbuddy` command to launch the standalone webpage: + +```bash +pip install docbuddy +docbuddy +``` + +This starts a local server on port **8008** and opens your browser to [http://localhost:8008/docs/index.html](http://localhost:8008/docs/index.html). + +#### Custom Host/Port + +Use `--host` and `--port` options to customize the server: + +```bash +docbuddy --port 9000 +docbuddy -p 8080 --host 127.0.0.1 +``` + +Run `docbuddy --help` for more information. + +### Option 2: Manual Local Server + +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..f7830b6 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 http://localhost:8008/docs/index.html +

+ +

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..ff5e9e5 --- /dev/null +++ b/src/docbuddy/cli.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""CLI entry point for launching DocBuddy standalone webpage.""" + +import argparse +import http.server +import os +import sys +import webbrowser + +from importlib.resources import files + + +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() + + # Use importlib.resources to find the docs directory + docbuddy_pkg = files("docbuddy") + + # The docs directory is at the project root, one level up from src/docbuddy + # When installed, it's in site-packages/docbuddy/../.. + docs_path = docbuddy_pkg.parent.parent / "docs" + + if not docs_path.exists(): + print(f"Error: Could not find 'docs' directory at {docs_path}", file=sys.stderr) + sys.exit(1) + + # Serve from the project root (parent of docs) so static files are accessible + # The index.html uses paths like /src/docbuddy/static/core.js which need the parent + os.chdir(docs_path.parent) + + url = f"http://{args.host}:{args.port}/docs/index.html" + + print(f"Serving DocBuddy at {url}") + print("Press Ctrl+C to stop the server") + + # Start HTTP server + with http.server.HTTPServer( + (args.host, args.port), http.server.SimpleHTTPRequestHandler + ) as httpd: + # Give it a moment for server to fully start before opening browser + import threading + + def open_browser(): + import time + + 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) From 7daf48c0f615abbd5e1870c6415e2c00362f2b34 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:30:32 -0700 Subject: [PATCH 2/8] Fix CLI: package standalone.html, use directory= instead of os.chdir, add tests (#22) * Initial plan * Fix CLI: package standalone.html, use directory= instead of os.chdir, add tests, fix hardcoded URLs Co-authored-by: pearsonkyle <4556546+pearsonkyle@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pearsonkyle <4556546+pearsonkyle@users.noreply.github.com> --- README.md | 2 +- docs/index.html | 2 +- src/docbuddy/cli.py | 39 +- src/docbuddy/standalone.html | 765 +++++++++++++++++++++++++++++++++++ tests/test_plugin.py | 93 +++++ 5 files changed, 877 insertions(+), 24 deletions(-) create mode 100644 src/docbuddy/standalone.html diff --git a/README.md b/README.md index 50f2a4a..7a223cd 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ pip install docbuddy docbuddy ``` -This starts a local server on port **8008** and opens your browser to [http://localhost:8008/docs/index.html](http://localhost:8008/docs/index.html). +This starts a local server on port **8008** and opens your browser to `http://localhost:8008/standalone.html`. #### Custom Host/Port diff --git a/docs/index.html b/docs/index.html index f7830b6..2665a26 100644 --- a/docs/index.html +++ b/docs/index.html @@ -448,7 +448,7 @@

πŸš€ Run Locally with CLI

Click to copy

- Opens at http://localhost:8008/docs/index.html + Opens at the address shown in the terminal (default port 8008)

Python Plugin

diff --git a/src/docbuddy/cli.py b/src/docbuddy/cli.py index ff5e9e5..0cb2f8e 100644 --- a/src/docbuddy/cli.py +++ b/src/docbuddy/cli.py @@ -2,11 +2,12 @@ """CLI entry point for launching DocBuddy standalone webpage.""" import argparse +import functools import http.server -import os import sys +import threading +import time import webbrowser - from importlib.resources import files @@ -33,36 +34,30 @@ def main(): args = parser.parse_args() - # Use importlib.resources to find the docs directory - docbuddy_pkg = files("docbuddy") - - # The docs directory is at the project root, one level up from src/docbuddy - # When installed, it's in site-packages/docbuddy/../.. - docs_path = docbuddy_pkg.parent.parent / "docs" + # Locate packaged assets via importlib.resources (works for both editable + # and normal pip installs; no os.chdir() needed). + pkg_ref = files("docbuddy") + standalone_ref = pkg_ref.joinpath("standalone.html") - if not docs_path.exists(): - print(f"Error: Could not find 'docs' directory at {docs_path}", file=sys.stderr) + if not standalone_ref.is_file(): + print( + f"Error: Could not find 'standalone.html' in the docbuddy package ({pkg_ref})", + file=sys.stderr, + ) sys.exit(1) - # Serve from the project root (parent of docs) so static files are accessible - # The index.html uses paths like /src/docbuddy/static/core.js which need the parent - os.chdir(docs_path.parent) + # Serve only the package directory – not the whole repo/site-packages root. + pkg_dir = str(pkg_ref) + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=pkg_dir) - url = f"http://{args.host}:{args.port}/docs/index.html" + url = f"http://{args.host}:{args.port}/standalone.html" print(f"Serving DocBuddy at {url}") print("Press Ctrl+C to stop the server") - # Start HTTP server - with http.server.HTTPServer( - (args.host, args.port), http.server.SimpleHTTPRequestHandler - ) as httpd: - # Give it a moment for server to fully start before opening browser - import threading + with http.server.HTTPServer((args.host, args.port), handler) as httpd: def open_browser(): - import time - time.sleep(0.5) webbrowser.open(url) 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..db4deeb 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1796,3 +1796,96 @@ 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.""" + from importlib.resources import files + + pkg_ref = files("docbuddy") + standalone_ref = pkg_ref.joinpath("standalone.html") + assert standalone_ref.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).""" + from importlib.resources import files + + pkg_ref = files("docbuddy") + html = pkg_ref.joinpath("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): + """main() must exit with a clear message when standalone.html is missing.""" + import sys + from unittest.mock import MagicMock + + import pytest + + import docbuddy.cli as cli_module + + # Patch `files` as it is imported in cli.py (must patch the name in that module) + fake_ref = MagicMock() + fake_ref.__str__ = lambda _: "/fake/pkg" + fake_file = MagicMock() + fake_file.is_file.return_value = False + fake_ref.joinpath.return_value = fake_file + + monkeypatch.setattr(cli_module, "files", lambda _pkg: fake_ref) + 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): + """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 + + # Stub out standalone.html lookup so it succeeds + fake_ref = MagicMock() + fake_ref.__str__ = lambda _: "/fake/pkg" + fake_file = MagicMock() + fake_file.is_file.return_value = True + fake_ref.joinpath.return_value = fake_file + + monkeypatch.setattr(cli_module, "files", lambda _pkg: fake_ref) + 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" From 97efaa4bc5ddee8d6394a4d72d600657b2fb00c0 Mon Sep 17 00:00:00 2001 From: Kyle Pearson Date: Thu, 12 Mar 2026 22:55:16 -0700 Subject: [PATCH 3/8] docs --- README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/README.md b/README.md index 7a223cd..3b7a407 100644 --- a/README.md +++ b/README.md @@ -59,24 +59,11 @@ Enable tool calling in the settings to allow the assistant to make API requests After installing DocBuddy, use the `docbuddy` command to launch the standalone webpage: -```bash -pip install docbuddy -docbuddy -``` - -This starts a local server on port **8008** and opens your browser to `http://localhost:8008/standalone.html`. - -#### Custom Host/Port - -Use `--host` and `--port` options to customize the server: - ```bash docbuddy --port 9000 docbuddy -p 8080 --host 127.0.0.1 ``` -Run `docbuddy --help` for more information. - ### Option 2: Manual Local Server If you prefer manual control, run DocBuddy from the repo root: From fae9159351928f8b1b1247278ad09979af5d06db Mon Sep 17 00:00:00 2001 From: Kyle Pearson Date: Thu, 12 Mar 2026 22:57:33 -0700 Subject: [PATCH 4/8] linting --- tests/test_plugin.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index db4deeb..f437110 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1807,7 +1807,9 @@ def test_cli_standalone_html_is_packaged(): pkg_ref = files("docbuddy") standalone_ref = pkg_ref.joinpath("standalone.html") - assert standalone_ref.is_file(), "standalone.html must be present in the installed package" + assert ( + standalone_ref.is_file() + ), "standalone.html must be present in the installed package" def test_cli_standalone_html_uses_local_static_path(): @@ -1816,10 +1818,12 @@ def test_cli_standalone_html_uses_local_static_path(): pkg_ref = files("docbuddy") html = pkg_ref.joinpath("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" - ) + 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): @@ -1888,4 +1892,6 @@ def fake_http_server(addr, 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" + assert ( + "directory" in handler.keywords + ), "handler must have directory= keyword argument" From 3fb81c1ab486c22050a9b0c3c89e446c21d6592c Mon Sep 17 00:00:00 2001 From: Kyle Pearson Date: Thu, 12 Mar 2026 23:01:04 -0700 Subject: [PATCH 5/8] docs --- README.md | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3b7a407..b542c95 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ pip install docbuddy ``` -## Quick Start +access the standalone page by running the following command: + +```bash +docbuddy --port 9000 +``` + +## Python Integration ```python from fastapi import FastAPI @@ -55,17 +61,6 @@ Enable tool calling in the settings to allow the assistant to make API requests ## Standalone Mode -### Option 1: Using the CLI (Recommended) - -After installing DocBuddy, use the `docbuddy` command to launch the standalone webpage: - -```bash -docbuddy --port 9000 -docbuddy -p 8080 --host 127.0.0.1 -``` - -### Option 2: Manual Local Server - If you prefer manual control, run DocBuddy from the repo root: 1. Run `python3 -m http.server 8080` from the repo root @@ -73,7 +68,6 @@ If you prefer manual control, run DocBuddy from the repo root: > **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 1. Choose your local LLM provider (Ollama, LM Studio, vLLM, or Custom) From fe5502a086a1232ca31fdcbc25d43b149aa29a63 Mon Sep 17 00:00:00 2001 From: Kyle Pearson Date: Thu, 12 Mar 2026 23:02:06 -0700 Subject: [PATCH 6/8] docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b542c95..7d672e1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pip install docbuddy ``` -access the standalone page by running the following command: +Run the standalone page locally by running the following command: ```bash docbuddy --port 9000 From 99ea160f248ee56f8815da1691591990172b43f9 Mon Sep 17 00:00:00 2001 From: Kyle Pearson Date: Thu, 12 Mar 2026 23:03:03 -0700 Subject: [PATCH 7/8] docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d672e1..f821e46 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pip install docbuddy ``` -Run the standalone page locally by running the following command: +Run the standalone page locally with the command: ```bash docbuddy --port 9000 From 98a2c9a341a198dbc2fca48a37e98fe1982acc52 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:30:47 -0700 Subject: [PATCH 8/8] Fix CLI 404: replace importlib.resources.files() with pathlib.__file__ for package directory resolution (#23) * Initial plan * Fix CLI: use pathlib.__file__ instead of importlib.resources.files() to resolve package directory Co-authored-by: pearsonkyle <4556546+pearsonkyle@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pearsonkyle <4556546+pearsonkyle@users.noreply.github.com> --- src/docbuddy/cli.py | 25 ++++++++++++++--------- tests/test_plugin.py | 48 ++++++++++++++++++++------------------------ 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/docbuddy/cli.py b/src/docbuddy/cli.py index 0cb2f8e..874efd5 100644 --- a/src/docbuddy/cli.py +++ b/src/docbuddy/cli.py @@ -4,11 +4,16 @@ import argparse import functools import http.server +import pathlib import sys import threading import time import webbrowser -from importlib.resources import files + + +def _pkg_dir() -> pathlib.Path: + """Return the directory that contains standalone.html and static/.""" + return pathlib.Path(__file__).parent def main(): @@ -34,21 +39,23 @@ def main(): args = parser.parse_args() - # Locate packaged assets via importlib.resources (works for both editable - # and normal pip installs; no os.chdir() needed). - pkg_ref = files("docbuddy") - standalone_ref = pkg_ref.joinpath("standalone.html") + # 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_ref.is_file(): + if not standalone_path.is_file(): print( - f"Error: Could not find 'standalone.html' in the docbuddy package ({pkg_ref})", + 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. - pkg_dir = str(pkg_ref) - handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=pkg_dir) + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, directory=str(pkg_dir) + ) url = f"http://{args.host}:{args.port}/standalone.html" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f437110..1a0ca2a 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1803,21 +1803,25 @@ def test_standalone_page_has_docbuddy_plugin(): def test_cli_standalone_html_is_packaged(): """Verify standalone.html is shipped inside the docbuddy package.""" - from importlib.resources import files + import pathlib - pkg_ref = files("docbuddy") - standalone_ref = pkg_ref.joinpath("standalone.html") + import docbuddy + + pkg_dir = pathlib.Path(docbuddy.__file__).parent + standalone_path = pkg_dir / "standalone.html" assert ( - standalone_ref.is_file() + 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).""" - from importlib.resources import files + import pathlib + + import docbuddy - pkg_ref = files("docbuddy") - html = pkg_ref.joinpath("standalone.html").read_text(encoding="utf-8") + 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" @@ -1826,23 +1830,16 @@ def test_cli_standalone_html_uses_local_static_path(): ), "standalone.html must not reference the repo-layout path ../src/docbuddy/static" -def test_cli_main_exits_on_missing_standalone(monkeypatch): +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 - from unittest.mock import MagicMock import pytest import docbuddy.cli as cli_module - # Patch `files` as it is imported in cli.py (must patch the name in that module) - fake_ref = MagicMock() - fake_ref.__str__ = lambda _: "/fake/pkg" - fake_file = MagicMock() - fake_file.is_file.return_value = False - fake_ref.joinpath.return_value = fake_file - - monkeypatch.setattr(cli_module, "files", lambda _pkg: fake_ref) + # 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: @@ -1850,7 +1847,7 @@ def test_cli_main_exits_on_missing_standalone(monkeypatch): assert exc_info.value.code == 1 -def test_cli_uses_directory_not_chdir(monkeypatch): +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 @@ -1858,14 +1855,9 @@ def test_cli_uses_directory_not_chdir(monkeypatch): import docbuddy.cli as cli_module - # Stub out standalone.html lookup so it succeeds - fake_ref = MagicMock() - fake_ref.__str__ = lambda _: "/fake/pkg" - fake_file = MagicMock() - fake_file.is_file.return_value = True - fake_ref.joinpath.return_value = fake_file - - monkeypatch.setattr(cli_module, "files", lambda _pkg: fake_ref) + # 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 @@ -1895,3 +1887,7 @@ def fake_http_server(addr, handler): 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"