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
codegraph_cli/cli.py
CHANGED
|
@@ -2,44 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from difflib import get_close_matches
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import Optional
|
|
7
|
+
from typing import Dict, Optional
|
|
7
8
|
|
|
8
9
|
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
9
12
|
|
|
10
13
|
from . import __version__, config
|
|
11
14
|
from .cli_chat import chat_app
|
|
15
|
+
from .cli_explore import explore_app
|
|
16
|
+
from .cli_export import export_app
|
|
17
|
+
from .cli_groups import analyze_grp, config_grp, project_grp
|
|
18
|
+
from .cli_health import health_app
|
|
19
|
+
from .cli_onboard import onboard
|
|
20
|
+
from .cli_quickstart import quickstart_app
|
|
12
21
|
from .cli_setup import setup as setup_wizard, set_llm, unset_llm, show_llm
|
|
13
22
|
from .cli_setup import set_embedding, unset_embedding, show_embedding
|
|
14
|
-
from .
|
|
23
|
+
from .cli_suggestions import show_next_steps
|
|
24
|
+
from .cli_watch import watch_app
|
|
15
25
|
from .graph_export import export_dot, export_html
|
|
16
26
|
from .orchestrator import MCPOrchestrator
|
|
17
27
|
from .storage import GraphStore, ProjectManager
|
|
18
28
|
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
19
31
|
app = typer.Typer(
|
|
20
32
|
help="🧠 CodeGraph CLI — AI-powered code intelligence & multi-agent assistant.",
|
|
21
33
|
no_args_is_help=True,
|
|
22
34
|
rich_markup_mode="rich",
|
|
35
|
+
add_completion=False,
|
|
23
36
|
)
|
|
24
37
|
|
|
25
|
-
#
|
|
26
|
-
app.add_typer(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
app.add_typer(chat_app, name="chat")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
app.command("setup")(setup_wizard)
|
|
33
|
-
|
|
34
|
-
# Register LLM management commands
|
|
35
|
-
app.command("set-llm")(set_llm)
|
|
36
|
-
app.command("unset-llm")(unset_llm)
|
|
37
|
-
app.command("show-llm")(show_llm)
|
|
38
|
+
# ── Top-level groups ─────────────────────────────────────────────
|
|
39
|
+
app.add_typer(config_grp, name="config", help="Configuration management")
|
|
40
|
+
app.add_typer(project_grp, name="project", help="Project management")
|
|
41
|
+
app.add_typer(analyze_grp, name="analyze", help="Code analysis")
|
|
42
|
+
app.add_typer(chat_app, name="chat", help="Interactive chat with AI agents")
|
|
43
|
+
app.add_typer(explore_app, name="explore", help="Visual code explorer in browser")
|
|
44
|
+
app.add_typer(export_app, name="export", help="Export project documentation")
|
|
38
45
|
|
|
39
|
-
#
|
|
40
|
-
app.command("
|
|
41
|
-
app.command("unset-embedding")(unset_embedding)
|
|
42
|
-
app.command("show-embedding")(show_embedding)
|
|
46
|
+
# Top-level standalone commands
|
|
47
|
+
app.command("onboard")(onboard)
|
|
43
48
|
|
|
44
49
|
|
|
45
50
|
def version_callback(value: bool):
|
|
@@ -58,7 +63,7 @@ def main(
|
|
|
58
63
|
help="Show version and exit.",
|
|
59
64
|
callback=version_callback,
|
|
60
65
|
is_eager=True,
|
|
61
|
-
)
|
|
66
|
+
),
|
|
62
67
|
):
|
|
63
68
|
"""CodeGraph CLI: Local-first code intelligence with AI-powered impact analysis."""
|
|
64
69
|
pass
|
|
@@ -78,7 +83,6 @@ def _open_current_store(pm: ProjectManager) -> GraphStore:
|
|
|
78
83
|
return GraphStore(project_dir)
|
|
79
84
|
|
|
80
85
|
|
|
81
|
-
@app.command("index")
|
|
82
86
|
def index_project(
|
|
83
87
|
project_path: Path = typer.Argument(..., exists=True, file_okay=False, help="Path to source project."),
|
|
84
88
|
project_name: Optional[str] = typer.Option(None, "--name", "-n", help="Explicit memory name for project."),
|
|
@@ -86,9 +90,15 @@ def index_project(
|
|
|
86
90
|
llm_provider: str = typer.Option("ollama", help="LLM provider: ollama, groq, openai, anthropic."),
|
|
87
91
|
llm_api_key: Optional[str] = typer.Option(None, help="API key for cloud LLM providers."),
|
|
88
92
|
):
|
|
89
|
-
"""Parse and index a project into local semantic memory.
|
|
93
|
+
"""📦 Parse and index a project into local semantic memory.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
cg index ./my-project
|
|
97
|
+
cg index ./backend --name my-api
|
|
98
|
+
"""
|
|
90
99
|
from datetime import datetime
|
|
91
|
-
|
|
100
|
+
import time as _time
|
|
101
|
+
|
|
92
102
|
pm = ProjectManager()
|
|
93
103
|
resolved_path = project_path.resolve()
|
|
94
104
|
name = project_name or _project_name_from_path(resolved_path)
|
|
@@ -101,26 +111,46 @@ def index_project(
|
|
|
101
111
|
llm_provider=llm_provider,
|
|
102
112
|
llm_api_key=llm_api_key,
|
|
103
113
|
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
|
|
115
|
+
start_t = _time.time()
|
|
116
|
+
with Progress(
|
|
117
|
+
SpinnerColumn(),
|
|
118
|
+
TextColumn("[progress.description]{task.description}"),
|
|
119
|
+
BarColumn(),
|
|
120
|
+
TaskProgressColumn(),
|
|
121
|
+
console=console,
|
|
122
|
+
) as progress:
|
|
123
|
+
task = progress.add_task("[cyan]Indexing project...", total=None)
|
|
124
|
+
stats = orchestrator.index(resolved_path)
|
|
125
|
+
progress.update(task, completed=100, total=100, description="[green]Indexing complete")
|
|
126
|
+
elapsed = _time.time() - start_t
|
|
127
|
+
|
|
128
|
+
# Store project metadata including source path and embedding info
|
|
129
|
+
emb_model_key = getattr(orchestrator.embedding_model, 'model_key', 'hash')
|
|
130
|
+
emb_dim = getattr(orchestrator.embedding_model, 'dim', 256)
|
|
107
131
|
store.set_metadata({
|
|
108
132
|
**store.get_metadata(),
|
|
109
133
|
"project_name": name,
|
|
110
134
|
"source_path": str(resolved_path),
|
|
111
|
-
"indexed_at": datetime.now().isoformat()
|
|
135
|
+
"indexed_at": datetime.now().isoformat(),
|
|
136
|
+
"embedding_model": emb_model_key,
|
|
137
|
+
"embedding_dim": emb_dim,
|
|
112
138
|
})
|
|
113
|
-
|
|
139
|
+
|
|
114
140
|
pm.set_current_project(name)
|
|
115
141
|
store.close()
|
|
116
142
|
|
|
117
|
-
|
|
118
|
-
|
|
143
|
+
console.print(f"[green]✓[/green] Indexed [bold]'{resolved_path}'[/bold] as project [bold]'{name}'[/bold] in {elapsed:.1f}s")
|
|
144
|
+
console.print(f" Nodes: {stats['nodes']} | Edges: {stats['edges']}")
|
|
145
|
+
show_next_steps("index")
|
|
119
146
|
|
|
120
147
|
|
|
121
|
-
@app.command("list-projects")
|
|
122
148
|
def list_projects():
|
|
123
|
-
"""List all persisted project memories.
|
|
149
|
+
"""📋 List all persisted project memories.
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
cg list-projects
|
|
153
|
+
"""
|
|
124
154
|
pm = ProjectManager()
|
|
125
155
|
projects = pm.list_projects()
|
|
126
156
|
current = pm.get_current_project()
|
|
@@ -134,9 +164,12 @@ def list_projects():
|
|
|
134
164
|
typer.echo(f"{marker} {p}")
|
|
135
165
|
|
|
136
166
|
|
|
137
|
-
@app.command("load-project")
|
|
138
167
|
def load_project(project_name: str = typer.Argument(..., help="Name of project memory to load.")):
|
|
139
|
-
"""Switch active project memory.
|
|
168
|
+
"""🔄 Switch active project memory.
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
cg load-project my-api
|
|
172
|
+
"""
|
|
140
173
|
pm = ProjectManager()
|
|
141
174
|
if project_name not in pm.list_projects():
|
|
142
175
|
raise typer.BadParameter(f"Project '{project_name}' not found.")
|
|
@@ -144,7 +177,6 @@ def load_project(project_name: str = typer.Argument(..., help="Name of project m
|
|
|
144
177
|
typer.echo(f"Loaded project '{project_name}'.")
|
|
145
178
|
|
|
146
179
|
|
|
147
|
-
@app.command("unload-project")
|
|
148
180
|
def unload_project():
|
|
149
181
|
"""Unload active project memory without deleting data."""
|
|
150
182
|
pm = ProjectManager()
|
|
@@ -152,9 +184,12 @@ def unload_project():
|
|
|
152
184
|
typer.echo("Unloaded active project.")
|
|
153
185
|
|
|
154
186
|
|
|
155
|
-
@app.command("delete-project")
|
|
156
187
|
def delete_project(project_name: str = typer.Argument(..., help="Project memory to delete.")):
|
|
157
|
-
"""Delete persisted project memory.
|
|
188
|
+
"""🗑️ Delete persisted project memory.
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
cg delete-project old-project
|
|
192
|
+
"""
|
|
158
193
|
pm = ProjectManager()
|
|
159
194
|
deleted = pm.delete_project(project_name)
|
|
160
195
|
if not deleted:
|
|
@@ -164,12 +199,15 @@ def delete_project(project_name: str = typer.Argument(..., help="Project memory
|
|
|
164
199
|
typer.echo(f"Deleted project '{project_name}'.")
|
|
165
200
|
|
|
166
201
|
|
|
167
|
-
@app.command("merge-projects")
|
|
168
202
|
def merge_projects(
|
|
169
203
|
source_project: str = typer.Argument(..., help="Project to merge from."),
|
|
170
204
|
target_project: str = typer.Argument(..., help="Project to merge into."),
|
|
171
205
|
):
|
|
172
|
-
"""Merge one project memory into another.
|
|
206
|
+
"""🔀 Merge one project memory into another.
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
cg merge-projects frontend backend
|
|
210
|
+
"""
|
|
173
211
|
pm = ProjectManager()
|
|
174
212
|
if source_project not in pm.list_projects() or target_project not in pm.list_projects():
|
|
175
213
|
raise typer.BadParameter("Both source and target projects must exist.")
|
|
@@ -183,19 +221,26 @@ def merge_projects(
|
|
|
183
221
|
typer.echo(f"Merged '{source_project}' into '{target_project}'.")
|
|
184
222
|
|
|
185
223
|
|
|
186
|
-
@app.command("search")
|
|
187
224
|
def search(
|
|
188
225
|
query: str = typer.Argument(..., help="Semantic query for code discovery."),
|
|
189
226
|
top_k: int = typer.Option(5, min=1, max=30, help="Maximum number of matches."),
|
|
190
227
|
):
|
|
191
|
-
"""
|
|
228
|
+
"""🔍 Semantic search across your codebase (alias: find).
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
cg search "database migration logic"
|
|
232
|
+
cg search "JWT token validation"
|
|
233
|
+
cg search "authentication" --top-k 10
|
|
234
|
+
"""
|
|
192
235
|
pm = ProjectManager()
|
|
193
236
|
store = _open_current_store(pm)
|
|
194
237
|
orchestrator = MCPOrchestrator(store)
|
|
195
|
-
|
|
238
|
+
|
|
239
|
+
with console.status("[cyan]Searching...[/cyan]"):
|
|
240
|
+
results = orchestrator.search(query, top_k=top_k)
|
|
196
241
|
|
|
197
242
|
if not results:
|
|
198
|
-
|
|
243
|
+
console.print("[yellow]No semantic matches found.[/yellow]")
|
|
199
244
|
store.close()
|
|
200
245
|
raise typer.Exit(code=0)
|
|
201
246
|
|
|
@@ -207,9 +252,9 @@ def search(
|
|
|
207
252
|
typer.echo(f" {snippet[0][:120]}")
|
|
208
253
|
|
|
209
254
|
store.close()
|
|
255
|
+
show_next_steps("search")
|
|
210
256
|
|
|
211
257
|
|
|
212
|
-
@app.command("impact")
|
|
213
258
|
def impact(
|
|
214
259
|
symbol: str = typer.Argument(..., help="Function/class/module symbol to analyze."),
|
|
215
260
|
hops: int = typer.Option(2, min=1, max=6, help="Dependency traversal depth."),
|
|
@@ -227,7 +272,12 @@ def impact(
|
|
|
227
272
|
help="LLM model name.",
|
|
228
273
|
),
|
|
229
274
|
):
|
|
230
|
-
"""
|
|
275
|
+
"""📊 Multi-hop impact analysis using graph + RAG + local LLM.
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
cg impact "UserService.authenticate"
|
|
279
|
+
cg impact "process_payment" --hops 3
|
|
280
|
+
"""
|
|
231
281
|
pm = ProjectManager()
|
|
232
282
|
store = _open_current_store(pm)
|
|
233
283
|
orchestrator = MCPOrchestrator(
|
|
@@ -270,12 +320,16 @@ def impact(
|
|
|
270
320
|
store.close()
|
|
271
321
|
|
|
272
322
|
|
|
273
|
-
@app.command("graph")
|
|
274
323
|
def graph(
|
|
275
324
|
symbol: str = typer.Argument(..., help="Function/class/module symbol to inspect."),
|
|
276
325
|
depth: int = typer.Option(2, min=1, max=6, help="Traversal depth."),
|
|
277
326
|
):
|
|
278
|
-
"""Show lightweight ASCII dependency graph around a symbol.
|
|
327
|
+
"""🕸️ Show lightweight ASCII dependency graph around a symbol.
|
|
328
|
+
|
|
329
|
+
Example:
|
|
330
|
+
cg graph "UserService"
|
|
331
|
+
cg graph "main" --depth 4
|
|
332
|
+
"""
|
|
279
333
|
pm = ProjectManager()
|
|
280
334
|
store = _open_current_store(pm)
|
|
281
335
|
orchestrator = MCPOrchestrator(store)
|
|
@@ -284,13 +338,18 @@ def graph(
|
|
|
284
338
|
store.close()
|
|
285
339
|
|
|
286
340
|
|
|
287
|
-
@app.command("export-graph")
|
|
288
341
|
def export_graph(
|
|
289
342
|
symbol: str = typer.Argument("", help="Optional focus symbol to export local subgraph."),
|
|
290
343
|
fmt: str = typer.Option("html", "--format", "-f", help="Export format: html or dot."),
|
|
291
344
|
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file path."),
|
|
292
345
|
):
|
|
293
|
-
"""Export graph to standalone HTML or Graphviz DOT.
|
|
346
|
+
"""📤 Export graph to standalone HTML or Graphviz DOT.
|
|
347
|
+
|
|
348
|
+
Example:
|
|
349
|
+
cg export-graph
|
|
350
|
+
cg export-graph UserService --format dot
|
|
351
|
+
cg export-graph --output my_graph.html
|
|
352
|
+
"""
|
|
294
353
|
fmt = fmt.lower()
|
|
295
354
|
if fmt not in {"html", "dot"}:
|
|
296
355
|
raise typer.BadParameter("Format must be one of: html, dot")
|
|
@@ -311,20 +370,27 @@ def export_graph(
|
|
|
311
370
|
store.close()
|
|
312
371
|
|
|
313
372
|
|
|
314
|
-
@app.command("current-project")
|
|
315
373
|
def current_project():
|
|
316
|
-
"""Print active project memory name.
|
|
374
|
+
"""📌 Print active project memory name.
|
|
375
|
+
|
|
376
|
+
Example:
|
|
377
|
+
cg current-project
|
|
378
|
+
"""
|
|
317
379
|
pm = ProjectManager()
|
|
318
380
|
current = pm.get_current_project()
|
|
319
381
|
typer.echo(current or "No project loaded")
|
|
320
382
|
|
|
321
383
|
|
|
322
|
-
@app.command("rag-context")
|
|
323
384
|
def rag_context(
|
|
324
385
|
query: str = typer.Argument(..., help="Query to retrieve code context without analysis."),
|
|
325
386
|
top_k: int = typer.Option(6, min=1, max=30, help="Number of snippets to fetch."),
|
|
326
387
|
):
|
|
327
|
-
"""Retrieve top semantic snippets to inspect RAG context directly.
|
|
388
|
+
"""📄 Retrieve top semantic snippets to inspect RAG context directly.
|
|
389
|
+
|
|
390
|
+
Example:
|
|
391
|
+
cg rag-context "authentication flow"
|
|
392
|
+
cg rag-context "database models" --top-k 10
|
|
393
|
+
"""
|
|
328
394
|
pm = ProjectManager()
|
|
329
395
|
store = _open_current_store(pm)
|
|
330
396
|
orchestrator = MCPOrchestrator(store)
|
|
@@ -332,5 +398,454 @@ def rag_context(
|
|
|
332
398
|
store.close()
|
|
333
399
|
|
|
334
400
|
|
|
401
|
+
# ===================================================================
|
|
402
|
+
# Tree command - Show project structure
|
|
403
|
+
# ===================================================================
|
|
404
|
+
|
|
405
|
+
def _build_tree_structure(store: "GraphStore", full: bool = False) -> Dict:
|
|
406
|
+
"""Build a nested tree structure from indexed nodes.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
store: GraphStore instance with indexed nodes
|
|
410
|
+
full: If True, include functions/classes breakdown
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Nested dict representing the tree structure
|
|
414
|
+
"""
|
|
415
|
+
from collections import defaultdict
|
|
416
|
+
|
|
417
|
+
# Get all nodes grouped by file
|
|
418
|
+
nodes_by_file = store.all_by_file()
|
|
419
|
+
|
|
420
|
+
# Build tree structure
|
|
421
|
+
tree: Dict = defaultdict(lambda: {"type": "directory", "children": {}, "functions": [], "classes": []})
|
|
422
|
+
|
|
423
|
+
for file_path, nodes in nodes_by_file.items():
|
|
424
|
+
# Split file path into parts
|
|
425
|
+
parts = Path(file_path).parts
|
|
426
|
+
current = tree
|
|
427
|
+
|
|
428
|
+
# Navigate/create directory structure
|
|
429
|
+
for part in parts[:-1]:
|
|
430
|
+
if part not in current:
|
|
431
|
+
current[part] = {"type": "directory", "children": {}, "functions": [], "classes": []}
|
|
432
|
+
current = current[part]["children"]
|
|
433
|
+
|
|
434
|
+
# Add file node
|
|
435
|
+
filename = parts[-1] if parts else file_path
|
|
436
|
+
file_node = {
|
|
437
|
+
"type": "file",
|
|
438
|
+
"path": file_path,
|
|
439
|
+
"functions": [],
|
|
440
|
+
"classes": [],
|
|
441
|
+
"other": [],
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if full:
|
|
445
|
+
# Categorize nodes by type
|
|
446
|
+
for node in nodes:
|
|
447
|
+
node_type = node.get("node_type", "unknown")
|
|
448
|
+
name = node.get("name", "unknown")
|
|
449
|
+
qualname = node.get("qualname", name)
|
|
450
|
+
start_line = node.get("start_line", 0)
|
|
451
|
+
end_line = node.get("end_line", 0)
|
|
452
|
+
|
|
453
|
+
node_info = {
|
|
454
|
+
"name": name,
|
|
455
|
+
"qualname": qualname,
|
|
456
|
+
"lines": f"L{start_line}-{end_line}",
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if node_type == "function":
|
|
460
|
+
file_node["functions"].append(node_info)
|
|
461
|
+
elif node_type == "class":
|
|
462
|
+
file_node["classes"].append(node_info)
|
|
463
|
+
else:
|
|
464
|
+
file_node["other"].append(node_info)
|
|
465
|
+
|
|
466
|
+
current[filename] = file_node
|
|
467
|
+
|
|
468
|
+
return dict(tree)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _render_tree(tree: Dict, prefix: str = "", full: bool = False, is_last: bool = True) -> str:
|
|
472
|
+
"""Render tree structure as ASCII art.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
tree: Nested dict representing the tree
|
|
476
|
+
prefix: Current line prefix for indentation
|
|
477
|
+
full: If True, show functions/classes breakdown
|
|
478
|
+
is_last: Whether this is the last item at current level
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
ASCII tree string
|
|
482
|
+
"""
|
|
483
|
+
lines = []
|
|
484
|
+
entries = sorted(tree.keys())
|
|
485
|
+
|
|
486
|
+
for i, name in enumerate(entries):
|
|
487
|
+
node = tree[name]
|
|
488
|
+
is_last_item = (i == len(entries) - 1)
|
|
489
|
+
|
|
490
|
+
# Choose connector
|
|
491
|
+
connector = " " if is_last_item else " |"
|
|
492
|
+
child_prefix = prefix + connector
|
|
493
|
+
|
|
494
|
+
# Determine node type indicator
|
|
495
|
+
if isinstance(node, dict):
|
|
496
|
+
node_type = node.get("type", "unknown")
|
|
497
|
+
|
|
498
|
+
if node_type == "directory":
|
|
499
|
+
# Directory - show with trailing slash
|
|
500
|
+
lines.append(f"{prefix}{'`-- ' if is_last_item else '|-- '}{name}/")
|
|
501
|
+
# Recursively render children
|
|
502
|
+
children = node.get("children", {})
|
|
503
|
+
if children:
|
|
504
|
+
lines.append(_render_tree(children, child_prefix, full, is_last_item))
|
|
505
|
+
|
|
506
|
+
elif node_type == "file":
|
|
507
|
+
# File - show filename
|
|
508
|
+
lines.append(f"{prefix}{'`-- ' if is_last_item else '|-- '}{name}")
|
|
509
|
+
|
|
510
|
+
if full:
|
|
511
|
+
# Show functions
|
|
512
|
+
functions = node.get("functions", [])
|
|
513
|
+
for j, func in enumerate(functions):
|
|
514
|
+
is_last_func = (j == len(functions) - 1) and not node.get("classes")
|
|
515
|
+
func_connector = " " if is_last_func else " |"
|
|
516
|
+
lines.append(f"{child_prefix}{'`-- ' if is_last_func else '|-- '}[fn] {func['name']} ({func['lines']})")
|
|
517
|
+
|
|
518
|
+
# Show classes
|
|
519
|
+
classes = node.get("classes", [])
|
|
520
|
+
for j, cls in enumerate(classes):
|
|
521
|
+
is_last_cls = (j == len(classes) - 1) and not node.get("other")
|
|
522
|
+
cls_connector = " " if is_last_cls else " |"
|
|
523
|
+
lines.append(f"{child_prefix}{'`-- ' if is_last_cls else '|-- '}[cls] {cls['name']} ({cls['lines']})")
|
|
524
|
+
|
|
525
|
+
# Show other nodes (modules, imports, etc.)
|
|
526
|
+
other = node.get("other", [])
|
|
527
|
+
for j, item in enumerate(other):
|
|
528
|
+
is_last_other = j == len(other) - 1
|
|
529
|
+
lines.append(f"{child_prefix}{'`-- ' if is_last_other else '|-- '}[{item.get('name', 'unknown')}]")
|
|
530
|
+
else:
|
|
531
|
+
# Simple entry
|
|
532
|
+
lines.append(f"{prefix}{'`-- ' if is_last_item else '|-- '}{name}")
|
|
533
|
+
|
|
534
|
+
return "\n".join(lines)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def tree_command(
|
|
538
|
+
full: bool = typer.Option(
|
|
539
|
+
False,
|
|
540
|
+
"--full",
|
|
541
|
+
"-f",
|
|
542
|
+
help="Show full breakdown including functions and classes within each file.",
|
|
543
|
+
),
|
|
544
|
+
path: Optional[Path] = typer.Option(
|
|
545
|
+
None,
|
|
546
|
+
"--path",
|
|
547
|
+
"-p",
|
|
548
|
+
help="Filter tree to show only files under a specific path prefix.",
|
|
549
|
+
),
|
|
550
|
+
):
|
|
551
|
+
"""Show the directory tree structure of the currently indexed project.
|
|
552
|
+
|
|
553
|
+
By default, shows files and directories. Use --full to see functions
|
|
554
|
+
and classes within each file.
|
|
555
|
+
|
|
556
|
+
Examples:
|
|
557
|
+
cg tree # Show basic file/directory tree
|
|
558
|
+
cg tree --full # Show tree with functions/classes breakdown
|
|
559
|
+
cg tree -p src/ # Show only files under src/ directory
|
|
560
|
+
"""
|
|
561
|
+
pm = ProjectManager()
|
|
562
|
+
store = _open_current_store(pm)
|
|
563
|
+
|
|
564
|
+
# Get project metadata for display
|
|
565
|
+
metadata = store.get_metadata()
|
|
566
|
+
project_name = metadata.get("project_name", pm.get_current_project() or "unknown")
|
|
567
|
+
source_path = metadata.get("source_path", "unknown")
|
|
568
|
+
|
|
569
|
+
typer.echo(f"Project: {project_name}")
|
|
570
|
+
typer.echo(f"Source: {source_path}")
|
|
571
|
+
typer.echo("")
|
|
572
|
+
|
|
573
|
+
# Build and render tree
|
|
574
|
+
tree = _build_tree_structure(store, full=full)
|
|
575
|
+
|
|
576
|
+
if not tree:
|
|
577
|
+
typer.echo("No files indexed in this project.")
|
|
578
|
+
store.close()
|
|
579
|
+
raise typer.Exit(code=0)
|
|
580
|
+
|
|
581
|
+
# Filter by path if specified
|
|
582
|
+
if path:
|
|
583
|
+
path_str = str(path).rstrip("/")
|
|
584
|
+
# Navigate to the specified sub-tree
|
|
585
|
+
parts = Path(path_str).parts
|
|
586
|
+
current = tree
|
|
587
|
+
for part in parts:
|
|
588
|
+
if part in current:
|
|
589
|
+
node = current[part]
|
|
590
|
+
if isinstance(node, dict) and node.get("type") == "directory":
|
|
591
|
+
current = node.get("children", {})
|
|
592
|
+
else:
|
|
593
|
+
current = {part: node}
|
|
594
|
+
break
|
|
595
|
+
else:
|
|
596
|
+
typer.echo(f"Path '{path}' not found in indexed project.")
|
|
597
|
+
store.close()
|
|
598
|
+
raise typer.Exit(code=1)
|
|
599
|
+
tree = current
|
|
600
|
+
|
|
601
|
+
# Render the tree
|
|
602
|
+
tree_output = _render_tree(tree, full=full)
|
|
603
|
+
typer.echo(tree_output)
|
|
604
|
+
|
|
605
|
+
# Show summary stats
|
|
606
|
+
nodes = store.get_nodes()
|
|
607
|
+
files = set(n["file_path"] for n in nodes)
|
|
608
|
+
functions = sum(1 for n in nodes if n["node_type"] == "function")
|
|
609
|
+
classes = sum(1 for n in nodes if n["node_type"] == "class")
|
|
610
|
+
|
|
611
|
+
typer.echo("")
|
|
612
|
+
typer.echo(f"Summary: {len(files)} files, {functions} functions, {classes} classes, {len(nodes)} total nodes")
|
|
613
|
+
|
|
614
|
+
# Detect unindexed files and warn
|
|
615
|
+
source = metadata.get("source_path") or metadata.get("project_root")
|
|
616
|
+
if source:
|
|
617
|
+
unindexed = _detect_unindexed_files(Path(source), files)
|
|
618
|
+
if unindexed:
|
|
619
|
+
typer.echo("")
|
|
620
|
+
typer.echo(
|
|
621
|
+
typer.style(
|
|
622
|
+
f"⚠ {len(unindexed)} file(s) on disk not in index:",
|
|
623
|
+
fg=typer.colors.YELLOW,
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
for f in sorted(unindexed)[:10]:
|
|
627
|
+
typer.echo(f" + {f}")
|
|
628
|
+
if len(unindexed) > 10:
|
|
629
|
+
typer.echo(f" … and {len(unindexed) - 10} more")
|
|
630
|
+
typer.echo(
|
|
631
|
+
f"\nRun {typer.style('cg analyze sync', fg=typer.colors.CYAN)} to incrementally index new/changed files."
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
store.close()
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
# ===================================================================
|
|
638
|
+
# Helpers: detect out-of-sync files
|
|
639
|
+
# ===================================================================
|
|
640
|
+
|
|
641
|
+
def _detect_unindexed_files(
|
|
642
|
+
source_root: Path, indexed_files: set[str],
|
|
643
|
+
) -> list[str]:
|
|
644
|
+
"""Return relative paths of parseable files on disk that are NOT in the index."""
|
|
645
|
+
from .parser import LANGUAGE_MAP, SKIP_DIRS
|
|
646
|
+
|
|
647
|
+
unindexed: list[str] = []
|
|
648
|
+
if not source_root.is_dir():
|
|
649
|
+
return unindexed
|
|
650
|
+
|
|
651
|
+
for ext in LANGUAGE_MAP:
|
|
652
|
+
for fp in sorted(source_root.rglob(f"*{ext}")):
|
|
653
|
+
if any(part in SKIP_DIRS for part in fp.parts):
|
|
654
|
+
continue
|
|
655
|
+
rel = str(fp.relative_to(source_root))
|
|
656
|
+
if rel not in indexed_files:
|
|
657
|
+
unindexed.append(rel)
|
|
658
|
+
return unindexed
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ===================================================================
|
|
662
|
+
# Sync command — incremental index of new / changed files
|
|
663
|
+
# ===================================================================
|
|
664
|
+
|
|
665
|
+
def sync_command(
|
|
666
|
+
dry_run: bool = typer.Option(
|
|
667
|
+
False, "--dry-run", "-n",
|
|
668
|
+
help="Only list what would be synced; don't modify the index.",
|
|
669
|
+
),
|
|
670
|
+
):
|
|
671
|
+
"""Incrementally sync the index with the source directory.
|
|
672
|
+
|
|
673
|
+
Detects new and deleted files relative to the current index and
|
|
674
|
+
updates accordingly — much faster than a full re-index.
|
|
675
|
+
|
|
676
|
+
Examples:
|
|
677
|
+
cg analyze sync # sync new/deleted files
|
|
678
|
+
cg analyze sync --dry-run # preview changes only
|
|
679
|
+
"""
|
|
680
|
+
from .embeddings import get_embedder
|
|
681
|
+
from .parser import LANGUAGE_MAP, SKIP_DIRS
|
|
682
|
+
|
|
683
|
+
pm = ProjectManager()
|
|
684
|
+
store = _open_current_store(pm)
|
|
685
|
+
metadata = store.get_metadata()
|
|
686
|
+
source = metadata.get("source_path") or metadata.get("project_root")
|
|
687
|
+
|
|
688
|
+
if not source:
|
|
689
|
+
typer.echo("❌ No source path recorded for this project. Re-index with: cg project index <path>")
|
|
690
|
+
store.close()
|
|
691
|
+
raise typer.Exit(1)
|
|
692
|
+
|
|
693
|
+
source_root = Path(source)
|
|
694
|
+
if not source_root.is_dir():
|
|
695
|
+
typer.echo(f"❌ Source path no longer exists: {source}")
|
|
696
|
+
store.close()
|
|
697
|
+
raise typer.Exit(1)
|
|
698
|
+
|
|
699
|
+
# Gather indexed files
|
|
700
|
+
nodes = store.get_nodes()
|
|
701
|
+
indexed_files = set(n["file_path"] for n in nodes)
|
|
702
|
+
|
|
703
|
+
# Gather files on disk
|
|
704
|
+
disk_files: set[str] = set()
|
|
705
|
+
for ext in LANGUAGE_MAP:
|
|
706
|
+
for fp in sorted(source_root.rglob(f"*{ext}")):
|
|
707
|
+
if any(part in SKIP_DIRS for part in fp.parts):
|
|
708
|
+
continue
|
|
709
|
+
disk_files.add(str(fp.relative_to(source_root)))
|
|
710
|
+
|
|
711
|
+
new_files = sorted(disk_files - indexed_files)
|
|
712
|
+
deleted_files = sorted(indexed_files - disk_files)
|
|
713
|
+
|
|
714
|
+
if not new_files and not deleted_files:
|
|
715
|
+
typer.echo("✅ Index is already in sync — no changes detected.")
|
|
716
|
+
store.close()
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
# Report
|
|
720
|
+
if new_files:
|
|
721
|
+
typer.echo(typer.style(f"\n📄 {len(new_files)} new file(s):", bold=True))
|
|
722
|
+
for f in new_files:
|
|
723
|
+
typer.echo(f" + {f}")
|
|
724
|
+
|
|
725
|
+
if deleted_files:
|
|
726
|
+
typer.echo(typer.style(f"\n🗑 {len(deleted_files)} deleted file(s):", bold=True))
|
|
727
|
+
for f in deleted_files:
|
|
728
|
+
typer.echo(f" - {f}")
|
|
729
|
+
|
|
730
|
+
if dry_run:
|
|
731
|
+
typer.echo(f"\n(dry run — no changes made)")
|
|
732
|
+
store.close()
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
# Apply changes
|
|
736
|
+
embedder = get_embedder()
|
|
737
|
+
model_key = getattr(embedder, "model_key", "hash")
|
|
738
|
+
|
|
739
|
+
added_nodes = 0
|
|
740
|
+
removed_nodes = 0
|
|
741
|
+
|
|
742
|
+
with Progress(
|
|
743
|
+
SpinnerColumn(),
|
|
744
|
+
TextColumn("[progress.description]{task.description}"),
|
|
745
|
+
BarColumn(),
|
|
746
|
+
TaskProgressColumn(),
|
|
747
|
+
console=console,
|
|
748
|
+
) as progress:
|
|
749
|
+
total = len(new_files) + len(deleted_files)
|
|
750
|
+
task = progress.add_task("Syncing...", total=total)
|
|
751
|
+
|
|
752
|
+
for f in deleted_files:
|
|
753
|
+
removed_nodes += store.remove_nodes_for_file(f)
|
|
754
|
+
progress.advance(task)
|
|
755
|
+
|
|
756
|
+
for f in new_files:
|
|
757
|
+
fp = source_root / f
|
|
758
|
+
added_nodes += store.index_single_file(
|
|
759
|
+
file_path=fp,
|
|
760
|
+
project_root=source_root,
|
|
761
|
+
embedder=embedder,
|
|
762
|
+
model_key=model_key,
|
|
763
|
+
)
|
|
764
|
+
progress.advance(task)
|
|
765
|
+
|
|
766
|
+
typer.echo(
|
|
767
|
+
f"\n✅ Sync complete: "
|
|
768
|
+
f"+{added_nodes} nodes ({len(new_files)} files), "
|
|
769
|
+
f"-{removed_nodes} nodes ({len(deleted_files)} files)"
|
|
770
|
+
)
|
|
771
|
+
store.close()
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# ===================================================================
|
|
775
|
+
# Wire functions into groups (the ONLY way to reach these commands)
|
|
776
|
+
# ===================================================================
|
|
777
|
+
|
|
778
|
+
# Config group: setup, LLM, and embedding management
|
|
779
|
+
config_grp.command("setup")(setup_wizard)
|
|
780
|
+
config_grp.command("set-llm")(set_llm)
|
|
781
|
+
config_grp.command("unset-llm")(unset_llm)
|
|
782
|
+
config_grp.command("show-llm")(show_llm)
|
|
783
|
+
config_grp.command("set-embedding")(set_embedding)
|
|
784
|
+
config_grp.command("unset-embedding")(unset_embedding)
|
|
785
|
+
config_grp.command("show-embedding")(show_embedding)
|
|
786
|
+
|
|
787
|
+
# Project group: index, load, list, delete, merge, current, init, watch
|
|
788
|
+
project_grp.command("index")(index_project)
|
|
789
|
+
project_grp.command("list")(list_projects)
|
|
790
|
+
project_grp.command("load")(load_project)
|
|
791
|
+
project_grp.command("unload")(unload_project)
|
|
792
|
+
project_grp.command("delete")(delete_project)
|
|
793
|
+
project_grp.command("merge")(merge_projects)
|
|
794
|
+
project_grp.command("current")(current_project)
|
|
795
|
+
project_grp.add_typer(quickstart_app, name="init", help="🚀 Quick-start wizard")
|
|
796
|
+
project_grp.add_typer(watch_app, name="watch", help="👀 Auto-reindex on file changes")
|
|
797
|
+
|
|
798
|
+
# Analyze group: search, impact, graph, export-graph, rag-context, tree, health
|
|
799
|
+
analyze_grp.command("search")(search)
|
|
800
|
+
analyze_grp.command("impact")(impact)
|
|
801
|
+
analyze_grp.command("graph")(graph)
|
|
802
|
+
analyze_grp.command("export-graph")(export_graph)
|
|
803
|
+
analyze_grp.command("rag-context")(rag_context)
|
|
804
|
+
analyze_grp.command("tree")(tree_command)
|
|
805
|
+
analyze_grp.command("sync")(sync_command)
|
|
806
|
+
analyze_grp.add_typer(health_app, name="health", help="🏥 Project health dashboard")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ===================================================================
|
|
810
|
+
# Fuzzy command matching for typos
|
|
811
|
+
# ===================================================================
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _get_all_command_names() -> list[str]:
|
|
815
|
+
"""Collect all registered command names including groups."""
|
|
816
|
+
names = []
|
|
817
|
+
for cmd in app.registered_commands:
|
|
818
|
+
if cmd.name:
|
|
819
|
+
names.append(cmd.name)
|
|
820
|
+
for group in app.registered_groups:
|
|
821
|
+
if group.name:
|
|
822
|
+
names.append(group.name)
|
|
823
|
+
return names
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def cli_main() -> None:
|
|
827
|
+
"""Entry point with fuzzy command matching on unknown commands.
|
|
828
|
+
|
|
829
|
+
This wraps the Typer app invocation to catch unknown-command errors
|
|
830
|
+
(exit code 2) and suggest similar commands using difflib.
|
|
831
|
+
"""
|
|
832
|
+
import sys
|
|
833
|
+
|
|
834
|
+
try:
|
|
835
|
+
app()
|
|
836
|
+
except SystemExit as e:
|
|
837
|
+
if e.code == 2 and len(sys.argv) > 1:
|
|
838
|
+
unknown = sys.argv[1]
|
|
839
|
+
if not unknown.startswith("-"):
|
|
840
|
+
all_commands = _get_all_command_names()
|
|
841
|
+
suggestions = get_close_matches(unknown, all_commands, n=3, cutoff=0.5)
|
|
842
|
+
if suggestions:
|
|
843
|
+
console.print(f"\n[yellow]Unknown command '[bold]{unknown}[/bold]'. Did you mean:[/yellow]")
|
|
844
|
+
for s in suggestions:
|
|
845
|
+
console.print(f" [cyan]cg {s}[/cyan]")
|
|
846
|
+
console.print()
|
|
847
|
+
raise
|
|
848
|
+
|
|
849
|
+
|
|
335
850
|
if __name__ == "__main__":
|
|
336
|
-
|
|
851
|
+
cli_main()
|