codegraph-cli 2.1.0__py3-none-any.whl → 2.1.2__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.
- codegraph_cli/__init__.py +1 -1
- codegraph_cli/agents.py +59 -3
- codegraph_cli/chat_agent.py +58 -11
- codegraph_cli/cli.py +569 -54
- codegraph_cli/cli_chat.py +204 -94
- codegraph_cli/cli_diagnose.py +13 -2
- codegraph_cli/cli_docs.py +207 -0
- codegraph_cli/cli_explore.py +1053 -0
- codegraph_cli/cli_export.py +941 -0
- codegraph_cli/cli_groups.py +33 -0
- codegraph_cli/cli_health.py +316 -0
- codegraph_cli/cli_history.py +213 -0
- codegraph_cli/cli_onboard.py +380 -0
- codegraph_cli/cli_quickstart.py +256 -0
- codegraph_cli/cli_refactor.py +17 -3
- codegraph_cli/cli_setup.py +12 -12
- codegraph_cli/cli_suggestions.py +90 -0
- codegraph_cli/cli_test.py +17 -3
- codegraph_cli/cli_tui.py +210 -0
- codegraph_cli/cli_v2.py +24 -4
- codegraph_cli/cli_watch.py +158 -0
- codegraph_cli/cli_workflows.py +255 -0
- codegraph_cli/codegen_agent.py +15 -1
- codegraph_cli/config.py +18 -5
- codegraph_cli/context_manager.py +117 -15
- codegraph_cli/crew_agents.py +32 -8
- codegraph_cli/crew_chat.py +146 -13
- codegraph_cli/crew_tools.py +30 -2
- codegraph_cli/embeddings.py +95 -5
- codegraph_cli/llm.py +42 -55
- codegraph_cli/project_context.py +64 -1
- codegraph_cli/rag.py +282 -19
- codegraph_cli/storage.py +310 -14
- codegraph_cli/vector_store.py +110 -8
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/METADATA +75 -21
- codegraph_cli-2.1.2.dist-info/RECORD +55 -0
- codegraph_cli-2.1.2.dist-info/entry_points.txt +2 -0
- codegraph_cli-2.1.0.dist-info/RECORD +0 -43
- codegraph_cli-2.1.0.dist-info/entry_points.txt +0 -2
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/WHEEL +0 -0
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/licenses/LICENSE +0 -0
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
"""Export indexed project data to DOCX format.
|
|
2
|
+
|
|
3
|
+
Two-tier export:
|
|
4
|
+
1. **Basic** (no LLM): structure + diagram + code listings
|
|
5
|
+
2. **Enhanced** (with LLM): adds AI explanations via hierarchical smart RAG
|
|
6
|
+
|
|
7
|
+
Uses ``python-docx`` for DOCX generation and embeds Mermaid diagrams
|
|
8
|
+
as PNG images (rendered via the Mermaid Ink online service).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from base64 import b64encode
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
import typer
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
export_app = typer.Typer(
|
|
32
|
+
help="📄 Export project documentation to DOCX.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
rich_markup_mode="rich",
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ===================================================================
|
|
40
|
+
# DOCX generation helpers
|
|
41
|
+
# ===================================================================
|
|
42
|
+
|
|
43
|
+
def _ensure_docx() -> Any:
|
|
44
|
+
"""Import python-docx or raise a friendly error."""
|
|
45
|
+
try:
|
|
46
|
+
import docx
|
|
47
|
+
return docx
|
|
48
|
+
except ImportError:
|
|
49
|
+
raise typer.Exit(
|
|
50
|
+
"python-docx is required for export. Install it:\n"
|
|
51
|
+
" pip install python-docx"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mermaid_to_png(mermaid_code: str) -> Optional[bytes]:
|
|
56
|
+
"""Render Mermaid code to PNG via the mermaid.ink online service.
|
|
57
|
+
|
|
58
|
+
Falls back to None on network errors so the export can continue
|
|
59
|
+
without embedded diagrams.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
import base64 as _b64
|
|
63
|
+
|
|
64
|
+
# mermaid.ink accepts base64-encoded mermaid text
|
|
65
|
+
encoded = _b64.urlsafe_b64encode(mermaid_code.encode("utf-8")).decode("ascii")
|
|
66
|
+
url = f"https://mermaid.ink/img/{encoded}?type=png&bgColor=!0d1117&theme=dark"
|
|
67
|
+
|
|
68
|
+
req = urllib.request.Request(url, headers={"User-Agent": "CodeGraph-CLI/2.0"})
|
|
69
|
+
with urllib.request.urlopen(req, timeout=25) as resp:
|
|
70
|
+
return resp.read()
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
logger.warning("Mermaid-to-PNG render failed: %s", exc)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _add_mermaid_code_block(doc: Any, mermaid_code: str) -> None:
|
|
77
|
+
"""Add Mermaid source as a styled code block (fallback when image fails)."""
|
|
78
|
+
from docx.shared import Pt, RGBColor
|
|
79
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
80
|
+
|
|
81
|
+
p = doc.add_paragraph()
|
|
82
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
83
|
+
run = p.add_run(mermaid_code)
|
|
84
|
+
run.font.name = "Consolas"
|
|
85
|
+
run.font.size = Pt(8)
|
|
86
|
+
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _add_code_block(doc: Any, code: str, language: str = "python") -> None:
|
|
90
|
+
"""Add a monospaced code block to the DOCX document."""
|
|
91
|
+
from docx.shared import Pt, RGBColor
|
|
92
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
93
|
+
|
|
94
|
+
p = doc.add_paragraph()
|
|
95
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
96
|
+
p.style = doc.styles["No Spacing"]
|
|
97
|
+
# Limit very long files
|
|
98
|
+
display_code = code if len(code) < 20_000 else code[:20_000] + "\n\n# ... (truncated)"
|
|
99
|
+
run = p.add_run(display_code)
|
|
100
|
+
run.font.name = "Consolas"
|
|
101
|
+
run.font.size = Pt(8)
|
|
102
|
+
run.font.color.rgb = RGBColor(0xC9, 0xD1, 0xD9)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _add_table(doc: Any, headers: List[str], rows: List[List[str]]) -> None:
|
|
106
|
+
"""Add a simple styled table to the DOCX."""
|
|
107
|
+
from docx.shared import Pt
|
|
108
|
+
|
|
109
|
+
table = doc.add_table(rows=1, cols=len(headers))
|
|
110
|
+
table.style = "Light Shading Accent 1"
|
|
111
|
+
# Header row
|
|
112
|
+
for i, h in enumerate(headers):
|
|
113
|
+
cell = table.rows[0].cells[i]
|
|
114
|
+
cell.text = h
|
|
115
|
+
for p in cell.paragraphs:
|
|
116
|
+
for r in p.runs:
|
|
117
|
+
r.bold = True
|
|
118
|
+
r.font.size = Pt(9)
|
|
119
|
+
# Data rows
|
|
120
|
+
for row_data in rows:
|
|
121
|
+
row = table.add_row()
|
|
122
|
+
for i, val in enumerate(row_data):
|
|
123
|
+
row.cells[i].text = str(val)
|
|
124
|
+
for p in row.cells[i].paragraphs:
|
|
125
|
+
for r in p.runs:
|
|
126
|
+
r.font.size = Pt(9)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _embed_diagram(doc: Any, mermaid_code: str, caption: str = "") -> None:
|
|
130
|
+
"""Attempt to embed a Mermaid diagram as a PNG image.
|
|
131
|
+
|
|
132
|
+
Falls back to a code block with the Mermaid source if rendering fails.
|
|
133
|
+
"""
|
|
134
|
+
from docx.shared import Inches
|
|
135
|
+
|
|
136
|
+
png_data = _mermaid_to_png(mermaid_code)
|
|
137
|
+
if png_data:
|
|
138
|
+
stream = io.BytesIO(png_data)
|
|
139
|
+
doc.add_picture(stream, width=Inches(6.0))
|
|
140
|
+
if caption:
|
|
141
|
+
p = doc.add_paragraph(caption)
|
|
142
|
+
p.style = doc.styles["Caption"] if "Caption" in [s.name for s in doc.styles] else doc.styles["Normal"]
|
|
143
|
+
else:
|
|
144
|
+
doc.add_paragraph("(Diagram rendered as Mermaid code — paste into mermaid.live to view)")
|
|
145
|
+
_add_mermaid_code_block(doc, mermaid_code)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ===================================================================
|
|
149
|
+
# Data gathering from GraphStore
|
|
150
|
+
# ===================================================================
|
|
151
|
+
|
|
152
|
+
def _gather_project_data(store: Any) -> Dict[str, Any]:
|
|
153
|
+
"""Gather all project data needed for the DOCX export."""
|
|
154
|
+
from .cli_explore import _build_api_tree, _generate_file_mermaid, _generate_system_mermaid
|
|
155
|
+
|
|
156
|
+
nodes = store.get_nodes()
|
|
157
|
+
edges = store.get_edges()
|
|
158
|
+
nodes_by_file = store.all_by_file()
|
|
159
|
+
metadata = store.get_metadata()
|
|
160
|
+
|
|
161
|
+
# Stats
|
|
162
|
+
files = sorted(nodes_by_file.keys())
|
|
163
|
+
functions = [n for n in nodes if n["node_type"] == "function"]
|
|
164
|
+
classes = [n for n in nodes if n["node_type"] == "class"]
|
|
165
|
+
|
|
166
|
+
# Language breakdown by extension
|
|
167
|
+
ext_counter: Counter = Counter()
|
|
168
|
+
for fp in files:
|
|
169
|
+
ext = Path(fp).suffix or "(no ext)"
|
|
170
|
+
ext_counter[ext] += 1
|
|
171
|
+
|
|
172
|
+
# Directory tree (reuse explore helper)
|
|
173
|
+
tree = _build_api_tree(store)
|
|
174
|
+
|
|
175
|
+
# System Mermaid diagram
|
|
176
|
+
system_mermaid = _generate_system_mermaid(store)
|
|
177
|
+
|
|
178
|
+
# Per-file Mermaid diagrams
|
|
179
|
+
file_mermaids: Dict[str, str] = {}
|
|
180
|
+
for fp, fnodes in nodes_by_file.items():
|
|
181
|
+
diagram = _generate_file_mermaid(store, fp, fnodes)
|
|
182
|
+
if diagram:
|
|
183
|
+
file_mermaids[fp] = diagram
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"metadata": metadata,
|
|
187
|
+
"files": files,
|
|
188
|
+
"nodes_by_file": nodes_by_file,
|
|
189
|
+
"functions": functions,
|
|
190
|
+
"classes": classes,
|
|
191
|
+
"edges": edges,
|
|
192
|
+
"total_nodes": len(nodes),
|
|
193
|
+
"ext_breakdown": ext_counter,
|
|
194
|
+
"tree": tree,
|
|
195
|
+
"system_mermaid": system_mermaid,
|
|
196
|
+
"file_mermaids": file_mermaids,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _build_text_tree(node: Dict, indent: int = 0) -> str:
|
|
201
|
+
"""Render the JSON tree as an indented text tree."""
|
|
202
|
+
lines: list[str] = []
|
|
203
|
+
prefix = " " * indent
|
|
204
|
+
if node.get("type") == "dir" and node.get("name") != "root":
|
|
205
|
+
lines.append(f"{prefix}📁 {node['name']}/")
|
|
206
|
+
elif node.get("type") == "file":
|
|
207
|
+
lines.append(f"{prefix}📄 {node['name']}")
|
|
208
|
+
|
|
209
|
+
for child in sorted(node.get("children", []), key=lambda c: (c["type"] != "dir", c["name"])):
|
|
210
|
+
lines.append(_build_text_tree(child, indent + 1))
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _get_file_deps(store: Any, file_path: str, nodes_by_file: Dict) -> Tuple[List[str], List[str]]:
|
|
215
|
+
"""Get dependencies and dependents for a file."""
|
|
216
|
+
file_nodes = nodes_by_file.get(file_path, [])
|
|
217
|
+
node_ids = {n.get("node_id") for n in file_nodes}
|
|
218
|
+
deps: set[str] = set()
|
|
219
|
+
dependents: set[str] = set()
|
|
220
|
+
|
|
221
|
+
for nid in node_ids:
|
|
222
|
+
for edge in store.neighbors(nid):
|
|
223
|
+
dst = edge["dst"] if isinstance(edge, dict) else edge[1]
|
|
224
|
+
dst_node = store.get_node(dst)
|
|
225
|
+
if dst_node:
|
|
226
|
+
deps.add(dst_node["qualname"])
|
|
227
|
+
for edge in store.reverse_neighbors(nid):
|
|
228
|
+
src = edge["src"] if isinstance(edge, dict) else edge[0]
|
|
229
|
+
src_node = store.get_node(src)
|
|
230
|
+
if src_node:
|
|
231
|
+
dependents.add(src_node["qualname"])
|
|
232
|
+
|
|
233
|
+
return sorted(deps), sorted(dependents)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ===================================================================
|
|
237
|
+
# LLM-enhanced explanations — hierarchical strategy
|
|
238
|
+
# ===================================================================
|
|
239
|
+
|
|
240
|
+
def _create_llm(provider: str, model: str, api_key: str) -> Optional[Any]:
|
|
241
|
+
"""Create an LLM instance or return None."""
|
|
242
|
+
try:
|
|
243
|
+
from .llm import LocalLLM
|
|
244
|
+
return LocalLLM(model=model, provider=provider, api_key=api_key)
|
|
245
|
+
except Exception:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _explain_project_overview(
|
|
250
|
+
llm: Any, metadata: Dict, data: Dict, store: Any,
|
|
251
|
+
) -> Optional[str]:
|
|
252
|
+
"""Generate a project-level architecture overview via LLM."""
|
|
253
|
+
# Gather compact context: directory structure + top connected files
|
|
254
|
+
tree_text = _build_text_tree(data["tree"])
|
|
255
|
+
# Top files by connectivity
|
|
256
|
+
connectivity: list[tuple[str, int]] = []
|
|
257
|
+
nodes_by_file = data["nodes_by_file"]
|
|
258
|
+
for fp, fnodes in nodes_by_file.items():
|
|
259
|
+
nids = {n["node_id"] for n in fnodes}
|
|
260
|
+
edge_count = sum(1 for nid in nids for _ in store.neighbors(nid))
|
|
261
|
+
edge_count += sum(1 for nid in nids for _ in store.reverse_neighbors(nid))
|
|
262
|
+
connectivity.append((fp, edge_count))
|
|
263
|
+
connectivity.sort(key=lambda x: x[1], reverse=True)
|
|
264
|
+
top_files = [f for f, _ in connectivity[:10]]
|
|
265
|
+
|
|
266
|
+
prompt = (
|
|
267
|
+
"You are a senior software architect. Explain this project's "
|
|
268
|
+
"architecture in 2-3 clear paragraphs. Focus on the overall design, "
|
|
269
|
+
"key modules, and how they interact.\n\n"
|
|
270
|
+
f"Project: {metadata.get('project_name', 'Unknown')}\n"
|
|
271
|
+
f"Files: {len(data['files'])}, Functions: {len(data['functions'])}, "
|
|
272
|
+
f"Classes: {len(data['classes'])}\n\n"
|
|
273
|
+
f"Directory structure:\n{tree_text[:2000]}\n\n"
|
|
274
|
+
f"Most connected files: {', '.join(top_files)}"
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
return llm.explain(prompt)
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
logger.warning("Project overview LLM call failed: %s", exc)
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _explain_module(
|
|
284
|
+
llm: Any, module_path: str, module_files: List[str],
|
|
285
|
+
store: Any, nodes_by_file: Dict,
|
|
286
|
+
) -> Optional[str]:
|
|
287
|
+
"""Generate a per-module/directory summary via LLM."""
|
|
288
|
+
# Gather file summaries for the module
|
|
289
|
+
file_summaries: list[str] = []
|
|
290
|
+
for fp in module_files[:15]: # Limit to 15 files per module
|
|
291
|
+
fnodes = nodes_by_file.get(fp, [])
|
|
292
|
+
fns = [n["name"] for n in fnodes if n.get("node_type") == "function"][:10]
|
|
293
|
+
clss = [n["name"] for n in fnodes if n.get("node_type") == "class"][:5]
|
|
294
|
+
sig = f" {fp}: functions={fns}, classes={clss}"
|
|
295
|
+
file_summaries.append(sig)
|
|
296
|
+
|
|
297
|
+
context = "\n".join(file_summaries)[:3000]
|
|
298
|
+
|
|
299
|
+
prompt = (
|
|
300
|
+
f"Explain the purpose of the '{module_path}' module/directory "
|
|
301
|
+
f"in 2-3 sentences. What is its role and key components?\n\n"
|
|
302
|
+
f"Files in module:\n{context}"
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
return llm.explain(prompt)
|
|
306
|
+
except Exception:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _explain_file_for_export(
|
|
311
|
+
llm: Any, file_path: str, store: Any, nodes_by_file: Dict,
|
|
312
|
+
source_root: str,
|
|
313
|
+
) -> Optional[str]:
|
|
314
|
+
"""Generate a per-file explanation via LLM."""
|
|
315
|
+
# Read actual file if available
|
|
316
|
+
content = ""
|
|
317
|
+
if source_root:
|
|
318
|
+
actual = Path(source_root) / file_path
|
|
319
|
+
if actual.exists():
|
|
320
|
+
try:
|
|
321
|
+
content = actual.read_text(encoding="utf-8", errors="replace")[:4000]
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
if not content:
|
|
326
|
+
fnodes = nodes_by_file.get(file_path, [])
|
|
327
|
+
content = "\n".join(n.get("code", "")[:500] for n in fnodes[:5])
|
|
328
|
+
|
|
329
|
+
if not content:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
deps, dependents = _get_file_deps(store, file_path, nodes_by_file)
|
|
333
|
+
|
|
334
|
+
prompt = (
|
|
335
|
+
f"Explain the purpose of '{file_path}' in 2-3 sentences. "
|
|
336
|
+
f"Focus on its role and key functionality.\n\n"
|
|
337
|
+
f"Dependencies: {', '.join(deps[:10]) or 'none'}\n"
|
|
338
|
+
f"Dependents: {', '.join(dependents[:10]) or 'none'}\n\n"
|
|
339
|
+
f"```\n{content[:3000]}\n```"
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
return llm.explain(prompt)
|
|
343
|
+
except Exception:
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ===================================================================
|
|
348
|
+
# DOCX builders
|
|
349
|
+
# ===================================================================
|
|
350
|
+
|
|
351
|
+
def generate_basic_docx(
|
|
352
|
+
store: Any,
|
|
353
|
+
output_path: Path,
|
|
354
|
+
include_code: bool = False,
|
|
355
|
+
include_diagram: bool = True,
|
|
356
|
+
console: Optional[Console] = None,
|
|
357
|
+
) -> Path:
|
|
358
|
+
"""Generate a basic DOCX without LLM — structure + diagram + code.
|
|
359
|
+
|
|
360
|
+
Returns the path to the generated file.
|
|
361
|
+
"""
|
|
362
|
+
docx_mod = _ensure_docx()
|
|
363
|
+
from docx.shared import Inches, Pt, Cm, RGBColor
|
|
364
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
365
|
+
|
|
366
|
+
console = console or Console()
|
|
367
|
+
|
|
368
|
+
with Progress(
|
|
369
|
+
SpinnerColumn(),
|
|
370
|
+
TextColumn("[progress.description]{task.description}"),
|
|
371
|
+
BarColumn(),
|
|
372
|
+
TaskProgressColumn(),
|
|
373
|
+
console=console,
|
|
374
|
+
) as progress:
|
|
375
|
+
task = progress.add_task("Gathering project data...", total=4)
|
|
376
|
+
|
|
377
|
+
data = _gather_project_data(store)
|
|
378
|
+
metadata = data["metadata"]
|
|
379
|
+
progress.update(task, advance=1, description="Building document...")
|
|
380
|
+
|
|
381
|
+
# ── Create document ──────────────────────────────────────
|
|
382
|
+
doc = docx_mod.Document()
|
|
383
|
+
|
|
384
|
+
# Title
|
|
385
|
+
title = doc.add_heading(
|
|
386
|
+
f"{metadata.get('project_name', 'Project')} — Documentation", level=0,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# ── Section 1: Project Overview ──────────────────────────
|
|
390
|
+
doc.add_heading("1. Project Overview", level=1)
|
|
391
|
+
|
|
392
|
+
overview_items = [
|
|
393
|
+
("Project Name", metadata.get("project_name", "Unknown")),
|
|
394
|
+
("Source Path", metadata.get("source_path", "N/A")),
|
|
395
|
+
("Total Files", str(len(data["files"]))),
|
|
396
|
+
("Total Functions", str(len(data["functions"]))),
|
|
397
|
+
("Total Classes", str(len(data["classes"]))),
|
|
398
|
+
("Total Nodes", str(data["total_nodes"])),
|
|
399
|
+
("Total Edges", str(len(data["edges"]))),
|
|
400
|
+
]
|
|
401
|
+
_add_table(doc, ["Property", "Value"], overview_items)
|
|
402
|
+
|
|
403
|
+
# Language breakdown
|
|
404
|
+
if data["ext_breakdown"]:
|
|
405
|
+
doc.add_heading("Language Breakdown", level=2)
|
|
406
|
+
lang_rows = [(ext, str(count)) for ext, count in data["ext_breakdown"].most_common()]
|
|
407
|
+
_add_table(doc, ["Extension", "File Count"], lang_rows)
|
|
408
|
+
|
|
409
|
+
progress.update(task, advance=1, description="Adding directory tree...")
|
|
410
|
+
|
|
411
|
+
# ── Section 2: Directory Structure ───────────────────────
|
|
412
|
+
doc.add_heading("2. Directory Structure", level=1)
|
|
413
|
+
tree_text = _build_text_tree(data["tree"])
|
|
414
|
+
p = doc.add_paragraph()
|
|
415
|
+
run = p.add_run(tree_text)
|
|
416
|
+
run.font.name = "Consolas"
|
|
417
|
+
run.font.size = Pt(9)
|
|
418
|
+
|
|
419
|
+
# ── Section 3: Architecture Diagram ──────────────────────
|
|
420
|
+
doc.add_heading("3. Architecture Diagram", level=1)
|
|
421
|
+
doc.add_paragraph(
|
|
422
|
+
"System-level dependency diagram showing file-to-file relationships."
|
|
423
|
+
)
|
|
424
|
+
if include_diagram and data["system_mermaid"]:
|
|
425
|
+
_embed_diagram(doc, data["system_mermaid"], "System Architecture")
|
|
426
|
+
|
|
427
|
+
progress.update(task, advance=1, description="Adding file listings...")
|
|
428
|
+
|
|
429
|
+
# ── Section 4: File Listings ─────────────────────────────
|
|
430
|
+
doc.add_heading("4. File Listings", level=1)
|
|
431
|
+
|
|
432
|
+
source_root = metadata.get("source_path", "")
|
|
433
|
+
|
|
434
|
+
for i, fp in enumerate(data["files"]):
|
|
435
|
+
doc.add_heading(f"4.{i + 1} {fp}", level=2)
|
|
436
|
+
|
|
437
|
+
file_nodes = data["nodes_by_file"].get(fp, [])
|
|
438
|
+
fns = [n for n in file_nodes if n.get("node_type") == "function"]
|
|
439
|
+
clss = [n for n in file_nodes if n.get("node_type") == "class"]
|
|
440
|
+
|
|
441
|
+
# Symbols table
|
|
442
|
+
if fns or clss:
|
|
443
|
+
symbol_rows: list[list[str]] = []
|
|
444
|
+
for fn in fns:
|
|
445
|
+
symbol_rows.append([
|
|
446
|
+
fn.get("name", ""),
|
|
447
|
+
"function",
|
|
448
|
+
f"L{fn.get('start_line', '?')}-{fn.get('end_line', '?')}",
|
|
449
|
+
])
|
|
450
|
+
for cls in clss:
|
|
451
|
+
symbol_rows.append([
|
|
452
|
+
cls.get("name", ""),
|
|
453
|
+
"class",
|
|
454
|
+
f"L{cls.get('start_line', '?')}-{cls.get('end_line', '?')}",
|
|
455
|
+
])
|
|
456
|
+
_add_table(doc, ["Symbol", "Type", "Lines"], symbol_rows)
|
|
457
|
+
|
|
458
|
+
# Dependencies
|
|
459
|
+
deps, dependents = _get_file_deps(store, fp, data["nodes_by_file"])
|
|
460
|
+
if deps:
|
|
461
|
+
doc.add_paragraph(f"Dependencies: {', '.join(deps[:20])}")
|
|
462
|
+
if dependents:
|
|
463
|
+
doc.add_paragraph(f"Dependents: {', '.join(dependents[:20])}")
|
|
464
|
+
|
|
465
|
+
# Source code
|
|
466
|
+
if include_code and source_root:
|
|
467
|
+
actual = Path(source_root) / fp
|
|
468
|
+
if actual.exists():
|
|
469
|
+
try:
|
|
470
|
+
code = actual.read_text(encoding="utf-8", errors="replace")
|
|
471
|
+
_add_code_block(doc, code)
|
|
472
|
+
except Exception:
|
|
473
|
+
doc.add_paragraph("(Could not read source file)")
|
|
474
|
+
|
|
475
|
+
# Per-file diagram
|
|
476
|
+
file_mermaid = data["file_mermaids"].get(fp)
|
|
477
|
+
if include_diagram and file_mermaid:
|
|
478
|
+
doc.add_heading("File Diagram", level=3)
|
|
479
|
+
_embed_diagram(doc, file_mermaid, f"{Path(fp).name} internal structure")
|
|
480
|
+
|
|
481
|
+
progress.update(task, advance=1, description="Saving...")
|
|
482
|
+
|
|
483
|
+
doc.save(str(output_path))
|
|
484
|
+
|
|
485
|
+
return output_path
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def generate_enhanced_docx(
|
|
489
|
+
store: Any,
|
|
490
|
+
output_path: Path,
|
|
491
|
+
include_code: bool = False,
|
|
492
|
+
include_diagram: bool = True,
|
|
493
|
+
explanation_depth: str = "modules", # "overview" | "modules" | "files"
|
|
494
|
+
llm_provider: str = "",
|
|
495
|
+
llm_model: str = "",
|
|
496
|
+
llm_api_key: str = "",
|
|
497
|
+
console: Optional[Console] = None,
|
|
498
|
+
batch_size: int = 5,
|
|
499
|
+
) -> Path:
|
|
500
|
+
"""Generate an LLM-enhanced DOCX with AI explanations.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
explanation_depth: "overview" (just project), "modules" (per-dir),
|
|
504
|
+
or "files" (per-file).
|
|
505
|
+
batch_size: How many LLM calls per batch (rate limiting).
|
|
506
|
+
"""
|
|
507
|
+
docx_mod = _ensure_docx()
|
|
508
|
+
from docx.shared import Inches, Pt, RGBColor
|
|
509
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
510
|
+
|
|
511
|
+
console = console or Console()
|
|
512
|
+
|
|
513
|
+
llm = _create_llm(llm_provider, llm_model, llm_api_key)
|
|
514
|
+
if not llm:
|
|
515
|
+
console.print("[yellow]⚠ LLM not available — falling back to basic export.[/yellow]")
|
|
516
|
+
return generate_basic_docx(store, output_path, include_code, include_diagram, console)
|
|
517
|
+
|
|
518
|
+
data = _gather_project_data(store)
|
|
519
|
+
metadata = data["metadata"]
|
|
520
|
+
source_root = metadata.get("source_path", "")
|
|
521
|
+
|
|
522
|
+
# ── Determine explanation targets ────────────────────────────
|
|
523
|
+
explanation_targets: list[tuple[str, str]] = [] # (type, key)
|
|
524
|
+
explanation_targets.append(("project", "project"))
|
|
525
|
+
|
|
526
|
+
if explanation_depth in ("modules", "files"):
|
|
527
|
+
# Group files by top-level directory
|
|
528
|
+
modules: dict[str, list[str]] = {}
|
|
529
|
+
for fp in data["files"]:
|
|
530
|
+
parts = Path(fp).parts
|
|
531
|
+
module_name = parts[0] if len(parts) > 1 else "(root)"
|
|
532
|
+
modules.setdefault(module_name, []).append(fp)
|
|
533
|
+
for mod_name in sorted(modules):
|
|
534
|
+
explanation_targets.append(("module", mod_name))
|
|
535
|
+
|
|
536
|
+
if explanation_depth == "files":
|
|
537
|
+
for fp in data["files"]:
|
|
538
|
+
explanation_targets.append(("file", fp))
|
|
539
|
+
|
|
540
|
+
total_explanations = len(explanation_targets)
|
|
541
|
+
|
|
542
|
+
# ── Generate explanations with progress ──────────────────────
|
|
543
|
+
explanations: dict[str, str] = {}
|
|
544
|
+
|
|
545
|
+
with Progress(
|
|
546
|
+
SpinnerColumn(),
|
|
547
|
+
TextColumn("[progress.description]{task.description}"),
|
|
548
|
+
BarColumn(),
|
|
549
|
+
TaskProgressColumn(),
|
|
550
|
+
console=console,
|
|
551
|
+
) as progress:
|
|
552
|
+
task = progress.add_task(
|
|
553
|
+
f"Generating {total_explanations} AI explanations...",
|
|
554
|
+
total=total_explanations,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Group files by module for the module explanations
|
|
558
|
+
modules: dict[str, list[str]] = {}
|
|
559
|
+
for fp in data["files"]:
|
|
560
|
+
parts = Path(fp).parts
|
|
561
|
+
module_name = parts[0] if len(parts) > 1 else "(root)"
|
|
562
|
+
modules.setdefault(module_name, []).append(fp)
|
|
563
|
+
|
|
564
|
+
batch_count = 0
|
|
565
|
+
for target_type, target_key in explanation_targets:
|
|
566
|
+
if target_type == "project":
|
|
567
|
+
progress.update(task, description="Explaining project overview...")
|
|
568
|
+
result = _explain_project_overview(llm, metadata, data, store)
|
|
569
|
+
if result:
|
|
570
|
+
explanations["project"] = result
|
|
571
|
+
|
|
572
|
+
elif target_type == "module":
|
|
573
|
+
progress.update(task, description=f"Explaining module: {target_key}...")
|
|
574
|
+
module_files = modules.get(target_key, [])
|
|
575
|
+
result = _explain_module(
|
|
576
|
+
llm, target_key, module_files, store, data["nodes_by_file"],
|
|
577
|
+
)
|
|
578
|
+
if result:
|
|
579
|
+
explanations[f"module:{target_key}"] = result
|
|
580
|
+
|
|
581
|
+
elif target_type == "file":
|
|
582
|
+
progress.update(task, description=f"Explaining: {target_key}...")
|
|
583
|
+
result = _explain_file_for_export(
|
|
584
|
+
llm, target_key, store, data["nodes_by_file"], source_root,
|
|
585
|
+
)
|
|
586
|
+
if result:
|
|
587
|
+
explanations[f"file:{target_key}"] = result
|
|
588
|
+
|
|
589
|
+
progress.update(task, advance=1)
|
|
590
|
+
|
|
591
|
+
# Rate limiting — pause between batches
|
|
592
|
+
batch_count += 1
|
|
593
|
+
if batch_count % batch_size == 0 and batch_count < total_explanations:
|
|
594
|
+
time.sleep(1.0)
|
|
595
|
+
|
|
596
|
+
# ── Build the DOCX ───────────────────────────────────────────
|
|
597
|
+
console.print(f"[dim]Generated {len(explanations)}/{total_explanations} explanations.[/dim]")
|
|
598
|
+
|
|
599
|
+
with Progress(
|
|
600
|
+
SpinnerColumn(),
|
|
601
|
+
TextColumn("[progress.description]{task.description}"),
|
|
602
|
+
BarColumn(),
|
|
603
|
+
TaskProgressColumn(),
|
|
604
|
+
console=console,
|
|
605
|
+
) as progress:
|
|
606
|
+
task = progress.add_task("Building document...", total=5)
|
|
607
|
+
|
|
608
|
+
doc = docx_mod.Document()
|
|
609
|
+
|
|
610
|
+
# Title
|
|
611
|
+
doc.add_heading(
|
|
612
|
+
f"{metadata.get('project_name', 'Project')} — Documentation", level=0,
|
|
613
|
+
)
|
|
614
|
+
doc.add_paragraph("Generated by CodeGraph CLI with AI-powered explanations.")
|
|
615
|
+
|
|
616
|
+
progress.update(task, advance=1, description="Project overview...")
|
|
617
|
+
|
|
618
|
+
# ── Section 1: Project Overview ──────────────────────────
|
|
619
|
+
doc.add_heading("1. Project Overview", level=1)
|
|
620
|
+
|
|
621
|
+
overview_items = [
|
|
622
|
+
("Project Name", metadata.get("project_name", "Unknown")),
|
|
623
|
+
("Source Path", metadata.get("source_path", "N/A")),
|
|
624
|
+
("Total Files", str(len(data["files"]))),
|
|
625
|
+
("Total Functions", str(len(data["functions"]))),
|
|
626
|
+
("Total Classes", str(len(data["classes"]))),
|
|
627
|
+
("Total Nodes", str(data["total_nodes"])),
|
|
628
|
+
]
|
|
629
|
+
_add_table(doc, ["Property", "Value"], overview_items)
|
|
630
|
+
|
|
631
|
+
# AI overview
|
|
632
|
+
if "project" in explanations:
|
|
633
|
+
doc.add_heading("Architecture Overview", level=2)
|
|
634
|
+
doc.add_paragraph(explanations["project"])
|
|
635
|
+
|
|
636
|
+
# Language breakdown
|
|
637
|
+
if data["ext_breakdown"]:
|
|
638
|
+
doc.add_heading("Language Breakdown", level=2)
|
|
639
|
+
lang_rows = [(ext, str(count)) for ext, count in data["ext_breakdown"].most_common()]
|
|
640
|
+
_add_table(doc, ["Extension", "File Count"], lang_rows)
|
|
641
|
+
|
|
642
|
+
progress.update(task, advance=1, description="Directory tree...")
|
|
643
|
+
|
|
644
|
+
# ── Section 2: Directory Structure ───────────────────────
|
|
645
|
+
doc.add_heading("2. Directory Structure", level=1)
|
|
646
|
+
tree_text = _build_text_tree(data["tree"])
|
|
647
|
+
p = doc.add_paragraph()
|
|
648
|
+
run = p.add_run(tree_text)
|
|
649
|
+
run.font.name = "Consolas"
|
|
650
|
+
run.font.size = Pt(9)
|
|
651
|
+
|
|
652
|
+
# ── Section 3: Architecture Diagram ──────────────────────
|
|
653
|
+
doc.add_heading("3. Architecture Diagram", level=1)
|
|
654
|
+
doc.add_paragraph(
|
|
655
|
+
"System-level dependency diagram showing file-to-file relationships."
|
|
656
|
+
)
|
|
657
|
+
if include_diagram and data["system_mermaid"]:
|
|
658
|
+
_embed_diagram(doc, data["system_mermaid"], "System Architecture")
|
|
659
|
+
|
|
660
|
+
progress.update(task, advance=1, description="Module summaries...")
|
|
661
|
+
|
|
662
|
+
# ── Section 4: Module Summaries ──────────────────────────
|
|
663
|
+
if explanation_depth in ("modules", "files"):
|
|
664
|
+
doc.add_heading("4. Module Summaries", level=1)
|
|
665
|
+
|
|
666
|
+
modules_sorted = sorted(modules.items())
|
|
667
|
+
for mod_name, mod_files in modules_sorted:
|
|
668
|
+
doc.add_heading(f"📁 {mod_name}", level=2)
|
|
669
|
+
|
|
670
|
+
# Module explanation
|
|
671
|
+
mod_key = f"module:{mod_name}"
|
|
672
|
+
if mod_key in explanations:
|
|
673
|
+
doc.add_paragraph(explanations[mod_key])
|
|
674
|
+
|
|
675
|
+
# File list for this module
|
|
676
|
+
file_rows = []
|
|
677
|
+
for fp in mod_files:
|
|
678
|
+
fnodes = data["nodes_by_file"].get(fp, [])
|
|
679
|
+
fn_count = sum(1 for n in fnodes if n.get("node_type") == "function")
|
|
680
|
+
cls_count = sum(1 for n in fnodes if n.get("node_type") == "class")
|
|
681
|
+
file_rows.append([Path(fp).name, str(fn_count), str(cls_count)])
|
|
682
|
+
if file_rows:
|
|
683
|
+
_add_table(doc, ["File", "Functions", "Classes"], file_rows)
|
|
684
|
+
|
|
685
|
+
section_offset = 5
|
|
686
|
+
else:
|
|
687
|
+
section_offset = 4
|
|
688
|
+
|
|
689
|
+
progress.update(task, advance=1, description="File listings...")
|
|
690
|
+
|
|
691
|
+
# ── Section N: File Listings ─────────────────────────────
|
|
692
|
+
doc.add_heading(f"{section_offset}. File Listings", level=1)
|
|
693
|
+
|
|
694
|
+
for i, fp in enumerate(data["files"]):
|
|
695
|
+
doc.add_heading(f"{section_offset}.{i + 1} {fp}", level=2)
|
|
696
|
+
|
|
697
|
+
# AI file explanation
|
|
698
|
+
file_key = f"file:{fp}"
|
|
699
|
+
if file_key in explanations:
|
|
700
|
+
p = doc.add_paragraph()
|
|
701
|
+
run = p.add_run("🤖 AI Explanation: ")
|
|
702
|
+
run.bold = True
|
|
703
|
+
run.font.color.rgb = RGBColor(0xBC, 0x8C, 0xFF)
|
|
704
|
+
p.add_run(explanations[file_key])
|
|
705
|
+
|
|
706
|
+
file_nodes = data["nodes_by_file"].get(fp, [])
|
|
707
|
+
fns = [n for n in file_nodes if n.get("node_type") == "function"]
|
|
708
|
+
clss = [n for n in file_nodes if n.get("node_type") == "class"]
|
|
709
|
+
|
|
710
|
+
if fns or clss:
|
|
711
|
+
symbol_rows: list[list[str]] = []
|
|
712
|
+
for fn in fns:
|
|
713
|
+
symbol_rows.append([
|
|
714
|
+
fn.get("name", ""),
|
|
715
|
+
"function",
|
|
716
|
+
f"L{fn.get('start_line', '?')}-{fn.get('end_line', '?')}",
|
|
717
|
+
])
|
|
718
|
+
for cls in clss:
|
|
719
|
+
symbol_rows.append([
|
|
720
|
+
cls.get("name", ""),
|
|
721
|
+
"class",
|
|
722
|
+
f"L{cls.get('start_line', '?')}-{cls.get('end_line', '?')}",
|
|
723
|
+
])
|
|
724
|
+
_add_table(doc, ["Symbol", "Type", "Lines"], symbol_rows)
|
|
725
|
+
|
|
726
|
+
# Dependencies
|
|
727
|
+
deps, dependents = _get_file_deps(store, fp, data["nodes_by_file"])
|
|
728
|
+
if deps:
|
|
729
|
+
doc.add_paragraph(f"Dependencies: {', '.join(deps[:20])}")
|
|
730
|
+
if dependents:
|
|
731
|
+
doc.add_paragraph(f"Dependents: {', '.join(dependents[:20])}")
|
|
732
|
+
|
|
733
|
+
# Source code
|
|
734
|
+
if include_code and source_root:
|
|
735
|
+
actual = Path(source_root) / fp
|
|
736
|
+
if actual.exists():
|
|
737
|
+
try:
|
|
738
|
+
code = actual.read_text(encoding="utf-8", errors="replace")
|
|
739
|
+
_add_code_block(doc, code)
|
|
740
|
+
except Exception:
|
|
741
|
+
doc.add_paragraph("(Could not read source file)")
|
|
742
|
+
|
|
743
|
+
# Per-file diagram
|
|
744
|
+
file_mermaid = data["file_mermaids"].get(fp)
|
|
745
|
+
if include_diagram and file_mermaid:
|
|
746
|
+
doc.add_heading("File Diagram", level=3)
|
|
747
|
+
_embed_diagram(doc, file_mermaid, f"{Path(fp).name} internal structure")
|
|
748
|
+
|
|
749
|
+
progress.update(task, advance=1, description="Saving...")
|
|
750
|
+
|
|
751
|
+
doc.save(str(output_path))
|
|
752
|
+
|
|
753
|
+
return output_path
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# ===================================================================
|
|
757
|
+
# JSON export for browser integration
|
|
758
|
+
# ===================================================================
|
|
759
|
+
|
|
760
|
+
def generate_export_data(
|
|
761
|
+
store: Any,
|
|
762
|
+
include_code: bool = False,
|
|
763
|
+
llm_provider: str = "",
|
|
764
|
+
llm_model: str = "",
|
|
765
|
+
llm_api_key: str = "",
|
|
766
|
+
explanation_depth: str = "overview",
|
|
767
|
+
) -> Dict[str, Any]:
|
|
768
|
+
"""Generate export data as JSON (used by the browser UI).
|
|
769
|
+
|
|
770
|
+
Returns a dict suitable for JSON serialization with all sections.
|
|
771
|
+
"""
|
|
772
|
+
data = _gather_project_data(store)
|
|
773
|
+
metadata = data["metadata"]
|
|
774
|
+
source_root = metadata.get("source_path", "")
|
|
775
|
+
|
|
776
|
+
result: Dict[str, Any] = {
|
|
777
|
+
"project_name": metadata.get("project_name", "Unknown"),
|
|
778
|
+
"source_path": source_root,
|
|
779
|
+
"stats": {
|
|
780
|
+
"files": len(data["files"]),
|
|
781
|
+
"functions": len(data["functions"]),
|
|
782
|
+
"classes": len(data["classes"]),
|
|
783
|
+
"nodes": data["total_nodes"],
|
|
784
|
+
"edges": len(data["edges"]),
|
|
785
|
+
},
|
|
786
|
+
"ext_breakdown": dict(data["ext_breakdown"]),
|
|
787
|
+
"tree_text": _build_text_tree(data["tree"]),
|
|
788
|
+
"system_mermaid": data["system_mermaid"],
|
|
789
|
+
"files": [],
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
for fp in data["files"]:
|
|
793
|
+
file_nodes = data["nodes_by_file"].get(fp, [])
|
|
794
|
+
fns = [{"name": n["name"], "line": n["start_line"]}
|
|
795
|
+
for n in file_nodes if n.get("node_type") == "function"]
|
|
796
|
+
clss = [{"name": n["name"], "line": n["start_line"]}
|
|
797
|
+
for n in file_nodes if n.get("node_type") == "class"]
|
|
798
|
+
deps, dependents = _get_file_deps(store, fp, data["nodes_by_file"])
|
|
799
|
+
|
|
800
|
+
file_entry: Dict[str, Any] = {
|
|
801
|
+
"path": fp,
|
|
802
|
+
"name": Path(fp).name,
|
|
803
|
+
"functions": fns,
|
|
804
|
+
"classes": clss,
|
|
805
|
+
"deps": deps[:20],
|
|
806
|
+
"dependents": dependents[:20],
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if include_code and source_root:
|
|
810
|
+
actual = Path(source_root) / fp
|
|
811
|
+
if actual.exists():
|
|
812
|
+
try:
|
|
813
|
+
file_entry["content"] = actual.read_text(
|
|
814
|
+
encoding="utf-8", errors="replace",
|
|
815
|
+
)
|
|
816
|
+
except Exception:
|
|
817
|
+
pass
|
|
818
|
+
|
|
819
|
+
result["files"].append(file_entry)
|
|
820
|
+
|
|
821
|
+
return result
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
# ===================================================================
|
|
825
|
+
# Typer commands
|
|
826
|
+
# ===================================================================
|
|
827
|
+
|
|
828
|
+
@export_app.command("docx")
|
|
829
|
+
def export_docx(
|
|
830
|
+
output: Path = typer.Option(
|
|
831
|
+
None, "--output", "-o",
|
|
832
|
+
help="Output file path (default: <project>.docx)",
|
|
833
|
+
),
|
|
834
|
+
include_code: bool = typer.Option(
|
|
835
|
+
False, "--include-code", "-c",
|
|
836
|
+
help="Include source code in the export.",
|
|
837
|
+
),
|
|
838
|
+
enhanced: bool = typer.Option(
|
|
839
|
+
False, "--enhanced", "-e",
|
|
840
|
+
help="Use LLM for AI-powered explanations (requires configured LLM).",
|
|
841
|
+
),
|
|
842
|
+
depth: str = typer.Option(
|
|
843
|
+
"modules", "--depth", "-d",
|
|
844
|
+
help="Explanation depth: overview, modules, or files.",
|
|
845
|
+
),
|
|
846
|
+
no_diagram: bool = typer.Option(
|
|
847
|
+
False, "--no-diagram",
|
|
848
|
+
help="Skip embedding architecture diagram (faster, smaller file).",
|
|
849
|
+
),
|
|
850
|
+
):
|
|
851
|
+
"""📄 Export project documentation to DOCX format.
|
|
852
|
+
|
|
853
|
+
[bold]Basic export[/bold] (default):
|
|
854
|
+
Structure, directory tree, architecture diagram, file symbols
|
|
855
|
+
|
|
856
|
+
[bold]Enhanced export[/bold] (--enhanced):
|
|
857
|
+
Adds AI explanations at chosen depth level
|
|
858
|
+
|
|
859
|
+
[bold]Examples:[/bold]
|
|
860
|
+
cg export docx
|
|
861
|
+
cg export docx --output my-docs.docx --include-code
|
|
862
|
+
cg export docx --enhanced --depth files --include-code
|
|
863
|
+
"""
|
|
864
|
+
from .storage import GraphStore, ProjectManager
|
|
865
|
+
from . import config as cfg
|
|
866
|
+
|
|
867
|
+
console = Console()
|
|
868
|
+
|
|
869
|
+
pm = ProjectManager()
|
|
870
|
+
project = pm.get_current_project()
|
|
871
|
+
if not project:
|
|
872
|
+
console.print("[red]No project loaded.[/red] Run [cyan]cg project index <path>[/cyan] first.")
|
|
873
|
+
raise typer.Exit(code=1)
|
|
874
|
+
|
|
875
|
+
project_dir = pm.project_dir(project)
|
|
876
|
+
if not project_dir.exists():
|
|
877
|
+
console.print(f"[red]Project '{project}' not found.[/red]")
|
|
878
|
+
raise typer.Exit(code=1)
|
|
879
|
+
|
|
880
|
+
store = GraphStore(project_dir)
|
|
881
|
+
|
|
882
|
+
nodes = store.get_nodes()
|
|
883
|
+
if not nodes:
|
|
884
|
+
console.print("[red]Project is empty.[/red] Re-run [cyan]cg project index[/cyan].")
|
|
885
|
+
store.close()
|
|
886
|
+
raise typer.Exit(code=1)
|
|
887
|
+
|
|
888
|
+
# Default output path
|
|
889
|
+
if output is None:
|
|
890
|
+
output = Path(f"{project}-docs.docx")
|
|
891
|
+
|
|
892
|
+
# Validate depth
|
|
893
|
+
if depth not in ("overview", "modules", "files"):
|
|
894
|
+
console.print(f"[red]Invalid depth '{depth}'. Use: overview, modules, or files.[/red]")
|
|
895
|
+
store.close()
|
|
896
|
+
raise typer.Exit(code=1)
|
|
897
|
+
|
|
898
|
+
try:
|
|
899
|
+
console.print(f"\n[bold green]📄 Exporting to DOCX[/bold green]")
|
|
900
|
+
console.print(f" Project: [cyan]{project}[/cyan]")
|
|
901
|
+
console.print(f" Output: [cyan]{output}[/cyan]")
|
|
902
|
+
console.print(f" Code: {'✅' if include_code else '❌'}")
|
|
903
|
+
console.print(f" Diagram: {'✅' if not no_diagram else '❌'}")
|
|
904
|
+
console.print(f" Enhanced: {'✅ ' + depth if enhanced else '❌'}")
|
|
905
|
+
console.print()
|
|
906
|
+
|
|
907
|
+
if enhanced:
|
|
908
|
+
result = generate_enhanced_docx(
|
|
909
|
+
store=store,
|
|
910
|
+
output_path=output,
|
|
911
|
+
include_code=include_code,
|
|
912
|
+
include_diagram=not no_diagram,
|
|
913
|
+
explanation_depth=depth,
|
|
914
|
+
llm_provider=cfg.LLM_PROVIDER,
|
|
915
|
+
llm_model=cfg.LLM_MODEL,
|
|
916
|
+
llm_api_key=cfg.LLM_API_KEY,
|
|
917
|
+
console=console,
|
|
918
|
+
)
|
|
919
|
+
else:
|
|
920
|
+
result = generate_basic_docx(
|
|
921
|
+
store=store,
|
|
922
|
+
output_path=output,
|
|
923
|
+
include_code=include_code,
|
|
924
|
+
include_diagram=not no_diagram,
|
|
925
|
+
console=console,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
file_size = output.stat().st_size
|
|
929
|
+
size_str = (
|
|
930
|
+
f"{file_size / 1024 / 1024:.1f} MB" if file_size > 1024 * 1024
|
|
931
|
+
else f"{file_size / 1024:.1f} KB"
|
|
932
|
+
)
|
|
933
|
+
console.print(f"\n[bold green]✅ Exported successfully![/bold green]")
|
|
934
|
+
console.print(f" File: [cyan]{result}[/cyan] ({size_str})")
|
|
935
|
+
|
|
936
|
+
except Exception as e:
|
|
937
|
+
console.print(f"\n[red]Export failed: {e}[/red]")
|
|
938
|
+
logger.exception("Export failed")
|
|
939
|
+
raise typer.Exit(code=1)
|
|
940
|
+
finally:
|
|
941
|
+
store.close()
|