docforge-gen 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. docforge/__init__.py +3 -0
  2. docforge/analysis/__init__.py +5 -0
  3. docforge/analysis/context_builder.py +131 -0
  4. docforge/analysis/dependency_detector.py +120 -0
  5. docforge/analysis/file_walker.py +63 -0
  6. docforge/analysis/repo_analyzer.py +53 -0
  7. docforge/cli/__init__.py +3 -0
  8. docforge/cli/app.py +29 -0
  9. docforge/cli/commands/__init__.py +0 -0
  10. docforge/cli/commands/generate.py +171 -0
  11. docforge/cli/console.py +3 -0
  12. docforge/config/__init__.py +4 -0
  13. docforge/config/loader.py +48 -0
  14. docforge/config/schema.py +40 -0
  15. docforge/generators/__init__.py +16 -0
  16. docforge/generators/api_generator.py +111 -0
  17. docforge/generators/architecture_generator.py +18 -0
  18. docforge/generators/base_generator.py +29 -0
  19. docforge/generators/changelog_generator.py +25 -0
  20. docforge/generators/installation_generator.py +22 -0
  21. docforge/generators/readme_generator.py +20 -0
  22. docforge/git/__init__.py +4 -0
  23. docforge/git/changelog_builder.py +88 -0
  24. docforge/git/repo_reader.py +44 -0
  25. docforge/llm/__init__.py +5 -0
  26. docforge/llm/chunker.py +69 -0
  27. docforge/llm/client.py +49 -0
  28. docforge/llm/prompt_manager.py +23 -0
  29. docforge/output/__init__.py +5 -0
  30. docforge/output/github_action_writer.py +34 -0
  31. docforge/output/mkdocs_builder.py +29 -0
  32. docforge/output/writer.py +21 -0
  33. docforge/parsing/__init__.py +4 -0
  34. docforge/parsing/base_parser.py +39 -0
  35. docforge/parsing/go_parser.py +99 -0
  36. docforge/parsing/java_parser.py +113 -0
  37. docforge/parsing/javascript_parser.py +84 -0
  38. docforge/parsing/python_parser.py +103 -0
  39. docforge/parsing/registry.py +29 -0
  40. docforge/parsing/rust_parser.py +107 -0
  41. docforge/parsing/typescript_parser.py +105 -0
  42. docforge/source/__init__.py +3 -0
  43. docforge/source/github_fetcher.py +58 -0
  44. docforge/source/local_fetcher.py +12 -0
  45. docforge/source/resolver.py +17 -0
  46. docforge/templates/outputs/github_action.yml.j2 +50 -0
  47. docforge/templates/outputs/mkdocs.yml.j2 +53 -0
  48. docforge/templates/prompts/api_module.j2 +38 -0
  49. docforge/templates/prompts/architecture.j2 +42 -0
  50. docforge/templates/prompts/changelog.j2 +22 -0
  51. docforge/templates/prompts/installation.j2 +41 -0
  52. docforge/templates/prompts/readme.j2 +47 -0
  53. docforge_gen-0.1.0.dist-info/METADATA +107 -0
  54. docforge_gen-0.1.0.dist-info/RECORD +57 -0
  55. docforge_gen-0.1.0.dist-info/WHEEL +4 -0
  56. docforge_gen-0.1.0.dist-info/entry_points.txt +2 -0
  57. docforge_gen-0.1.0.dist-info/licenses/LICENSE +21 -0
