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.
Files changed (42) hide show
  1. codegraph_cli/__init__.py +1 -1
  2. codegraph_cli/agents.py +59 -3
  3. codegraph_cli/chat_agent.py +58 -11
  4. codegraph_cli/cli.py +569 -54
  5. codegraph_cli/cli_chat.py +204 -94
  6. codegraph_cli/cli_diagnose.py +13 -2
  7. codegraph_cli/cli_docs.py +207 -0
  8. codegraph_cli/cli_explore.py +1053 -0
  9. codegraph_cli/cli_export.py +941 -0
  10. codegraph_cli/cli_groups.py +33 -0
  11. codegraph_cli/cli_health.py +316 -0
  12. codegraph_cli/cli_history.py +213 -0
  13. codegraph_cli/cli_onboard.py +380 -0
  14. codegraph_cli/cli_quickstart.py +256 -0
  15. codegraph_cli/cli_refactor.py +17 -3
  16. codegraph_cli/cli_setup.py +12 -12
  17. codegraph_cli/cli_suggestions.py +90 -0
  18. codegraph_cli/cli_test.py +17 -3
  19. codegraph_cli/cli_tui.py +210 -0
  20. codegraph_cli/cli_v2.py +24 -4
  21. codegraph_cli/cli_watch.py +158 -0
  22. codegraph_cli/cli_workflows.py +255 -0
  23. codegraph_cli/codegen_agent.py +15 -1
  24. codegraph_cli/config.py +18 -5
  25. codegraph_cli/context_manager.py +117 -15
  26. codegraph_cli/crew_agents.py +32 -8
  27. codegraph_cli/crew_chat.py +146 -13
  28. codegraph_cli/crew_tools.py +30 -2
  29. codegraph_cli/embeddings.py +95 -5
  30. codegraph_cli/llm.py +42 -55
  31. codegraph_cli/project_context.py +64 -1
  32. codegraph_cli/rag.py +282 -19
  33. codegraph_cli/storage.py +310 -14
  34. codegraph_cli/vector_store.py +110 -8
  35. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/METADATA +75 -21
  36. codegraph_cli-2.1.2.dist-info/RECORD +55 -0
  37. codegraph_cli-2.1.2.dist-info/entry_points.txt +2 -0
  38. codegraph_cli-2.1.0.dist-info/RECORD +0 -43
  39. codegraph_cli-2.1.0.dist-info/entry_points.txt +0 -2
  40. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/WHEEL +0 -0
  41. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/licenses/LICENSE +0 -0
  42. {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()