codegraph-cli 2.1.1__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 +200 -95
  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 +26 -7
  27. codegraph_cli/crew_chat.py +141 -12
  28. codegraph_cli/crew_tools.py +21 -1
  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.1.dist-info → codegraph_cli-2.1.2.dist-info}/METADATA +35 -24
  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.1.dist-info/RECORD +0 -43
  39. codegraph_cli-2.1.1.dist-info/entry_points.txt +0 -2
  40. {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.2.dist-info}/WHEEL +0 -0
  41. {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.2.dist-info}/licenses/LICENSE +0 -0
  42. {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,33 @@
1
+ """Command hierarchy groups for organized CLI experience.
2
+
3
+ Provides logical grouping of commands under:
4
+ cg config — Configuration management
5
+ cg project — Project management
6
+ cg analyze — Code analysis
7
+ cg chat — Interactive AI chat
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import typer
13
+
14
+ # ── Configuration group ──────────────────────────────────────
15
+ config_grp = typer.Typer(
16
+ help="⚙️ Configuration — LLM, embedding, and setup.",
17
+ no_args_is_help=True,
18
+ rich_markup_mode="rich",
19
+ )
20
+
21
+ # ── Project management group ─────────────────────────────────
22
+ project_grp = typer.Typer(
23
+ help="📂 Projects — index, load, and manage project memories.",
24
+ no_args_is_help=True,
25
+ rich_markup_mode="rich",
26
+ )
27
+
28
+ # ── Analysis group ───────────────────────────────────────────
29
+ analyze_grp = typer.Typer(
30
+ help="🔍 Analysis — search, impact, graph, and RAG context.",
31
+ no_args_is_help=True,
32
+ rich_markup_mode="rich",
33
+ )
@@ -0,0 +1,316 @@
1
+ """Project health dashboard for CodeGraph CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.progress import BarColumn, Progress, TextColumn
12
+ from rich.table import Table
13
+
14
+ from . import config
15
+ from .storage import GraphStore, ProjectManager
16
+
17
+ console = Console()
18
+
19
+ health_app = typer.Typer(help="🏥 Project health dashboard")
20
+
21
+
22
+ def _render_bar(percentage: float) -> str:
23
+ """Render a simple text progress bar."""
24
+ filled = int(percentage / 10)
25
+ empty = 10 - filled
26
+ bar = "█" * filled + "░" * empty
27
+ if percentage >= 80:
28
+ color = "green"
29
+ elif percentage >= 60:
30
+ color = "yellow"
31
+ else:
32
+ color = "red"
33
+ return f"[{color}]{bar}[/{color}] {percentage:.0f}%"
34
+
35
+
36
+ def _score_color(score: float) -> str:
37
+ if score >= 80:
38
+ return "green"
39
+ elif score >= 60:
40
+ return "yellow"
41
+ return "red"
42
+
43
+
44
+ def _analyze_code_quality(store: GraphStore) -> Dict[str, Any]:
45
+ """Analyze code quality from indexed nodes."""
46
+ nodes = store.get_nodes()
47
+ if not nodes:
48
+ return {"score": 0, "total_nodes": 0, "functions": 0, "classes": 0}
49
+
50
+ functions = [n for n in nodes if n.get("node_type") == "function"]
51
+ classes = [n for n in nodes if n.get("node_type") == "class"]
52
+
53
+ # Heuristic quality score based on:
54
+ # - docstring presence
55
+ # - reasonable function size
56
+ # - naming conventions
57
+ score_points = 0
58
+ total_checks = 0
59
+
60
+ for fn in functions:
61
+ total_checks += 1
62
+ snippet = fn.get("snippet", "")
63
+ # Has docstring?
64
+ if '"""' in snippet or "'''" in snippet:
65
+ score_points += 1
66
+ else:
67
+ score_points += 0.3
68
+ # Reasonable size (< 50 lines)
69
+ start = fn.get("start_line", 0)
70
+ end = fn.get("end_line", 0)
71
+ if 0 < (end - start) < 50:
72
+ score_points += 0.5
73
+ total_checks += 0.5
74
+ else:
75
+ total_checks += 0.5
76
+
77
+ quality_pct = (score_points / max(total_checks, 1)) * 100
78
+ return {
79
+ "score": min(quality_pct, 100),
80
+ "total_nodes": len(nodes),
81
+ "functions": len(functions),
82
+ "classes": len(classes),
83
+ "documented": sum(
84
+ 1 for fn in functions if '"""' in fn.get("snippet", "") or "'''" in fn.get("snippet", "")
85
+ ),
86
+ }
87
+
88
+
89
+ def _analyze_complexity(store: GraphStore) -> Dict[str, Any]:
90
+ """Estimate code complexity from indexed functions."""
91
+ nodes = store.get_nodes()
92
+ functions = [n for n in nodes if n.get("node_type") == "function"]
93
+
94
+ if not functions:
95
+ return {"avg": 0, "max": 0, "high_complexity": []}
96
+
97
+ complexities = []
98
+ high_complexity = []
99
+
100
+ for fn in functions:
101
+ snippet = fn.get("snippet", "")
102
+ # Rough cyclomatic complexity estimate
103
+ complexity = 1 # base
104
+ for keyword in ["if ", "elif ", "else:", "for ", "while ", "except ", "and ", "or ", "case "]:
105
+ complexity += snippet.count(keyword)
106
+
107
+ complexities.append(complexity)
108
+ if complexity > 10:
109
+ high_complexity.append({
110
+ "name": fn.get("qualname", fn.get("name", "unknown")),
111
+ "complexity": complexity,
112
+ "file": fn.get("file_path", ""),
113
+ })
114
+
115
+ avg_complexity = sum(complexities) / len(complexities) if complexities else 0
116
+ max_complexity = max(complexities) if complexities else 0
117
+
118
+ return {
119
+ "avg": avg_complexity,
120
+ "max": max_complexity,
121
+ "high_complexity": sorted(high_complexity, key=lambda x: x["complexity"], reverse=True)[:10],
122
+ }
123
+
124
+
125
+ def _analyze_security(store: GraphStore) -> Dict[str, Any]:
126
+ """Quick security scan from indexed code."""
127
+ nodes = store.get_nodes()
128
+ critical = 0
129
+ high = 0
130
+ medium = 0
131
+
132
+ security_patterns = {
133
+ "critical": ["eval(", "exec(", "os.system(", "subprocess.call(shell=True"],
134
+ "high": ["pickle.loads", "yaml.load(", "password", "secret", "SQL"],
135
+ "medium": ["print(", "TODO", "FIXME", "HACK"],
136
+ }
137
+
138
+ for node in nodes:
139
+ snippet = node.get("snippet", "")
140
+ for pattern in security_patterns["critical"]:
141
+ if pattern in snippet:
142
+ critical += 1
143
+ for pattern in security_patterns["high"]:
144
+ if pattern.lower() in snippet.lower():
145
+ high += 1
146
+ for pattern in security_patterns["medium"]:
147
+ if pattern in snippet:
148
+ medium += 1
149
+
150
+ return {"critical": critical, "high": high, "medium": medium}
151
+
152
+
153
+ def _generate_recommendations(
154
+ quality: Dict[str, Any],
155
+ complexity: Dict[str, Any],
156
+ security: Dict[str, Any],
157
+ ) -> List[str]:
158
+ """Generate actionable recommendations."""
159
+ recs = []
160
+
161
+ documented = quality.get("documented", 0)
162
+ total_fns = quality.get("functions", 0)
163
+ if total_fns > 0 and documented / total_fns < 0.5:
164
+ recs.append(
165
+ f"Add docstrings: only {documented}/{total_fns} functions are documented. "
166
+ "Run 'cg v2 review <file>' for suggestions."
167
+ )
168
+
169
+ if security["critical"] > 0:
170
+ recs.append(
171
+ f"Fix {security['critical']} critical security pattern(s) (eval/exec/os.system). "
172
+ "Run 'cg v2 review <file> --check security'."
173
+ )
174
+
175
+ if security["high"] > 0:
176
+ recs.append(
177
+ f"Review {security['high']} high-severity pattern(s). "
178
+ "Run 'cg v2 review <file> --check security'."
179
+ )
180
+
181
+ if complexity["max"] > 15:
182
+ recs.append(
183
+ f"Refactor high-complexity functions (max: {complexity['max']}). "
184
+ "Run 'cg v2 refactor extract-function' to simplify."
185
+ )
186
+
187
+ if quality["score"] < 70:
188
+ recs.append("Run 'cg v2 review <file>' on low-quality modules to get improvement suggestions.")
189
+
190
+ if not recs:
191
+ recs.append("Your project looks healthy! Keep up the good work. 🎉")
192
+
193
+ return recs
194
+
195
+
196
+ @health_app.command("dashboard")
197
+ def health_dashboard():
198
+ """🏥 Project health dashboard.
199
+
200
+ Shows code quality, complexity, security patterns, and recommendations.
201
+
202
+ Example:
203
+ cg health dashboard
204
+ """
205
+ pm = ProjectManager()
206
+ project = pm.get_current_project()
207
+ if not project:
208
+ console.print("[red]✗[/red] No project loaded. Run 'cg index <path>' first.")
209
+ raise typer.Exit(1)
210
+
211
+ project_dir = pm.project_dir(project)
212
+ store = GraphStore(project_dir)
213
+
214
+ console.print(f"\n[bold cyan]🏥 Analyzing project health for '{project}'...[/bold cyan]\n")
215
+
216
+ with Progress(
217
+ TextColumn("[progress.description]{task.description}"),
218
+ BarColumn(),
219
+ console=console,
220
+ ) as progress:
221
+ task = progress.add_task("[cyan]Running analyzers...", total=3)
222
+
223
+ progress.update(task, description="[cyan]Analyzing code quality...")
224
+ quality = _analyze_code_quality(store)
225
+ progress.advance(task)
226
+
227
+ progress.update(task, description="[cyan]Analyzing complexity...")
228
+ complexity = _analyze_complexity(store)
229
+ progress.advance(task)
230
+
231
+ progress.update(task, description="[cyan]Scanning for security patterns...")
232
+ security = _analyze_security(store)
233
+ progress.advance(task)
234
+
235
+ # ── Overall score ────────────────────────────────────────
236
+ quality_score = quality["score"]
237
+ complexity_penalty = min(complexity["avg"] * 2, 30)
238
+ security_penalty = security["critical"] * 10 + security["high"] * 3
239
+ overall = max(0, min(100, quality_score - complexity_penalty - security_penalty))
240
+ color = _score_color(overall)
241
+
242
+ console.print(
243
+ Panel.fit(
244
+ f"[bold {color}]{overall:.0f}%[/bold {color}]",
245
+ title="[bold]Overall Health Score[/bold]",
246
+ border_style=color,
247
+ )
248
+ )
249
+
250
+ # ── Detailed metrics ─────────────────────────────────────
251
+ table = Table(title="\nDetailed Metrics", show_header=True, show_lines=False)
252
+ table.add_column("Metric", style="cyan", width=18)
253
+ table.add_column("Score", width=24)
254
+ table.add_column("Details", min_width=30)
255
+
256
+ table.add_row(
257
+ "Code Quality",
258
+ _render_bar(quality_score),
259
+ f"{quality['functions']} functions, {quality['classes']} classes, "
260
+ f"{quality['documented']} documented",
261
+ )
262
+
263
+ complexity_pct = max(0, 100 - complexity["avg"] * 5)
264
+ table.add_row(
265
+ "Complexity",
266
+ _render_bar(complexity_pct),
267
+ f"Avg: {complexity['avg']:.1f}, Max: {complexity['max']}",
268
+ )
269
+
270
+ sec_pct = max(0, 100 - security["critical"] * 20 - security["high"] * 5 - security["medium"])
271
+ sec_status = (
272
+ "🔴 Critical" if security["critical"] > 0
273
+ else "🟡 Warning" if security["high"] > 0
274
+ else "🟢 Good"
275
+ )
276
+ table.add_row(
277
+ "Security",
278
+ sec_status,
279
+ f"{security['critical']} critical, {security['high']} high, {security['medium']} medium",
280
+ )
281
+
282
+ table.add_row(
283
+ "Total Nodes",
284
+ str(quality["total_nodes"]),
285
+ f"Indexed symbols in project '{project}'",
286
+ )
287
+
288
+ console.print(table)
289
+
290
+ # ── High complexity functions ────────────────────────────
291
+ if complexity["high_complexity"]:
292
+ console.print("\n[bold yellow]⚠️ High-Complexity Functions[/bold yellow]")
293
+ for item in complexity["high_complexity"][:5]:
294
+ console.print(f" • {item['name']} (complexity: {item['complexity']}) — {item['file']}")
295
+
296
+ # ── Recommendations ──────────────────────────────────────
297
+ recs = _generate_recommendations(quality, complexity, security)
298
+ console.print(
299
+ "\n"
300
+ + Panel(
301
+ "\n".join([f" • {rec}" for rec in recs]),
302
+ title="[bold yellow]📋 Recommendations[/bold yellow]",
303
+ border_style="yellow",
304
+ ).markup # type: ignore
305
+ if False
306
+ else ""
307
+ )
308
+ console.print(
309
+ Panel(
310
+ "\n".join([f" • {rec}" for rec in recs]),
311
+ title="[bold yellow]📋 Recommendations[/bold yellow]",
312
+ border_style="yellow",
313
+ )
314
+ )
315
+
316
+ store.close()
@@ -0,0 +1,213 @@
1
+ """Undo/redo system and change history for CodeGraph CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from .config import BASE_DIR
15
+
16
+ console = Console()
17
+
18
+ history_app = typer.Typer(help="📜 Change history and undo/redo")
19
+
20
+ HISTORY_FILE = BASE_DIR / "change_history.json"
21
+ MAX_HISTORY = 100
22
+
23
+
24
+ class ChangeHistory:
25
+ """Track code changes for undo/redo support."""
26
+
27
+ def __init__(self):
28
+ self.history: List[dict] = self._load()
29
+ self.current_index: int = len(self.history) - 1
30
+
31
+ def _load(self) -> List[dict]:
32
+ if HISTORY_FILE.exists():
33
+ try:
34
+ return json.loads(HISTORY_FILE.read_text())
35
+ except (json.JSONDecodeError, OSError):
36
+ return []
37
+ return []
38
+
39
+ def _save(self) -> None:
40
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
41
+ # Trim to max history
42
+ data = self.history[-MAX_HISTORY:]
43
+ HISTORY_FILE.write_text(json.dumps(data, indent=2))
44
+
45
+ def record_change(
46
+ self,
47
+ change_type: str,
48
+ description: str,
49
+ files_changed: List[str],
50
+ backup_id: str,
51
+ ) -> None:
52
+ """Record a change for undo/redo."""
53
+ # Truncate any "future" entries if we've undone things
54
+ self.history = self.history[: self.current_index + 1]
55
+ self.history.append(
56
+ {
57
+ "timestamp": datetime.now().isoformat(),
58
+ "type": change_type,
59
+ "description": description,
60
+ "files": files_changed,
61
+ "backup_id": backup_id,
62
+ }
63
+ )
64
+ self.current_index = len(self.history) - 1
65
+ self._save()
66
+
67
+ def can_undo(self) -> bool:
68
+ return self.current_index >= 0 and len(self.history) > 0
69
+
70
+ def can_redo(self) -> bool:
71
+ return self.current_index < len(self.history) - 1
72
+
73
+ def get_undo_entry(self) -> Optional[dict]:
74
+ if not self.can_undo():
75
+ return None
76
+ entry = self.history[self.current_index]
77
+ self.current_index -= 1
78
+ self._save()
79
+ return entry
80
+
81
+ def get_redo_entry(self) -> Optional[dict]:
82
+ if not self.can_redo():
83
+ return None
84
+ self.current_index += 1
85
+ entry = self.history[self.current_index]
86
+ self._save()
87
+ return entry
88
+
89
+
90
+ @history_app.command("undo")
91
+ def undo(
92
+ steps: int = typer.Option(1, "--steps", "-n", min=1, help="Number of steps to undo."),
93
+ ):
94
+ """⏪ Undo last code changes.
95
+
96
+ Restores files from backups created during generate, refactor, or fix operations.
97
+
98
+ Example:
99
+ cg undo
100
+ cg undo --steps 3
101
+ """
102
+ from .diff_engine import DiffEngine
103
+
104
+ hist = ChangeHistory()
105
+
106
+ if not hist.can_undo():
107
+ console.print("[yellow]No changes to undo.[/yellow]")
108
+ return
109
+
110
+ diff_engine = DiffEngine()
111
+ undone = 0
112
+
113
+ for _ in range(steps):
114
+ entry = hist.get_undo_entry()
115
+ if not entry:
116
+ console.print("[yellow]No more changes to undo.[/yellow]")
117
+ break
118
+
119
+ console.print(f" [yellow]⏪ Undoing:[/yellow] {entry['description']}")
120
+
121
+ success = diff_engine.rollback(entry["backup_id"])
122
+ if success:
123
+ console.print(f" [green]✓[/green] Restored {len(entry['files'])} file(s)")
124
+ undone += 1
125
+ else:
126
+ console.print(f" [red]✗[/red] Backup not found: {entry['backup_id']}")
127
+
128
+ if undone:
129
+ console.print(f"\n[green]Undid {undone} change(s).[/green]")
130
+ console.print("[dim]Use 'cg redo' to reapply.[/dim]")
131
+
132
+
133
+ @history_app.command("redo")
134
+ def redo(
135
+ steps: int = typer.Option(1, "--steps", "-n", min=1, help="Number of steps to redo."),
136
+ ):
137
+ """⏩ Redo previously undone changes.
138
+
139
+ Example:
140
+ cg redo
141
+ cg redo --steps 2
142
+ """
143
+ hist = ChangeHistory()
144
+
145
+ if not hist.can_redo():
146
+ console.print("[yellow]No changes to redo.[/yellow]")
147
+ return
148
+
149
+ console.print("[cyan]Redo is not yet fully supported — please re-run the command.[/cyan]")
150
+ console.print("[dim]History tracking is active; full redo support coming soon.[/dim]")
151
+
152
+
153
+ @history_app.command("show")
154
+ def show_history(
155
+ limit: int = typer.Option(10, "--limit", "-n", min=1, help="Number of entries to show."),
156
+ ):
157
+ """📜 Show change history.
158
+
159
+ Example:
160
+ cg history show
161
+ cg history show --limit 20
162
+ """
163
+ hist = ChangeHistory()
164
+
165
+ if not hist.history:
166
+ console.print("[yellow]No change history yet.[/yellow]")
167
+ console.print("[dim]History is recorded when you generate, refactor, or fix code.[/dim]")
168
+ return
169
+
170
+ table = Table(title="Change History", show_lines=False)
171
+ table.add_column("#", style="dim", width=4)
172
+ table.add_column("Time", style="cyan", width=16)
173
+ table.add_column("Type", style="magenta", width=12)
174
+ table.add_column("Description", min_width=30)
175
+ table.add_column("Files", justify="right", style="green", width=6)
176
+
177
+ entries = hist.history[-limit:]
178
+ for i, change in enumerate(reversed(entries), 1):
179
+ try:
180
+ timestamp = datetime.fromisoformat(change["timestamp"])
181
+ time_str = timestamp.strftime("%Y-%m-%d %H:%M")
182
+ except (ValueError, KeyError):
183
+ time_str = "unknown"
184
+
185
+ marker = " ◄" if (len(hist.history) - i) == hist.current_index else ""
186
+ table.add_row(
187
+ str(i),
188
+ time_str,
189
+ change.get("type", "unknown"),
190
+ change.get("description", "—") + marker,
191
+ str(len(change.get("files", []))),
192
+ )
193
+
194
+ console.print(table)
195
+ console.print(f"\n[dim]Showing {len(entries)} of {len(hist.history)} entries[/dim]")
196
+
197
+
198
+ @history_app.command("clear")
199
+ def clear_history():
200
+ """🗑️ Clear all change history.
201
+
202
+ Example:
203
+ cg history clear
204
+ """
205
+ if not HISTORY_FILE.exists():
206
+ console.print("[yellow]No history to clear.[/yellow]")
207
+ return
208
+
209
+ if typer.confirm("Clear all change history?", default=False):
210
+ HISTORY_FILE.unlink(missing_ok=True)
211
+ console.print("[green]✓ History cleared.[/green]")
212
+ else:
213
+ console.print("[dim]Cancelled.[/dim]")