docforge/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DocForge - Automatically generate documentation for any GitHub repository using LLMs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .context_builder import RepoContext, CommitGroup, build_context
2
+ from .repo_analyzer import analyze_repo
3
+ from .dependency_detector import detect_dependencies
4
+
5
+ __all__ = ["RepoContext", "CommitGroup", "build_context", "analyze_repo", "detect_dependencies"]
@@ -0,0 +1,131 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+ from ..parsing.base_parser import ParsedFile
5
+
6
+
7
+ @dataclass
8
+ class CommitGroup:
9
+ tag: str # "v1.2.0" or "Unreleased"
10
+ date: str
11
+ commits: list[dict] = field(default_factory=list)
12
+
13
+
14
+ @dataclass
15
+ class RepoContext:
16
+ name: str
17
+ description: str
18
+ primary_language: str
19
+ languages: list[str]
20
+ file_tree: list[str]
21
+ parsed_files: list[ParsedFile]
22
+ dependencies: dict[str, list[str]]
23
+ commit_groups: list[CommitGroup]
24
+ github_url: str | None = None
25
+ topics: list[str] = field(default_factory=list)
26
+ license: str | None = None
27
+ has_tests: bool = False
28
+ has_ci: bool = False
29
+ entry_points: list[str] = field(default_factory=list)
30
+
31
+
32
+ def build_context(
33
+ repo_path: Path,
34
+ parsed_files: list[ParsedFile],
35
+ dependencies: dict[str, list[str]],
36
+ commit_groups: list[CommitGroup],
37
+ gh_metadata: dict | None = None,
38
+ ) -> RepoContext:
39
+ gh = gh_metadata or {}
40
+
41
+ name = gh.get("name") or repo_path.name
42
+ description = gh.get("description") or _infer_description(parsed_files, repo_path)
43
+ languages = _detect_languages(parsed_files)
44
+ primary_language = languages[0] if languages else "unknown"
45
+ file_tree = _build_file_tree(repo_path, parsed_files)
46
+ has_tests = _has_tests(repo_path)
47
+ has_ci = _has_ci(repo_path)
48
+ entry_points = _find_entry_points(repo_path, parsed_files)
49
+
50
+ return RepoContext(
51
+ name=name,
52
+ description=description,
53
+ primary_language=primary_language,
54
+ languages=languages,
55
+ file_tree=file_tree,
56
+ parsed_files=parsed_files,
57
+ dependencies=dependencies,
58
+ commit_groups=commit_groups,
59
+ github_url=gh.get("html_url"),
60
+ topics=gh.get("topics", []),
61
+ license=gh.get("license", {}).get("name") if gh.get("license") else None,
62
+ has_tests=has_tests,
63
+ has_ci=has_ci,
64
+ entry_points=entry_points,
65
+ )
66
+
67
+
68
+ def _detect_languages(parsed_files: list[ParsedFile]) -> list[str]:
69
+ counts: dict[str, int] = {}
70
+ for f in parsed_files:
71
+ counts[f.language] = counts.get(f.language, 0) + 1
72
+ return sorted(counts, key=lambda k: counts[k], reverse=True)
73
+
74
+
75
+ def _build_file_tree(repo_path: Path, parsed_files: list[ParsedFile]) -> list[str]:
76
+ paths = []
77
+ for f in parsed_files:
78
+ try:
79
+ rel = Path(f.path).relative_to(repo_path)
80
+ paths.append(str(rel))
81
+ except ValueError:
82
+ paths.append(f.path)
83
+ return sorted(paths)
84
+
85
+
86
+ def _infer_description(parsed_files: list[ParsedFile], repo_path: Path) -> str:
87
+ # Try to read existing README for description
88
+ for readme_name in ["README.md", "README.rst", "README.txt", "README"]:
89
+ readme = repo_path / readme_name
90
+ if readme.exists():
91
+ try:
92
+ content = readme.read_text(errors="replace")
93
+ lines = [l.strip() for l in content.splitlines() if l.strip()]
94
+ # Find first non-heading line
95
+ for line in lines:
96
+ if not line.startswith("#") and len(line) > 20:
97
+ return line[:200]
98
+ except Exception:
99
+ pass
100
+ return "A software project"
101
+
102
+
103
+ def _has_tests(repo_path: Path) -> bool:
104
+ test_indicators = ["tests/", "test/", "spec/", "__tests__/", "test_*.py", "*_test.go"]
105
+ for indicator in test_indicators:
106
+ if list(repo_path.glob(f"**/{indicator}")):
107
+ return True
108
+ return False
109
+
110
+
111
+ def _has_ci(repo_path: Path) -> bool:
112
+ ci_paths = [
113
+ ".github/workflows",
114
+ ".gitlab-ci.yml",
115
+ ".circleci/config.yml",
116
+ "Jenkinsfile",
117
+ ".travis.yml",
118
+ ]
119
+ for ci_path in ci_paths:
120
+ if (repo_path / ci_path).exists():
121
+ return True
122
+ return False
123
+
124
+
125
+ def _find_entry_points(repo_path: Path, parsed_files: list[ParsedFile]) -> list[str]:
126
+ entry_points = []
127
+ candidates = ["main.py", "app.py", "cli.py", "main.go", "main.rs", "index.js", "index.ts"]
128
+ for candidate in candidates:
129
+ if (repo_path / candidate).exists():
130
+ entry_points.append(candidate)
131
+ return entry_points
@@ -0,0 +1,120 @@
1
+ import json
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+
6
+ def detect_dependencies(repo_path: Path) -> dict[str, list[str]]:
7
+ """Read manifest files and return dependencies per language/ecosystem."""
8
+ deps: dict[str, list[str]] = {}
9
+
10
+ # Python
11
+ py_deps = _detect_python(repo_path)
12
+ if py_deps:
13
+ deps["python"] = py_deps
14
+
15
+ # JavaScript/TypeScript
16
+ js_deps = _detect_javascript(repo_path)
17
+ if js_deps:
18
+ deps["javascript"] = js_deps
19
+
20
+ # Go
21
+ go_deps = _detect_go(repo_path)
22
+ if go_deps:
23
+ deps["go"] = go_deps
24
+
25
+ # Rust
26
+ rust_deps = _detect_rust(repo_path)
27
+ if rust_deps:
28
+ deps["rust"] = rust_deps
29
+
30
+ # Java
31
+ java_deps = _detect_java(repo_path)
32
+ if java_deps:
33
+ deps["java"] = java_deps
34
+
35
+ return deps
36
+
37
+
38
+ def _detect_python(repo_path: Path) -> list[str]:
39
+ deps = []
40
+
41
+ pyproject = repo_path / "pyproject.toml"
42
+ if pyproject.exists():
43
+ try:
44
+ with open(pyproject, "rb") as f:
45
+ data = tomllib.load(f)
46
+ deps += data.get("project", {}).get("dependencies", [])
47
+ except Exception:
48
+ pass
49
+
50
+ requirements = repo_path / "requirements.txt"
51
+ if requirements.exists():
52
+ try:
53
+ lines = requirements.read_text().splitlines()
54
+ deps += [l.strip() for l in lines if l.strip() and not l.startswith("#")]
55
+ except Exception:
56
+ pass
57
+
58
+ return deps
59
+
60
+
61
+ def _detect_javascript(repo_path: Path) -> list[str]:
62
+ package_json = repo_path / "package.json"
63
+ if not package_json.exists():
64
+ return []
65
+ try:
66
+ data = json.loads(package_json.read_text())
67
+ deps = list(data.get("dependencies", {}).keys())
68
+ deps += list(data.get("devDependencies", {}).keys())
69
+ return deps
70
+ except Exception:
71
+ return []
72
+
73
+
74
+ def _detect_go(repo_path: Path) -> list[str]:
75
+ go_mod = repo_path / "go.mod"
76
+ if not go_mod.exists():
77
+ return []
78
+ deps = []
79
+ try:
80
+ for line in go_mod.read_text().splitlines():
81
+ line = line.strip()
82
+ if line.startswith("require ") or (line and not line.startswith("//") and " v" in line):
83
+ parts = line.split()
84
+ if len(parts) >= 2 and "/" in parts[0]:
85
+ deps.append(parts[0])
86
+ except Exception:
87
+ pass
88
+ return deps
89
+
90
+
91
+ def _detect_rust(repo_path: Path) -> list[str]:
92
+ cargo_toml = repo_path / "Cargo.toml"
93
+ if not cargo_toml.exists():
94
+ return []
95
+ try:
96
+ with open(cargo_toml, "rb") as f:
97
+ data = tomllib.load(f)
98
+ deps = list(data.get("dependencies", {}).keys())
99
+ deps += list(data.get("dev-dependencies", {}).keys())
100
+ return deps
101
+ except Exception:
102
+ return []
103
+
104
+
105
+ def _detect_java(repo_path: Path) -> list[str]:
106
+ # Basic pom.xml detection
107
+ pom = repo_path / "pom.xml"
108
+ if pom.exists():
109
+ try:
110
+ content = pom.read_text()
111
+ # Very basic extraction - just flag that Maven is used
112
+ return ["[Maven - see pom.xml]"]
113
+ except Exception:
114
+ pass
115
+
116
+ build_gradle = repo_path / "build.gradle"
117
+ if build_gradle.exists():
118
+ return ["[Gradle - see build.gradle]"]
119
+
120
+ return []
@@ -0,0 +1,63 @@
1
+ import fnmatch
2
+ from pathlib import Path
3
+
4
+
5
+ BINARY_EXTENSIONS = {
6
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
7
+ ".pdf", ".zip", ".tar", ".gz", ".bz2", ".xz", ".rar",
8
+ ".exe", ".dll", ".so", ".dylib", ".a", ".o",
9
+ ".woff", ".woff2", ".ttf", ".eot",
10
+ ".mp3", ".mp4", ".wav", ".avi", ".mov",
11
+ ".db", ".sqlite", ".lock",
12
+ }
13
+
14
+
15
+ def walk_repo(
16
+ repo_path: Path,
17
+ exclude_patterns: list[str],
18
+ max_file_size_kb: int = 512,
19
+ supported_extensions: list[str] | None = None,
20
+ ):
21
+ """
22
+ Yield (file_path, extension) for all code files in repo_path.
23
+ Respects exclude patterns and size limits.
24
+ """
25
+ max_bytes = max_file_size_kb * 1024
26
+
27
+ for file_path in sorted(repo_path.rglob("*")):
28
+ if not file_path.is_file():
29
+ continue
30
+
31
+ # Check binary extension
32
+ if file_path.suffix.lower() in BINARY_EXTENSIONS:
33
+ continue
34
+
35
+ # Check exclude patterns
36
+ relative = str(file_path.relative_to(repo_path))
37
+ if _is_excluded(relative, exclude_patterns):
38
+ continue
39
+
40
+ # Check supported extension
41
+ ext = file_path.suffix.lower()
42
+ if supported_extensions and ext not in supported_extensions:
43
+ continue
44
+
45
+ # Check file size
46
+ try:
47
+ if file_path.stat().st_size > max_bytes:
48
+ continue
49
+ except OSError:
50
+ continue
51
+
52
+ yield file_path, ext
53
+
54
+
55
+ def _is_excluded(relative_path: str, patterns: list[str]) -> bool:
56
+ for pattern in patterns:
57
+ if fnmatch.fnmatch(relative_path, pattern):
58
+ return True
59
+ # Also match path components
60
+ parts = relative_path.replace("\\", "/")
61
+ if fnmatch.fnmatch(parts, pattern):
62
+ return True
63
+ return False
@@ -0,0 +1,53 @@
1
+ from concurrent.futures import ThreadPoolExecutor, as_completed
2
+ from pathlib import Path
3
+
4
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
5
+
6
+ from ..config.schema import DocForgeConfig
7
+ from ..parsing.base_parser import ParsedFile
8
+ from ..parsing.registry import get_parser, supported_extensions
9
+ from .file_walker import walk_repo
10
+
11
+
12
+ def analyze_repo(repo_path: Path, config: DocForgeConfig) -> list[ParsedFile]:
13
+ """Parse all code files in repo_path and return ParsedFile list."""
14
+ exts = supported_extensions()
15
+ files_to_parse = list(walk_repo(
16
+ repo_path,
17
+ exclude_patterns=config.exclude_patterns,
18
+ max_file_size_kb=config.max_file_size_kb,
19
+ supported_extensions=exts,
20
+ ))
21
+
22
+ parsed: list[ParsedFile] = []
23
+
24
+ with Progress(
25
+ SpinnerColumn(),
26
+ TextColumn("[bold blue]Parsing files..."),
27
+ BarColumn(),
28
+ TaskProgressColumn(),
29
+ transient=True,
30
+ ) as progress:
31
+ task = progress.add_task("parsing", total=len(files_to_parse))
32
+
33
+ def parse_file(args):
34
+ file_path, ext = args
35
+ parser = get_parser(ext)
36
+ if parser is None:
37
+ return None
38
+ try:
39
+ source = file_path.read_bytes()
40
+ return parser.parse(file_path, source)
41
+ except Exception:
42
+ return None
43
+ finally:
44
+ progress.advance(task)
45
+
46
+ with ThreadPoolExecutor(max_workers=8) as executor:
47
+ futures = {executor.submit(parse_file, item): item for item in files_to_parse}
48
+ for future in as_completed(futures):
49
+ result = future.result()
50
+ if result is not None:
51
+ parsed.append(result)
52
+
53
+ return parsed
@@ -0,0 +1,3 @@
1
+ from docforge import __version__
2
+
3
+ __all__ = ["__version__"]
docforge/cli/app.py ADDED
@@ -0,0 +1,29 @@
1
+ import typer
2
+ from . import __version__
3
+ from .commands.generate import generate
4
+
5
+ app = typer.Typer(
6
+ name="docforge",
7
+ help="AI-powered documentation generator for any GitHub repository.",
8
+ add_completion=False,
9
+ rich_markup_mode="rich",
10
+ )
11
+
12
+ app.command(name="generate", help="Generate documentation for a repo.")(generate)
13
+
14
+
15
+ def version_callback(value: bool):
16
+ if value:
17
+ typer.echo(f"DocForge v{__version__}")
18
+ raise typer.Exit()
19
+
20
+
21
+ @app.callback()
22
+ def main_callback(
23
+ version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True),
24
+ ):
25
+ pass
26
+
27
+
28
+ def main():
29
+ app()
File without changes
@@ -0,0 +1,171 @@
1
+ from pathlib import Path
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+
4
+ import typer
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+ from rich import box
8
+
9
+ from ..console import console
10
+ from ...config.loader import load_config
11
+ from ...source.resolver import SourceResolver
12
+ from ...git.repo_reader import read_repo
13
+ from ...git.changelog_builder import build_changelog
14
+ from ...analysis.repo_analyzer import analyze_repo
15
+ from ...analysis.dependency_detector import detect_dependencies
16
+ from ...analysis.context_builder import build_context
17
+ from ...llm.client import LLMClient
18
+ from ...llm.prompt_manager import PromptManager
19
+ from ...generators.readme_generator import ReadmeGenerator
20
+ from ...generators.installation_generator import InstallationGenerator
21
+ from ...generators.api_generator import ApiGenerator
22
+ from ...generators.architecture_generator import ArchitectureGenerator
23
+ from ...generators.changelog_generator import ChangelogGenerator
24
+ from ...generators.base_generator import GeneratorResult
25
+ from ...output.writer import write_results
26
+ from ...output.mkdocs_builder import build_mkdocs
27
+ from ...output.github_action_writer import write_github_action
28
+
29
+
30
+ def generate(
31
+ source: str = typer.Argument(..., help="GitHub URL or local path to repository"),
32
+ model: str = typer.Option(None, "--model", "-m", help="LLM model (e.g. gpt-4o, claude-sonnet-4-6, ollama/llama3)"),
33
+ api_base: str = typer.Option(None, "--api-base", help="Custom API base URL (for Ollama: http://localhost:11434)"),
34
+ output_dir: str = typer.Option(None, "--output-dir", "-o", help="Output directory (default: ./docs)"),
35
+ mkdocs: bool = typer.Option(False, "--mkdocs", help="Generate mkdocs.yml"),
36
+ github_action: bool = typer.Option(False, "--github-action", help="Generate GitHub Action workflow"),
37
+ overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing docs"),
38
+ no_api: bool = typer.Option(False, "--no-api", help="Skip per-file API docs generation"),
39
+ no_changelog: bool = typer.Option(False, "--no-changelog", help="Skip changelog generation"),
40
+ ):
41
+ """Generate documentation for a GitHub repository or local project."""
42
+
43
+ console.print(Panel.fit(
44
+ "[bold blue]DocForge[/bold blue] — AI-powered documentation generator",
45
+ border_style="blue",
46
+ ))
47
+
48
+ # 1. Resolve source
49
+ with console.status("[bold]Resolving source..."):
50
+ resolver = SourceResolver()
51
+ try:
52
+ repo_path, gh_metadata = resolver.resolve(source)
53
+ except Exception as e:
54
+ console.print(f"[red]Error resolving source:[/red] {e}")
55
+ raise typer.Exit(1)
56
+
57
+ console.print(f"[green]✓[/green] Source: [bold]{repo_path}[/bold]")
58
+
59
+ # 2. Load config
60
+ config = load_config(
61
+ repo_path=repo_path,
62
+ model=model,
63
+ api_base=api_base,
64
+ output_dir=output_dir,
65
+ mkdocs=mkdocs,
66
+ github_action=github_action,
67
+ overwrite=overwrite,
68
+ )
69
+ out_dir = Path(config.output.directory)
70
+
71
+ console.print(f"[green]✓[/green] Model: [bold]{config.llm.model}[/bold]")
72
+ console.print(f"[green]✓[/green] Output: [bold]{out_dir}[/bold]")
73
+
74
+ # 3. Git history
75
+ with console.status("[bold]Reading git history..."):
76
+ commits, tags = read_repo(repo_path)
77
+ commit_groups = build_changelog(commits) if commits else []
78
+
79
+ console.print(f"[green]✓[/green] Git: {len(commits)} commits, {len(tags)} tags")
80
+
81
+ # 4. Parse code
82
+ console.print("[bold]Parsing code...[/bold]")
83
+ parsed_files = analyze_repo(repo_path, config)
84
+ console.print(f"[green]✓[/green] Parsed {len(parsed_files)} files")
85
+
86
+ # 5. Detect dependencies
87
+ with console.status("[bold]Detecting dependencies..."):
88
+ dependencies = detect_dependencies(repo_path)
89
+
90
+ # 6. Build context
91
+ context = build_context(
92
+ repo_path=repo_path,
93
+ parsed_files=parsed_files,
94
+ dependencies=dependencies,
95
+ commit_groups=commit_groups,
96
+ gh_metadata=gh_metadata,
97
+ )
98
+
99
+ console.print(f"[green]✓[/green] Project: [bold]{context.name}[/bold] ({', '.join(context.languages) or 'unknown'})")
100
+
101
+ # 7. Initialize LLM
102
+ llm = LLMClient(config.llm)
103
+ prompts = PromptManager()
104
+
105
+ # 8. Run generators
106
+ console.print("\n[bold]Generating documentation...[/bold]")
107
+ all_results: list[GeneratorResult] = []
108
+
109
+ generators = [
110
+ ("README", ReadmeGenerator(llm, prompts)),
111
+ ("Installation", InstallationGenerator(llm, prompts)),
112
+ ("Architecture", ArchitectureGenerator(llm, prompts)),
113
+ ]
114
+
115
+ if not no_changelog:
116
+ generators.append(("Changelog", ChangelogGenerator(llm, prompts)))
117
+
118
+ def run_generator(name_gen):
119
+ name, gen = name_gen
120
+ with console.status(f"[bold]Generating {name}..."):
121
+ result = gen.generate(context)
122
+ console.print(f"[green]✓[/green] {name}")
123
+ return result
124
+
125
+ # Run core generators (README, Installation, Architecture, Changelog) in parallel
126
+ with ThreadPoolExecutor(max_workers=4) as executor:
127
+ futures = {executor.submit(run_generator, ng): ng[0] for ng in generators}
128
+ for future in as_completed(futures):
129
+ try:
130
+ all_results.append(future.result())
131
+ except Exception as e:
132
+ name = futures[future]
133
+ console.print(f"[red]✗[/red] {name}: {e}")
134
+
135
+ # API docs (sequential to avoid rate limits)
136
+ if not no_api:
137
+ with console.status("[bold]Generating API docs..."):
138
+ api_gen = ApiGenerator(llm, prompts)
139
+ try:
140
+ api_results = api_gen.generate_all(context)
141
+ all_results.extend(api_results)
142
+ console.print(f"[green]✓[/green] API docs ({len(api_results)} files)")
143
+ except Exception as e:
144
+ console.print(f"[red]✗[/red] API docs: {e}")
145
+
146
+ # 9. Write output
147
+ with console.status("[bold]Writing files..."):
148
+ written = write_results(all_results, out_dir, overwrite=config.output.overwrite)
149
+
150
+ # 10. Optional outputs
151
+ if config.output.mkdocs:
152
+ with console.status("[bold]Generating mkdocs.yml..."):
153
+ mkdocs_path = build_mkdocs(context, all_results, out_dir, prompts)
154
+ console.print(f"[green]✓[/green] MkDocs config: {mkdocs_path}")
155
+
156
+ if config.output.github_action:
157
+ with console.status("[bold]Writing GitHub Action..."):
158
+ action_path = write_github_action(repo_path, config.llm, prompts)
159
+ console.print(f"[green]✓[/green] GitHub Action: {action_path}")
160
+
161
+ # 11. Summary table
162
+ table = Table(title="\nGenerated Documentation", box=box.ROUNDED)
163
+ table.add_column("File", style="cyan")
164
+ table.add_column("Size", justify="right", style="green")
165
+
166
+ for path in sorted(written):
167
+ size = path.stat().st_size
168
+ table.add_row(str(path.relative_to(Path.cwd()) if path.is_relative_to(Path.cwd()) else path), f"{size:,} bytes")
169
+
170
+ console.print(table)
171
+ console.print(f"\n[bold green]Done![/bold green] {len(written)} files written to [bold]{out_dir}[/bold]")
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console()
@@ -0,0 +1,4 @@
1
+ from .schema import DocForgeConfig, LLMConfig, OutputConfig, GeneratorConfig
2
+ from .loader import load_config
3
+
4
+ __all__ = ["DocForgeConfig", "LLMConfig", "OutputConfig", "GeneratorConfig", "load_config"]
@@ -0,0 +1,48 @@
1
+ import os
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+ from .schema import DocForgeConfig, LLMConfig, OutputConfig
6
+
7
+
8
+ def load_config(
9
+ repo_path: Path,
10
+ model: str | None = None,
11
+ api_base: str | None = None,
12
+ output_dir: str | None = None,
13
+ mkdocs: bool = False,
14
+ github_action: bool = False,
15
+ overwrite: bool = False,
16
+ ) -> DocForgeConfig:
17
+ """Load config from .docforge.toml if present, then apply CLI overrides."""
18
+ config = DocForgeConfig()
19
+
20
+ config_file = repo_path / ".docforge.toml"
21
+ if config_file.exists():
22
+ with open(config_file, "rb") as f:
23
+ data = tomllib.load(f)
24
+ config = DocForgeConfig.model_validate(data)
25
+
26
+ # Apply CLI flag overrides
27
+ if model:
28
+ config.llm.model = model
29
+ if api_base:
30
+ config.llm.api_base = api_base
31
+ if output_dir:
32
+ config.output.directory = output_dir
33
+ if mkdocs:
34
+ config.output.mkdocs = True
35
+ if github_action:
36
+ config.output.github_action = True
37
+ if overwrite:
38
+ config.output.overwrite = True
39
+
40
+ # Apply environment variable overrides
41
+ if env_key := os.getenv("OPENAI_API_KEY"):
42
+ config.llm.api_key = config.llm.api_key or env_key
43
+ if env_model := os.getenv("DOCFORGE_MODEL"):
44
+ config.llm.model = env_model
45
+ if env_base := os.getenv("DOCFORGE_API_BASE"):
46
+ config.llm.api_base = env_base
47
+
48
+ return config
@@ -0,0 +1,40 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class LLMConfig(BaseModel):
5
+ model: str = "gpt-4o"
6
+ api_base: str | None = None # For Ollama: "http://localhost:11434"
7
+ api_key: str | None = None
8
+ max_tokens: int = 4096
9
+ temperature: float = 0.2
10
+
11
+
12
+ class GeneratorConfig(BaseModel):
13
+ enabled: bool = True
14
+ extra_instructions: str = ""
15
+
16
+
17
+ class OutputConfig(BaseModel):
18
+ directory: str = "./docs"
19
+ mkdocs: bool = False
20
+ github_action: bool = False
21
+ overwrite: bool = False
22
+
23
+
24
+ class DocForgeConfig(BaseModel):
25
+ llm: LLMConfig = Field(default_factory=LLMConfig)
26
+ output: OutputConfig = Field(default_factory=OutputConfig)
27
+ generators: dict[str, GeneratorConfig] = Field(default_factory=dict)
28
+ exclude_patterns: list[str] = [
29
+ "**/vendor/**",
30
+ "**/node_modules/**",
31
+ "**/.git/**",
32
+ "**/dist/**",
33
+ "**/build/**",
34
+ "**/__pycache__/**",
35
+ "**/*.pyc",
36
+ "**/*.min.js",
37
+ "**/*.min.css",
38
+ ]
39
+ max_file_size_kb: int = 512
40
+ languages: list[str] = ["python", "javascript", "typescript", "go", "rust", "java"]