"""Command-line interface for doc-forge. The CLI provides a thin orchestration layer over the core doc-forge functionality. It follows the ADS specification by being library-first with the CLI as a secondary interface. Available commands: - generate: Generate source files for a renderer - build: Build final documentation artifacts - serve: Serve documentation locally - export: Export to MCP format """ from __future__ import annotations import logging import sys from pathlib import Path from typing import List, Optional try: import click except ImportError as e: raise ImportError( "click is required for doc-forge CLI. " "Install with: pip install click" ) from e from docforge.loader import GriffeLoader from docforge.renderers import MkDocsRenderer, SphinxRenderer from docforge.exporters import MCPExporter from docforge.server import MCPServer logger = logging.getLogger(__name__) @click.group() @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") @click.pass_context def cli(ctx: click.Context, verbose: bool) -> None: """doc-forge — A renderer-agnostic Python documentation compiler. doc-forge converts Python source code and docstrings into a structured, semantic documentation model and emits multiple downstream representations. """ if verbose: logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") else: logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") # Ensure context object exists ctx.ensure_object(dict) @cli.command() @click.option("--renderer", type=click.Choice(["mkdocs", "sphinx"]), required=True, help="Renderer to use") @click.option("--out-dir", type=click.Path(path_type=Path), default=Path("docs"), help="Output directory") @click.option("--project-name", help="Project name (defaults to first module)") @click.argument("modules", nargs=-1, required=True, help="Python modules to document") @click.pass_context def generate( ctx: click.Context, renderer: str, out_dir: Path, project_name: Optional[str], modules: List[str], ) -> None: """Generate documentation source files. Generate renderer-specific source files from Python modules. This creates the source files but does not build the final documentation. Examples: doc-forge generate --renderer mkdocs mypackage doc-forge generate --renderer sphinx mypackage.submodule mypackage.utils """ try: # Load project loader = GriffeLoader() project = loader.load_project(list(modules), project_name) # Get renderer renderer_instance = _get_renderer(renderer) # Generate sources renderer_instance.generate_sources(project, out_dir) click.echo(f"Generated {renderer} sources in {out_dir}") click.echo(f"Modules: {', '.join(modules)}") except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @cli.command() @click.option("--renderer", type=click.Choice(["mkdocs", "sphinx"]), required=True, help="Renderer to use") @click.option("--out-dir", type=click.Path(path_type=Path), default=Path("docs"), help="Output directory") @click.option("--project-name", help="Project name (defaults to first module)") @click.argument("modules", nargs=-1, required=True, help="Python modules to document") @click.pass_context def build( ctx: click.Context, renderer: str, out_dir: Path, project_name: Optional[str], modules: List[str], ) -> None: """Build final documentation artifacts. Build the final documentation from Python modules. This both generates source files and builds the final artifacts (HTML, etc.). Examples: doc-forge build --renderer mkdocs mypackage doc-forge build --renderer sphinx mypackage.submodule """ try: # Load project loader = GriffeLoader() project = loader.load_project(list(modules), project_name) # Get renderer renderer_instance = _get_renderer(renderer) # Generate sources first renderer_instance.generate_sources(project, out_dir) # Build final artifacts from docforge.renderers.base import RendererConfig config = RendererConfig(out_dir, project) renderer_instance.build(config) click.echo(f"Built {renderer} documentation in {out_dir}") # Show output location if renderer == "mkdocs": build_dir = out_dir / "_site" else: # sphinx build_dir = out_dir / "build" / "html" if build_dir.exists(): click.echo(f"Output: {build_dir}") except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @cli.command() @click.option("--renderer", type=click.Choice(["mkdocs", "sphinx"]), required=True, help="Renderer to use") @click.option("--out-dir", type=click.Path(path_type=Path), default=Path("docs"), help="Output directory") @click.option("--host", default="127.0.0.1", help="Host to serve on") @click.option("--port", type=int, default=8000, help="Port to serve on") @click.option("--project-name", help="Project name (defaults to first module)") @click.argument("modules", nargs=-1, required=True, help="Python modules to document") @click.pass_context def serve( ctx: click.Context, renderer: str, out_dir: Path, host: str, port: int, project_name: Optional[str], modules: List[str], ) -> None: """Serve documentation locally. Start a local development server to serve documentation. This generates sources, builds artifacts, and starts a server. Examples: doc-forge serve --renderer mkdocs mypackage doc-forge serve --renderer sphinx --port 9000 mypackage """ try: # Load project loader = GriffeLoader() project = loader.load_project(list(modules), project_name) # Get renderer renderer_instance = _get_renderer(renderer) # Generate sources first renderer_instance.generate_sources(project, out_dir) # Build artifacts from docforge.renderers.base import RendererConfig config = RendererConfig(out_dir, project, {"host": host, "port": port}) # Start serving click.echo(f"Serving {renderer} documentation at http://{host}:{port}") click.echo("Press Ctrl+C to stop serving") renderer_instance.serve(config) except KeyboardInterrupt: click.echo("\nStopped serving") except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @cli.command() @click.option("--out-dir", type=click.Path(path_type=Path), default=Path("mcp"), help="Output directory") @click.option("--project-name", help="Project name (defaults to first module)") @click.argument("modules", nargs=-1, required=True, help="Python modules to document") @click.pass_context def export( ctx: click.Context, out_dir: Path, project_name: Optional[str], modules: List[str], ) -> None: """Export documentation to MCP format. Export documentation as a static MCP JSON bundle. This creates machine-consumable documentation that can be loaded by MCP clients. Examples: doc-forge export mypackage doc-forge export --out-dir ./docs/mcp mypackage.submodule """ try: # Load project loader = GriffeLoader() project = loader.load_project(list(modules), project_name) # Export to MCP exporter = MCPExporter() exporter.export(project, out_dir) click.echo(f"Exported MCP bundle to {out_dir}") click.echo(f"Modules: {', '.join(modules)}") # Show bundle structure index_path = out_dir / "index.json" nav_path = out_dir / "nav.json" modules_dir = out_dir / "modules" click.echo(f"Bundle structure:") click.echo(f" {index_path}") click.echo(f" {nav_path}") click.echo(f" {modules_dir}/") except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @cli.command() @click.option("--host", default="127.0.0.1", help="Host to serve on") @click.option("--port", type=int, default=8080, help="Port to serve on") @click.option("--project-name", help="Project name (defaults to first module)") @click.argument("modules", nargs=-1, required=True, help="Python modules to document") @click.pass_context def server( ctx: click.Context, host: str, port: int, project_name: Optional[str], modules: List[str], ) -> None: """Start MCP server for live documentation access. Start a live MCP server that provides real-time access to documentation through the Model Context Protocol. This serves documentation directly from the Python model without generating static files. Examples: doc-forge server mypackage doc-forge server --port 9000 mypackage.submodule """ try: # Load project loader = GriffeLoader() project = loader.load_project(list(modules), project_name) # Start MCP server server = MCPServer(project) click.echo(f"Starting MCP server at http://{host}:{port}") click.echo("Available resources:") click.echo(f" docs://index - Project metadata") click.echo(f" docs://nav - Navigation structure") for module_path in project.get_module_list(): click.echo(f" docs://module/{module_path} - Module documentation") click.echo("Press Ctrl+C to stop server") server.start(host, port) # Keep server running try: while server.is_running(): import time time.sleep(1) except KeyboardInterrupt: click.echo("\nStopping server...") server.stop() except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) def _get_renderer(renderer_name: str): """Get renderer instance by name. Args: renderer_name: Name of the renderer Returns: Renderer instance Raises: ValueError: If renderer name is unknown """ if renderer_name == "mkdocs": return MkDocsRenderer() elif renderer_name == "sphinx": return SphinxRenderer() else: raise ValueError(f"Unknown renderer: {renderer_name}") def main() -> None: """Main entry point for the CLI.""" cli() if __name__ == "__main__": main()