ctxgraph-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctxgraph_code/cli.py ADDED
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ctxgraph_code.config.init import init_project
12
+ from ctxgraph_code.config.settings import Settings
13
+ from ctxgraph_code.graph.builder import build_graph, get_storage
14
+ from ctxgraph_code.graph.query import search_relevant_nodes
15
+ from ctxgraph_code.render import (
16
+ render_context,
17
+ render_deps,
18
+ render_overview,
19
+ render_symbols,
20
+ render_usedby,
21
+ )
22
+
23
+ app = typer.Typer(name="ctxgraph-code", help="Code knowledge graph for Claude Code")
24
+ console = Console()
25
+
26
+
27
+ SLASH_COMMAND_TEMPLATE = """# ctxgraph-code: Code Relationship Graph
28
+
29
+ This project has a knowledge graph at `.ctxgraph/graph.db`.
30
+ The graph knows about imports, class hierarchies, and function calls.
31
+
32
+ **Available commands** (run these as shell commands in the terminal):
33
+
34
+ - `ctxgraph-code query "search terms"` -- Find relevant files, classes, and functions
35
+ - `ctxgraph-code deps <path>` -- Show what a file imports and what calls it
36
+ - `ctxgraph-code usedby <path>` -- Show what depends on a file
37
+ - `ctxgraph-code overview` -- Show the full project structure
38
+ - `ctxgraph-code symbols <path>` -- List classes/functions defined in a file
39
+ - `ctxgraph-code context "task description"` -- Generate a focused context summary
40
+
41
+ **When to use:**
42
+ - Before modifying code, run `deps` and `usedby` to understand ripple effects.
43
+ - When exploring an unfamiliar area, run `query` to find relevant files, then read them.
44
+ - When asked about architecture, run `overview` for the big picture.
45
+ - For complex tasks, run `context "what I need to do"` for a focused summary.
46
+ """
47
+
48
+
49
+ @app.callback()
50
+ def callback():
51
+ pass
52
+
53
+
54
+ @app.command()
55
+ def init(
56
+ repo_path: Optional[str] = typer.Argument(
57
+ None, help="Path to repository (default: current directory)"
58
+ ),
59
+ ):
60
+ """Scaffold .ctxgraph directory with default config."""
61
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
62
+ result = init_project(path)
63
+ config_path = result / "config.toml"
64
+ console.print(f"[green]Created {config_path}[/green]")
65
+ console.print(f"[green]Initialized .ctxgraph in: {result}[/green]")
66
+
67
+
68
+ @app.command()
69
+ def build(
70
+ repo_path: Optional[str] = typer.Argument(
71
+ None, help="Path to repository (default: current directory)"
72
+ ),
73
+ exclude: Optional[list[str]] = typer.Option(
74
+ None, "--exclude", "-e", help="Additional exclude patterns"
75
+ ),
76
+ ):
77
+ """Build the knowledge graph from Python source files."""
78
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
79
+
80
+ settings = Settings(path)
81
+ user_patterns = settings.exclude_patterns
82
+ if exclude:
83
+ user_patterns = list((user_patterns or []) + exclude)
84
+
85
+ if not (path / ".ctxgraph").exists():
86
+ (path / ".ctxgraph").mkdir(parents=True, exist_ok=True)
87
+
88
+ with console.status(f"Analyzing {path}..."):
89
+ stats = build_graph(path, exclude_patterns=user_patterns)
90
+
91
+ table = Table(title="Graph Build Complete")
92
+ table.add_column("Metric", style="cyan")
93
+ table.add_column("Value", style="green")
94
+
95
+ table.add_row("Files Analyzed", str(stats["files_analyzed"]))
96
+ table.add_row("Files Skipped", str(stats.get("files_skipped", 0)))
97
+ table.add_row("Errors", str(stats.get("errors", 0)))
98
+ table.add_row("Total Nodes", str(stats.get("total_nodes", 0)))
99
+ table.add_row("Total Edges", str(stats.get("total_edges", 0)))
100
+ table.add_row("Time", f"{stats.get('elapsed_seconds', 0)}s")
101
+
102
+ console.print(table)
103
+ console.print(f"\nGraph stored in: [bold]{path / '.ctxgraph' / 'graph.db'}[/bold]")
104
+
105
+
106
+ @app.command()
107
+ def query(
108
+ query: str = typer.Argument(..., help="Search query"),
109
+ repo_path: Optional[str] = typer.Option(
110
+ None, "--repo", "-r", help="Repository path"
111
+ ),
112
+ max_results: int = typer.Option(
113
+ 15, "--max", "-m", help="Maximum number of results"
114
+ ),
115
+ ):
116
+ """Search the knowledge graph for relevant files, classes, and functions."""
117
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
118
+
119
+ storage = get_storage(path)
120
+ if storage is None:
121
+ console.print("[red]No graph found. Run [bold]ctxgraph-code build[/bold] first.[/red]")
122
+ raise typer.Exit(1)
123
+
124
+ results = search_relevant_nodes(storage, query, max_nodes=max_results)
125
+
126
+ if not results:
127
+ console.print("[yellow]No matches found.[/yellow]")
128
+ return
129
+
130
+ table = Table(title=f"Search Results: {query}")
131
+ table.add_column("Type", style="cyan")
132
+ table.add_column("Name", style="green")
133
+ table.add_column("Path", style="blue")
134
+ table.add_column("Score", style="yellow")
135
+
136
+ for node, score in results:
137
+ type_tag = {"file": "F", "class": "C", "function": "M"}
138
+ table.add_row(
139
+ type_tag.get(node.type, "?"),
140
+ node.name,
141
+ node.path or "-",
142
+ str(score),
143
+ )
144
+
145
+ console.print(table)
146
+
147
+
148
+ @app.command()
149
+ def deps(
150
+ file_path: str = typer.Argument(..., help="Path to file (relative to repo root)"),
151
+ repo_path: Optional[str] = typer.Option(
152
+ None, "--repo", "-r", help="Repository path"
153
+ ),
154
+ ):
155
+ """Show imports, dependents, and call relationships for a file."""
156
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
157
+
158
+ storage = get_storage(path)
159
+ if storage is None:
160
+ console.print("[red]No graph found. Run [bold]ctxgraph-code build[/bold] first.[/red]")
161
+ raise typer.Exit(1)
162
+
163
+ output = render_deps(storage, file_path)
164
+ console.print(output)
165
+
166
+
167
+ @app.command()
168
+ def usedby(
169
+ file_path: str = typer.Argument(..., help="Path to file (relative to repo root)"),
170
+ repo_path: Optional[str] = typer.Option(
171
+ None, "--repo", "-r", help="Repository path"
172
+ ),
173
+ ):
174
+ """Show what files depend on a given file."""
175
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
176
+
177
+ storage = get_storage(path)
178
+ if storage is None:
179
+ console.print("[red]No graph found. Run [bold]ctxgraph-code build[/bold] first.[/red]")
180
+ raise typer.Exit(1)
181
+
182
+ output = render_usedby(storage, file_path)
183
+ console.print(output)
184
+
185
+
186
+ @app.command()
187
+ def overview(
188
+ repo_path: Optional[str] = typer.Option(
189
+ None, "--repo", "-r", help="Repository path"
190
+ ),
191
+ ):
192
+ """Show the full project structure from the graph."""
193
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
194
+
195
+ storage = get_storage(path)
196
+ if storage is None:
197
+ console.print("[red]No graph found. Run [bold]ctxgraph-code build[/bold] first.[/red]")
198
+ raise typer.Exit(1)
199
+
200
+ output = render_overview(storage)
201
+ console.print(output)
202
+
203
+
204
+ @app.command()
205
+ def symbols(
206
+ file_path: str = typer.Argument(..., help="Path to file (relative to repo root)"),
207
+ repo_path: Optional[str] = typer.Option(
208
+ None, "--repo", "-r", help="Repository path"
209
+ ),
210
+ ):
211
+ """List classes and functions defined in a file."""
212
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
213
+
214
+ storage = get_storage(path)
215
+ if storage is None:
216
+ console.print("[red]No graph found. Run [bold]ctxgraph-code build[/bold] first.[/red]")
217
+ raise typer.Exit(1)
218
+
219
+ output = render_symbols(storage, file_path)
220
+ console.print(output)
221
+
222
+
223
+ @app.command()
224
+ def context(
225
+ query: str = typer.Argument(..., help="Task description"),
226
+ repo_path: Optional[str] = typer.Option(
227
+ None, "--repo", "-r", help="Repository path"
228
+ ),
229
+ max_nodes: int = typer.Option(
230
+ 15, "--max-nodes", "-n", help="Maximum nodes to include"
231
+ ),
232
+ ):
233
+ """Generate a focused context summary for a specific task."""
234
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
235
+
236
+ storage = get_storage(path)
237
+ if storage is None:
238
+ console.print("[red]No graph found. Run [bold]ctxgraph-code build[/bold] first.[/red]")
239
+ raise typer.Exit(1)
240
+
241
+ output = render_context(storage, query, max_nodes=max_nodes)
242
+ console.print(output)
243
+
244
+
245
+ @app.command()
246
+ def setup(
247
+ repo_path: Optional[str] = typer.Argument(
248
+ None, help="Path to repository (default: current directory)"
249
+ ),
250
+ ):
251
+ """Initialize config, build the graph, and configure Claude Code integration."""
252
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
253
+
254
+ init_project(path)
255
+ console.print(f"[green][OK] Initialized .ctxgraph/[/green]")
256
+
257
+ settings = Settings(path)
258
+ with console.status(f"Building graph for {path}..."):
259
+ stats = build_graph(path, exclude_patterns=settings.exclude_patterns)
260
+
261
+ table = Table(title="Graph Build Complete")
262
+ table.add_column("Metric", style="cyan")
263
+ table.add_column("Value", style="green")
264
+ table.add_row("Files Analyzed", str(stats["files_analyzed"]))
265
+ table.add_row("Files Skipped", str(stats.get("files_skipped", 0)))
266
+ table.add_row("Errors", str(stats.get("errors", 0)))
267
+ table.add_row("Total Nodes", str(stats.get("total_nodes", 0)))
268
+ table.add_row("Total Edges", str(stats.get("total_edges", 0)))
269
+ table.add_row("Time", f"{stats.get('elapsed_seconds', 0)}s")
270
+ console.print(table)
271
+ console.print(f"[green][OK] Built graph[/green]")
272
+
273
+ claude_dir = path / ".claude" / "commands"
274
+ claude_dir.mkdir(parents=True, exist_ok=True)
275
+ slash_path = claude_dir / "ctxgraph-code.md"
276
+ if not slash_path.exists():
277
+ slash_path.write_text(SLASH_COMMAND_TEMPLATE, encoding="utf-8")
278
+ console.print(f"[green][OK] Created {slash_path}[/green]")
279
+ else:
280
+ console.print(f"[yellow] Skipped (already exists): {slash_path}[/yellow]")
281
+
282
+ console.print()
283
+ console.print("[bold green]Setup complete![/bold green]")
284
+ console.print("Open Claude Code in this project and type [bold]/ctxgraph-code[/bold] to get started.")
285
+
286
+
287
+ @app.command()
288
+ def info(
289
+ repo_path: Optional[str] = typer.Option(
290
+ None, "--repo", "-r", help="Repository path"
291
+ ),
292
+ ):
293
+ """Show graph statistics."""
294
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
295
+
296
+ storage = get_storage(path)
297
+ if storage is None:
298
+ console.print("[red]No graph found. Run [bold]ctxgraph-code build[/bold] first.[/red]")
299
+ raise typer.Exit(1)
300
+
301
+ stats = storage.stats()
302
+ build_time = storage.get_metadata("build_time")
303
+ file_count = storage.get_metadata("file_count")
304
+
305
+ table = Table(title="Graph Info")
306
+ table.add_column("Metric", style="cyan")
307
+ table.add_column("Value", style="green")
308
+
309
+ table.add_row("Total Nodes", str(stats["nodes"]))
310
+ table.add_row("Total Edges", str(stats["edges"]))
311
+
312
+ plural_map = {"file": "files", "class": "classes", "function": "functions", "module": "modules"}
313
+ for t, cnt in stats.get("types", {}).items():
314
+ label = plural_map.get(t, t + "s")
315
+ table.add_row(f" {label}", str(cnt))
316
+
317
+ if file_count:
318
+ table.add_row("Files Analyzed", file_count)
319
+ if build_time:
320
+ table.add_row("Last Build", build_time)
321
+
322
+ console.print(table)
323
+
324
+
325
+ @app.command()
326
+ def version():
327
+ """Show the version number."""
328
+ from importlib.metadata import version as _v
329
+ try:
330
+ ver = _v("ctxgraph-code")
331
+ except Exception:
332
+ ver = "0.1.0"
333
+ console.print(f"ctxgraph-code version [bold]{ver}[/bold]")
334
+
335
+
336
+ if __name__ == "__main__":
337
+ app()
File without changes
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ctxgraph_code.config.settings import create_default_config
6
+
7
+
8
+ def init_project(repo_path: Path) -> Path:
9
+ cfg_dir = repo_path / ".ctxgraph"
10
+ cfg_dir.mkdir(parents=True, exist_ok=True)
11
+
12
+ create_default_config(repo_path)
13
+
14
+ return cfg_dir
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ DEFAULT_CONFIG = {
10
+ "graph": {
11
+ "exclude": [],
12
+ "follow_symlinks": False,
13
+ "max_file_size_mb": 5,
14
+ },
15
+ }
16
+
17
+
18
+ class Settings:
19
+ def __init__(self, repo_path: Optional[Path] = None):
20
+ self.repo_path = Path(repo_path).resolve() if repo_path else Path.cwd()
21
+ self._data = dict(DEFAULT_CONFIG)
22
+ self._load()
23
+
24
+ def _load(self):
25
+ config_paths = [
26
+ self.repo_path / ".ctxgraph" / "config.toml",
27
+ self.repo_path / ".ctxgraph" / "config.json",
28
+ self.repo_path / "ctxgraph-code.toml",
29
+ self.repo_path / "ctxgraph-code.json",
30
+ ]
31
+
32
+ for path in config_paths:
33
+ if path.exists():
34
+ self._load_file(path)
35
+ break
36
+
37
+ def _load_file(self, path: Path):
38
+ text = path.read_text(encoding="utf-8")
39
+ if path.suffix == ".json":
40
+ parsed = json.loads(text)
41
+ self._deep_merge(self._data, parsed)
42
+ elif path.suffix == ".toml":
43
+ parsed = self._parse_toml(text)
44
+ self._deep_merge(self._data, parsed)
45
+
46
+ @property
47
+ def exclude_patterns(self) -> list[str]:
48
+ return self._data["graph"].get("exclude", [])
49
+
50
+ def to_dict(self) -> dict:
51
+ return dict(self._data)
52
+
53
+ @staticmethod
54
+ def _parse_toml(text: str) -> dict:
55
+ result = {}
56
+ current_section = result
57
+
58
+ for line in text.split("\n"):
59
+ line = line.strip()
60
+ if not line or line.startswith("#"):
61
+ continue
62
+ if line.startswith("[") and line.endswith("]"):
63
+ section_name = line[1:-1].strip()
64
+ current_section = result.setdefault(section_name, {})
65
+ elif "=" in line:
66
+ key, _, value = line.partition("=")
67
+ key = key.strip()
68
+ value = value.strip()
69
+
70
+ if (value.startswith('"') and value.endswith('"')) or \
71
+ (value.startswith("'") and value.endswith("'")):
72
+ value = value[1:-1]
73
+ else:
74
+ value = Settings._parse_toml_value(value)
75
+
76
+ current_section[key] = value
77
+
78
+ return result
79
+
80
+ @staticmethod
81
+ def _parse_toml_value(value: str):
82
+ if value.lower() in ("true", "false"):
83
+ return value.lower() == "true"
84
+ try:
85
+ if "." in value:
86
+ return float(value)
87
+ return int(value)
88
+ except ValueError:
89
+ return value
90
+
91
+ @staticmethod
92
+ def _deep_merge(base: dict, override: dict):
93
+ for key, value in override.items():
94
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
95
+ Settings._deep_merge(base[key], value)
96
+ else:
97
+ base[key] = value
98
+
99
+
100
+ def create_default_config(repo_path: Path):
101
+ config_dir = repo_path / ".ctxgraph"
102
+ config_dir.mkdir(parents=True, exist_ok=True)
103
+
104
+ config_path = config_dir / "config.toml"
105
+ if config_path.exists():
106
+ return
107
+
108
+ config_path.write_text(
109
+ """# ctxgraph-code configuration
110
+
111
+ [graph]
112
+ # Additional exclude patterns (gitignore is used automatically)
113
+ exclude = []
114
+ # Follow symlinks when scanning files
115
+ follow_symlinks = false
116
+ # Skip files larger than this many MB
117
+ max_file_size_mb = 5
118
+ """,
119
+ encoding="utf-8",
120
+ )
121
+ return config_path
File without changes
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+
7
+ DEFAULT_EXCLUDE = [
8
+ "__pycache__",
9
+ "*.pyc",
10
+ ".git",
11
+ ".svn",
12
+ ".hg",
13
+ "node_modules",
14
+ "venv",
15
+ ".venv",
16
+ "env",
17
+ ".env",
18
+ "dist",
19
+ "build",
20
+ "*.egg-info",
21
+ ".pytest_cache",
22
+ ".mypy_cache",
23
+ ".ruff_cache",
24
+ ".tox",
25
+ ".nox",
26
+ "migrations",
27
+ "*.min.js",
28
+ "*.min.css",
29
+ ]
30
+
31
+
32
+ def should_exclude(
33
+ file_path: Path,
34
+ root_path: Path,
35
+ user_patterns: Optional[list[str]] = None,
36
+ ) -> bool:
37
+ patterns = list(DEFAULT_EXCLUDE)
38
+ if user_patterns:
39
+ patterns.extend(user_patterns)
40
+
41
+ rel_path = _relative_path(file_path, root_path)
42
+
43
+ for pattern in patterns:
44
+ if _matches_pattern(rel_path, pattern):
45
+ return True
46
+
47
+ return False
48
+
49
+
50
+ def _matches_pattern(path: str, pattern: str) -> bool:
51
+ if pattern.startswith("*."):
52
+ return path.endswith(pattern[1:])
53
+
54
+ if pattern.endswith("/"):
55
+ return pattern.rstrip("/") in path.split("/")
56
+
57
+ if "*" not in pattern:
58
+ return pattern in path.split("/")
59
+
60
+ if pattern.startswith("*") and pattern.endswith("*"):
61
+ mid = pattern[1:-1]
62
+ return mid in path
63
+ elif pattern.startswith("*"):
64
+ return path.endswith(pattern[1:])
65
+ elif pattern.endswith("*"):
66
+ return path.startswith(pattern[:-1])
67
+
68
+ return pattern in path
69
+
70
+
71
+ def _relative_path(file_path: Path, root_path: Path) -> str:
72
+ try:
73
+ return str(file_path.relative_to(root_path)).replace("\\", "/")
74
+ except ValueError:
75
+ return file_path.name
File without changes
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ctxgraph_code.analyzers.python.importer import analyze_imports
8
+ from ctxgraph_code.analyzers.python.semantic import enrich_node_summary
9
+ from ctxgraph_code.analyzers.python.symbols import analyze_symbols
10
+ from ctxgraph_code.exclude.patterns import should_exclude
11
+ from ctxgraph_code.graph.models import Graph
12
+ from ctxgraph_code.graph.storage import Storage
13
+
14
+
15
+ def build_graph(
16
+ repo_path: str | Path,
17
+ db_path: Optional[str | Path] = None,
18
+ exclude_patterns: Optional[list[str]] = None,
19
+ ) -> dict:
20
+ repo_path = Path(repo_path).resolve()
21
+ if db_path is None:
22
+ db_path = repo_path / ".ctxgraph" / "graph.db"
23
+
24
+ db_path = Path(db_path)
25
+ start = time.time()
26
+
27
+ storage = Storage(db_path)
28
+ storage.connect()
29
+ combined = Graph()
30
+ stats = {"files_analyzed": 0, "files_skipped": 0, "errors": 0}
31
+
32
+ python_files = list(repo_path.rglob("*.py"))
33
+ for file_path in python_files:
34
+ if should_exclude(file_path, repo_path, exclude_patterns):
35
+ stats["files_skipped"] += 1
36
+ continue
37
+
38
+ try:
39
+ import_graph = analyze_imports(file_path, repo_path)
40
+ combined.merge(import_graph)
41
+
42
+ symbol_graph = analyze_symbols(file_path, repo_path)
43
+ combined.merge(symbol_graph)
44
+
45
+ for node in combined.nodes.values():
46
+ if node.path and node.path in str(file_path):
47
+ if not node.summary:
48
+ summary = enrich_node_summary(node, file_path)
49
+ if summary:
50
+ node.summary = summary
51
+
52
+ stats["files_analyzed"] += 1
53
+ except Exception:
54
+ stats["errors"] += 1
55
+
56
+ storage.save_graph(combined)
57
+ storage.save_metadata("build_time", str(time.time()))
58
+ storage.save_metadata("repo_path", str(repo_path))
59
+ storage.save_metadata("file_count", str(stats["files_analyzed"]))
60
+ storage.close()
61
+
62
+ elapsed = time.time() - start
63
+ stats["elapsed_seconds"] = round(elapsed, 2)
64
+ stats["total_nodes"] = len(combined.nodes)
65
+ stats["total_edges"] = len(combined.edges)
66
+
67
+ return stats
68
+
69
+
70
+ def get_storage(repo_path: str | Path) -> Optional[Storage]:
71
+ db_path = Path(repo_path) / ".ctxgraph" / "graph.db"
72
+ if not db_path.exists():
73
+ return None
74
+ storage = Storage(db_path)
75
+ storage.connect()
76
+ return storage