context-gen-cli 1.0.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.
- context_gen/__init__.py +3 -0
- context_gen/cli.py +124 -0
- context_gen/extractor.py +181 -0
- context_gen/generator.py +153 -0
- context_gen/graph.py +196 -0
- context_gen/scanner.py +149 -0
- context_gen_cli-1.0.0.dist-info/METADATA +53 -0
- context_gen_cli-1.0.0.dist-info/RECORD +11 -0
- context_gen_cli-1.0.0.dist-info/WHEEL +5 -0
- context_gen_cli-1.0.0.dist-info/entry_points.txt +2 -0
- context_gen_cli-1.0.0.dist-info/top_level.txt +1 -0
context_gen/__init__.py
ADDED
context_gen/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context_gen.cli: Typer-based CLI entry point for context-gen-cli.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from context_gen.scanner import scan_project
|
|
15
|
+
from context_gen.extractor import extract_metadata
|
|
16
|
+
from context_gen.graph import build_dependency_graph
|
|
17
|
+
from context_gen.generator import generate_agent_md, generate_agent_json
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="context-gen",
|
|
21
|
+
help="⚡ Generate graph-based project context (agent.md / agent.json) for LLMs.",
|
|
22
|
+
add_completion=False,
|
|
23
|
+
no_args_is_help=False
|
|
24
|
+
)
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OutputFormat(str, Enum):
|
|
29
|
+
"""Supported output formats."""
|
|
30
|
+
MD = "md"
|
|
31
|
+
JSON = "json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def main(
|
|
36
|
+
path: Path = typer.Argument(
|
|
37
|
+
default=Path("."),
|
|
38
|
+
help="Target project directory to analyze.",
|
|
39
|
+
exists=True,
|
|
40
|
+
file_okay=False,
|
|
41
|
+
dir_okay=True,
|
|
42
|
+
readable=True,
|
|
43
|
+
resolve_path=True,
|
|
44
|
+
),
|
|
45
|
+
format: OutputFormat = typer.Option(
|
|
46
|
+
OutputFormat.MD,
|
|
47
|
+
"--format",
|
|
48
|
+
"-f",
|
|
49
|
+
help="Output format: 'md' (Markdown) or 'json' (MCP JSON).",
|
|
50
|
+
case_sensitive=False,
|
|
51
|
+
),
|
|
52
|
+
output: Optional[Path] = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--output",
|
|
55
|
+
"-o",
|
|
56
|
+
help="Custom output file path (e.g. context/agent.md or custom.json).",
|
|
57
|
+
),
|
|
58
|
+
max_depth: int = typer.Option(
|
|
59
|
+
3,
|
|
60
|
+
"--max-depth",
|
|
61
|
+
"-d",
|
|
62
|
+
help="Maximum directory depth to scan.",
|
|
63
|
+
min=1,
|
|
64
|
+
max=10,
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Scan a project, build an AST-powered dependency graph, and generate LLM context.
|
|
69
|
+
"""
|
|
70
|
+
target_dir = path.resolve()
|
|
71
|
+
|
|
72
|
+
console.print(
|
|
73
|
+
Panel.fit(
|
|
74
|
+
f"[bold cyan]⚡ context-gen-cli (Python Edition)[/bold cyan]\n"
|
|
75
|
+
f"[dim]Analyzing target:[/dim] [green]{target_dir}[/green]\n"
|
|
76
|
+
f"[dim]Format:[/dim] [yellow]{format.value.upper()}[/yellow]",
|
|
77
|
+
border_style="cyan"
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
with console.status("[bold green]Scanning directory and parsing AST graph...[/bold green]"):
|
|
82
|
+
scan_result = scan_project(str(target_dir), max_depth=max_depth)
|
|
83
|
+
files = scan_result["files"]
|
|
84
|
+
tree = scan_result["tree"]
|
|
85
|
+
|
|
86
|
+
metadata = extract_metadata(str(target_dir), files)
|
|
87
|
+
graph = build_dependency_graph(files, str(target_dir))
|
|
88
|
+
|
|
89
|
+
# Determine default destination if not provided
|
|
90
|
+
if output:
|
|
91
|
+
out_file = output.resolve()
|
|
92
|
+
else:
|
|
93
|
+
context_dir = target_dir / "context"
|
|
94
|
+
context_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
filename = f"agent.{format.value}"
|
|
96
|
+
out_file = context_dir / filename
|
|
97
|
+
|
|
98
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
if format == OutputFormat.JSON:
|
|
101
|
+
json_data = generate_agent_json(metadata, graph, tree, str(target_dir))
|
|
102
|
+
out_file.write_text(json.dumps(json_data, indent=2), encoding="utf-8")
|
|
103
|
+
else:
|
|
104
|
+
md_text = generate_agent_md(metadata, graph, tree)
|
|
105
|
+
out_file.write_text(md_text, encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
# Display completion summary
|
|
108
|
+
table = Table(show_header=False, box=None)
|
|
109
|
+
table.add_row("[bold]Scanned Files:[/bold]", f"[cyan]{len(files)}[/cyan]")
|
|
110
|
+
table.add_row("[bold]AST Graph Edges:[/bold]", f"[cyan]{len(graph['edges'])}[/cyan]")
|
|
111
|
+
table.add_row("[bold]Tech Stack:[/bold]", f"[green]{', '.join(metadata['techStack'])}[/green]")
|
|
112
|
+
table.add_row("[bold]Output File:[/bold]", f"[yellow]{out_file}[/yellow]")
|
|
113
|
+
|
|
114
|
+
console.print(
|
|
115
|
+
Panel(
|
|
116
|
+
table,
|
|
117
|
+
title="[bold green]✅ Context Generated Successfully[/bold green]",
|
|
118
|
+
border_style="green"
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
app()
|
context_gen/extractor.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context_gen.extractor: Extracts project metadata, tech stack, dependencies, and file descriptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
FRAMEWORK_PATTERNS = {
|
|
11
|
+
"FastAPI": ["fastapi"],
|
|
12
|
+
"Django": ["django"],
|
|
13
|
+
"Flask": ["flask"],
|
|
14
|
+
"Typer": ["typer"],
|
|
15
|
+
"Click": ["click"],
|
|
16
|
+
"PyTorch": ["torch", "pytorch"],
|
|
17
|
+
"TensorFlow": ["tensorflow"],
|
|
18
|
+
"Pandas": ["pandas"],
|
|
19
|
+
"NumPy": ["numpy"],
|
|
20
|
+
"Pydantic": ["pydantic"],
|
|
21
|
+
"SQLAlchemy": ["sqlalchemy"],
|
|
22
|
+
"Celery": ["celery"],
|
|
23
|
+
"Pytest": ["pytest"],
|
|
24
|
+
"Rich": ["rich"]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _strip_markdown(text: str) -> str:
|
|
29
|
+
"""Removes markdown syntax like headers, asterisks, links, and bold tags."""
|
|
30
|
+
clean = re.sub(r"#+\s*", "", text)
|
|
31
|
+
clean = re.sub(r"\*\*([^*]+)\*\*", r"\1", clean)
|
|
32
|
+
clean = re.sub(r"\*([^*]+)\*", r"\1", clean)
|
|
33
|
+
clean = re.sub(r"`([^`]+)`", r"\1", clean)
|
|
34
|
+
clean = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", clean)
|
|
35
|
+
clean = re.sub(r"\n+", " ", clean)
|
|
36
|
+
return clean.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_overview(root: Path, max_chars: int = 350) -> str:
|
|
40
|
+
"""Extracts project overview summary from README.md or fallback."""
|
|
41
|
+
for readme_name in ["README.md", "readme.md", "README.rst", "README.txt", "README"]:
|
|
42
|
+
readme_path = root / readme_name
|
|
43
|
+
if readme_path.is_file():
|
|
44
|
+
try:
|
|
45
|
+
content = readme_path.read_text(encoding="utf-8", errors="ignore").strip()
|
|
46
|
+
if content:
|
|
47
|
+
lines = [l.strip() for l in content.split("\n") if l.strip() and not l.startswith("#")]
|
|
48
|
+
summary = " ".join(lines[:4])
|
|
49
|
+
clean_summary = _strip_markdown(summary)
|
|
50
|
+
if len(clean_summary) > max_chars:
|
|
51
|
+
return clean_summary[:max_chars].rstrip() + "..."
|
|
52
|
+
return clean_summary or f"Project overview based on {readme_name}."
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
return f"Project workspace rooted at {root.name}."
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_dependencies(root: Path) -> Dict[str, Dict[str, str]]:
|
|
60
|
+
"""Extracts dependencies from requirements.txt, pyproject.toml, or setup.py."""
|
|
61
|
+
prod_deps: Dict[str, str] = {}
|
|
62
|
+
dev_deps: Dict[str, str] = {}
|
|
63
|
+
|
|
64
|
+
req_path = root / "requirements.txt"
|
|
65
|
+
if req_path.is_file():
|
|
66
|
+
try:
|
|
67
|
+
for line in req_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if line and not line.startswith("#"):
|
|
70
|
+
match = re.match(r"^([a-zA-Z0-9_\-\.]+)([=<>!~]+.*)?$", line)
|
|
71
|
+
if match:
|
|
72
|
+
prod_deps[match.group(1)] = match.group(2) or "latest"
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
pyproject_path = root / "pyproject.toml"
|
|
77
|
+
if pyproject_path.is_file():
|
|
78
|
+
try:
|
|
79
|
+
content = pyproject_path.read_text(encoding="utf-8", errors="ignore")
|
|
80
|
+
# Simple regex parser for pyproject dependencies list
|
|
81
|
+
dep_matches = re.findall(r'"([a-zA-Z0-9_\-\.]+)([=<>!~]+[^"]*)?"', content)
|
|
82
|
+
for pkg, ver in dep_matches:
|
|
83
|
+
if pkg not in {"setuptools", "wheel", "pytest", "build", "twine"}:
|
|
84
|
+
prod_deps[pkg] = ver or "latest"
|
|
85
|
+
else:
|
|
86
|
+
dev_deps[pkg] = ver or "latest"
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"production": prod_deps,
|
|
92
|
+
"development": dev_deps
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_tech_stack(root: Path, files: List[str], deps: Dict[str, Dict[str, str]]) -> List[str]:
|
|
97
|
+
"""Detects technologies and frameworks present in the codebase."""
|
|
98
|
+
stack: List[str] = []
|
|
99
|
+
|
|
100
|
+
has_py = any(f.endswith(".py") for f in files)
|
|
101
|
+
has_js = any(f.endswith((".js", ".ts", ".jsx", ".tsx")) for f in files)
|
|
102
|
+
has_html = any(f.endswith((".html", ".css")) for f in files)
|
|
103
|
+
|
|
104
|
+
if has_py or (root / "pyproject.toml").is_file() or (root / "requirements.txt").is_file():
|
|
105
|
+
stack.append("Python")
|
|
106
|
+
if has_js or (root / "package.json").is_file():
|
|
107
|
+
stack.append("JavaScript/Node.js")
|
|
108
|
+
if has_html:
|
|
109
|
+
stack.append("HTML/CSS")
|
|
110
|
+
|
|
111
|
+
all_dep_names = {k.lower() for k in deps.get("production", {})}
|
|
112
|
+
|
|
113
|
+
for framework, keywords in FRAMEWORK_PATTERNS.items():
|
|
114
|
+
if any(kw in all_dep_names for kw in keywords):
|
|
115
|
+
if framework not in stack:
|
|
116
|
+
stack.append(framework)
|
|
117
|
+
|
|
118
|
+
return stack or ["Python"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_file_description(file_path: Path) -> str:
|
|
122
|
+
"""Infers a file's purpose from module docstring or top comments."""
|
|
123
|
+
if file_path.suffix == ".py":
|
|
124
|
+
try:
|
|
125
|
+
source = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
126
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
127
|
+
docstring = ast.get_docstring(tree)
|
|
128
|
+
if docstring:
|
|
129
|
+
return _strip_markdown(docstring.strip().split("\n")[0])
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
lines = file_path.read_text(encoding="utf-8", errors="ignore").splitlines()[:15]
|
|
135
|
+
for line in lines:
|
|
136
|
+
line = line.strip()
|
|
137
|
+
if line.startswith(("#", "//", "/*")):
|
|
138
|
+
cleaned = _strip_markdown(line.lstrip("#/*").strip())
|
|
139
|
+
if cleaned and len(cleaned) > 5:
|
|
140
|
+
return cleaned
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
return f"Source file: {file_path.name}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def extract_metadata(root_path: str, files: List[str]) -> Dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Extracts high-level metadata, tech stack, descriptions, and dependencies.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
root_path (str): Project root directory.
|
|
153
|
+
files (List[str]): Scanned project files.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Dict[str, Any]: Extracted metadata.
|
|
157
|
+
"""
|
|
158
|
+
root = Path(root_path).resolve()
|
|
159
|
+
overview = extract_overview(root)
|
|
160
|
+
dependencies = extract_dependencies(root)
|
|
161
|
+
tech_stack = extract_tech_stack(root, files, dependencies)
|
|
162
|
+
|
|
163
|
+
key_files: List[Dict[str, str]] = []
|
|
164
|
+
# Identify top priority files
|
|
165
|
+
top_candidates = [
|
|
166
|
+
f for f in files if f.endswith((".py", ".js", ".ts", ".json", ".toml", ".md"))
|
|
167
|
+
and not f.startswith("test")
|
|
168
|
+
][:12]
|
|
169
|
+
|
|
170
|
+
for rel_file in top_candidates:
|
|
171
|
+
full_path = root / rel_file
|
|
172
|
+
desc = extract_file_description(full_path)
|
|
173
|
+
key_files.append({"path": rel_file, "description": desc})
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"overview": overview,
|
|
177
|
+
"techStack": tech_stack,
|
|
178
|
+
"dependencies": dependencies,
|
|
179
|
+
"keyFiles": key_files,
|
|
180
|
+
"projectPath": str(root)
|
|
181
|
+
}
|
context_gen/generator.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context_gen.generator: Generates structured agent.md and MCP-compatible agent.json.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_agent_json(
|
|
10
|
+
metadata: Dict[str, Any],
|
|
11
|
+
graph: Dict[str, Any],
|
|
12
|
+
tree: str = "",
|
|
13
|
+
project_path: str = ""
|
|
14
|
+
) -> Dict[str, Any]:
|
|
15
|
+
"""
|
|
16
|
+
Generates structured, MCP-compatible JSON representation of project context.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
metadata (Dict[str, Any]): Project metadata from extractor.
|
|
20
|
+
graph (Dict[str, Any]): AST dependency graph.
|
|
21
|
+
tree (str): Visual directory tree.
|
|
22
|
+
project_path (str): Absolute root project path.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Dict[str, Any]: Exact 12-key MCP context dictionary.
|
|
26
|
+
"""
|
|
27
|
+
path_val = project_path or metadata.get("projectPath", "")
|
|
28
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
29
|
+
|
|
30
|
+
# Filter entry points: only retain clean file paths
|
|
31
|
+
raw_entries: List[str] = graph.get("entryPoints", [])
|
|
32
|
+
entry_points = [
|
|
33
|
+
e for e in raw_entries
|
|
34
|
+
if not any(token in e for token in ["node ", "npm ", "python ", " "])
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"version": "1.0.0",
|
|
39
|
+
"generatedAt": now_iso,
|
|
40
|
+
"projectPath": path_val,
|
|
41
|
+
"overview": metadata.get("overview", ""),
|
|
42
|
+
"techStack": metadata.get("techStack", []),
|
|
43
|
+
"directoryStructure": tree or "",
|
|
44
|
+
"keyFiles": metadata.get("keyFiles", []),
|
|
45
|
+
"dependencies": metadata.get("dependencies", {"production": {}, "development": {}}),
|
|
46
|
+
"dependencyGraph": {
|
|
47
|
+
"nodes": graph.get("nodes", []),
|
|
48
|
+
"edges": graph.get("edges", [])
|
|
49
|
+
},
|
|
50
|
+
"entryPoints": entry_points,
|
|
51
|
+
"utilities": graph.get("utilities", []),
|
|
52
|
+
"scripts": metadata.get("scripts", {})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_agent_md(
|
|
57
|
+
metadata: Dict[str, Any],
|
|
58
|
+
graph: Dict[str, Any],
|
|
59
|
+
tree: str = ""
|
|
60
|
+
) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Generates human-readable Markdown context document (agent.md) for LLMs.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
metadata (Dict[str, Any]): Project metadata from extractor.
|
|
66
|
+
graph (Dict[str, Any]): AST dependency graph.
|
|
67
|
+
tree (str): Visual directory tree.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
str: Formatted Markdown string.
|
|
71
|
+
"""
|
|
72
|
+
lines: List[str] = [
|
|
73
|
+
"# Project Context & Architecture Guide",
|
|
74
|
+
"",
|
|
75
|
+
"> Auto-generated by `context-gen-cli` (Python Edition) for AI agent reasoning.",
|
|
76
|
+
"",
|
|
77
|
+
"## Overview",
|
|
78
|
+
metadata.get("overview", "No overview provided."),
|
|
79
|
+
"",
|
|
80
|
+
"## Tech Stack",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
stack = metadata.get("techStack", [])
|
|
84
|
+
if stack:
|
|
85
|
+
lines.extend([f"- **{tech}**" for tech in stack])
|
|
86
|
+
else:
|
|
87
|
+
lines.append("- Python")
|
|
88
|
+
|
|
89
|
+
lines.extend([
|
|
90
|
+
"",
|
|
91
|
+
"## Directory Structure",
|
|
92
|
+
"```",
|
|
93
|
+
tree.strip() if tree else "No directory tree available.",
|
|
94
|
+
"```",
|
|
95
|
+
"",
|
|
96
|
+
"## Key Files & Entry Points",
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
key_files = metadata.get("keyFiles", [])
|
|
100
|
+
if key_files:
|
|
101
|
+
for kf in key_files:
|
|
102
|
+
lines.append(f"- **`{kf['path']}`**: {kf['description']}")
|
|
103
|
+
else:
|
|
104
|
+
lines.append("- No key files detected.")
|
|
105
|
+
|
|
106
|
+
lines.extend([
|
|
107
|
+
"",
|
|
108
|
+
"## Module Dependency Graph",
|
|
109
|
+
"",
|
|
110
|
+
"### Entry Points",
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
entry_points = graph.get("entryPoints", [])
|
|
114
|
+
if entry_points:
|
|
115
|
+
for ep in entry_points:
|
|
116
|
+
lines.append(f"- `{ep}`")
|
|
117
|
+
else:
|
|
118
|
+
lines.append("- None detected.")
|
|
119
|
+
|
|
120
|
+
lines.extend([
|
|
121
|
+
"",
|
|
122
|
+
"### Core Modules & Utilities",
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
utilities = graph.get("utilities", [])
|
|
126
|
+
if utilities:
|
|
127
|
+
for u in utilities:
|
|
128
|
+
lines.append(f"- `{u}`")
|
|
129
|
+
else:
|
|
130
|
+
lines.append("- None detected.")
|
|
131
|
+
|
|
132
|
+
lines.extend([
|
|
133
|
+
"",
|
|
134
|
+
"### Import Relationships (Edges)",
|
|
135
|
+
"```mermaid",
|
|
136
|
+
"graph TD;",
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
edges = graph.get("edges", [])
|
|
140
|
+
if edges:
|
|
141
|
+
for edge in edges:
|
|
142
|
+
source = edge["from"].replace(".", "_").replace("/", "_").replace("-", "_")
|
|
143
|
+
target = edge["to"].replace(".", "_").replace("/", "_").replace("-", "_")
|
|
144
|
+
lines.append(f" {source}[\"{edge['from']}\"] --> {target}[\"{edge['to']}\"]")
|
|
145
|
+
else:
|
|
146
|
+
lines.append(" %% No internal dependencies detected")
|
|
147
|
+
|
|
148
|
+
lines.extend([
|
|
149
|
+
"```",
|
|
150
|
+
""
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
return "\n".join(lines)
|
context_gen/graph.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context_gen.graph: AST-based Python dependency graph builder.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Set
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("context_gen.graph")
|
|
11
|
+
|
|
12
|
+
ENTRY_CANDIDATES = {"main.py", "app.py", "cli.py", "index.py", "server.py", "manage.py", "run.py"}
|
|
13
|
+
UTIL_KEYWORDS = {"util", "utils", "helper", "helpers", "common", "tool", "tools", "config", "constant", "constants"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_module_to_file(
|
|
17
|
+
module_name: str,
|
|
18
|
+
from_file: str,
|
|
19
|
+
level: int,
|
|
20
|
+
available_files: Set[str],
|
|
21
|
+
root_path: Path
|
|
22
|
+
) -> List[str]:
|
|
23
|
+
"""
|
|
24
|
+
Resolves an AST import target to a concrete file in available_files.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
module_name (str): Module path (e.g. 'utils', 'models.user').
|
|
28
|
+
from_file (str): Relative path of the importing file.
|
|
29
|
+
level (int): Relative import level (0 for absolute, 1 for ., 2 for .., etc.).
|
|
30
|
+
available_files (Set[str]): Set of all scanned project files.
|
|
31
|
+
root_path (Path): Absolute project root path.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List[str]: Resolved matching file paths in the project.
|
|
35
|
+
"""
|
|
36
|
+
resolved: List[str] = []
|
|
37
|
+
current_file_path = Path(from_file)
|
|
38
|
+
current_dir = current_file_path.parent
|
|
39
|
+
|
|
40
|
+
# Determine base directory for resolution
|
|
41
|
+
if level > 0:
|
|
42
|
+
# Relative import
|
|
43
|
+
base_dir = current_dir
|
|
44
|
+
for _ in range(level - 1):
|
|
45
|
+
base_dir = base_dir.parent
|
|
46
|
+
else:
|
|
47
|
+
# Absolute import in project root or relative to current_dir
|
|
48
|
+
base_dir = Path(".")
|
|
49
|
+
|
|
50
|
+
module_parts = module_name.split(".") if module_name else []
|
|
51
|
+
module_rel_path = "/".join(module_parts)
|
|
52
|
+
|
|
53
|
+
candidates: List[str] = []
|
|
54
|
+
|
|
55
|
+
if module_rel_path:
|
|
56
|
+
# 1. module as a .py file from base_dir: base_dir/module.py
|
|
57
|
+
candidates.append((base_dir / f"{module_rel_path}.py").as_posix())
|
|
58
|
+
# 2. module as a package: base_dir/module/__init__.py
|
|
59
|
+
candidates.append((base_dir / module_rel_path / "__init__.py").as_posix())
|
|
60
|
+
|
|
61
|
+
# 3. If level == 0, also try relative to current_dir
|
|
62
|
+
if level == 0 and current_dir != Path("."):
|
|
63
|
+
candidates.append((current_dir / f"{module_rel_path}.py").as_posix())
|
|
64
|
+
candidates.append((current_dir / module_rel_path / "__init__.py").as_posix())
|
|
65
|
+
|
|
66
|
+
# Normalize candidate paths (strip leading './' etc.)
|
|
67
|
+
for cand in candidates:
|
|
68
|
+
norm = Path(cand).as_posix()
|
|
69
|
+
if norm.startswith("./"):
|
|
70
|
+
norm = norm[2:]
|
|
71
|
+
if norm in available_files:
|
|
72
|
+
resolved.append(norm)
|
|
73
|
+
|
|
74
|
+
return resolved
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_dependency_graph(files: List[str], root_path: str) -> Dict[str, Any]:
|
|
78
|
+
"""
|
|
79
|
+
Builds a module dependency graph from project files using Python's AST parser.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
files (List[str]): List of relative file paths in the project.
|
|
83
|
+
root_path (str): Root project directory path.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dict[str, Any]: Graph structure matching MCP context schema.
|
|
87
|
+
"""
|
|
88
|
+
root = Path(root_path).resolve()
|
|
89
|
+
available_files: Set[str] = set(files)
|
|
90
|
+
py_files = [f for f in files if f.endswith(".py")]
|
|
91
|
+
|
|
92
|
+
imports_map: Dict[str, Set[str]] = {f: set() for f in py_files}
|
|
93
|
+
imported_by_map: Dict[str, Set[str]] = {f: set() for f in py_files}
|
|
94
|
+
edges: List[Dict[str, str]] = []
|
|
95
|
+
seen_edges: Set[tuple[str, str]] = set()
|
|
96
|
+
|
|
97
|
+
for py_file in py_files:
|
|
98
|
+
full_path = root / py_file
|
|
99
|
+
try:
|
|
100
|
+
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
101
|
+
source = f.read()
|
|
102
|
+
tree = ast.parse(source, filename=py_file)
|
|
103
|
+
except (SyntaxError, UnicodeDecodeError) as err:
|
|
104
|
+
logger.warning("Skipping syntax-invalid Python file %s: %s", py_file, err)
|
|
105
|
+
continue
|
|
106
|
+
except Exception as err:
|
|
107
|
+
logger.warning("Error reading %s: %s", py_file, err)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
for node in ast.walk(tree):
|
|
111
|
+
# Handle standard import statements: import a, b as c
|
|
112
|
+
if isinstance(node, ast.Import):
|
|
113
|
+
for alias in node.names:
|
|
114
|
+
targets = _resolve_module_to_file(
|
|
115
|
+
alias.name,
|
|
116
|
+
py_file,
|
|
117
|
+
level=0,
|
|
118
|
+
available_files=available_files,
|
|
119
|
+
root_path=root
|
|
120
|
+
)
|
|
121
|
+
for target in targets:
|
|
122
|
+
if target != py_file:
|
|
123
|
+
imports_map[py_file].add(target)
|
|
124
|
+
imported_by_map[target].add(py_file)
|
|
125
|
+
edge_key = (py_file, target)
|
|
126
|
+
if edge_key not in seen_edges:
|
|
127
|
+
seen_edges.add(edge_key)
|
|
128
|
+
edges.append({"from": py_file, "to": target, "type": "import"})
|
|
129
|
+
|
|
130
|
+
# Handle from ... import statements: from .models import User, or from utils import helper
|
|
131
|
+
elif isinstance(node, ast.ImportFrom):
|
|
132
|
+
module_name = node.module or ""
|
|
133
|
+
targets = _resolve_module_to_file(
|
|
134
|
+
module_name,
|
|
135
|
+
py_file,
|
|
136
|
+
level=node.level,
|
|
137
|
+
available_files=available_files,
|
|
138
|
+
root_path=root
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# If from package import submodule (e.g. from models import user)
|
|
142
|
+
if not targets and node.names:
|
|
143
|
+
for alias in node.names:
|
|
144
|
+
sub_module = f"{module_name}.{alias.name}" if module_name else alias.name
|
|
145
|
+
sub_targets = _resolve_module_to_file(
|
|
146
|
+
sub_module,
|
|
147
|
+
py_file,
|
|
148
|
+
level=node.level,
|
|
149
|
+
available_files=available_files,
|
|
150
|
+
root_path=root
|
|
151
|
+
)
|
|
152
|
+
targets.extend(sub_targets)
|
|
153
|
+
|
|
154
|
+
for target in targets:
|
|
155
|
+
if target != py_file:
|
|
156
|
+
imports_map[py_file].add(target)
|
|
157
|
+
imported_by_map[target].add(py_file)
|
|
158
|
+
edge_key = (py_file, target)
|
|
159
|
+
if edge_key not in seen_edges:
|
|
160
|
+
seen_edges.add(edge_key)
|
|
161
|
+
edges.append({"from": py_file, "to": target, "type": "import"})
|
|
162
|
+
|
|
163
|
+
entry_points: List[str] = []
|
|
164
|
+
utilities: List[str] = []
|
|
165
|
+
nodes: List[Dict[str, Any]] = []
|
|
166
|
+
|
|
167
|
+
for f in sorted(py_files):
|
|
168
|
+
name_lower = Path(f).name.lower()
|
|
169
|
+
stem_lower = Path(f).stem.lower()
|
|
170
|
+
|
|
171
|
+
# Classify node type
|
|
172
|
+
if name_lower in ENTRY_CANDIDATES or (len(imported_by_map[f]) == 0 and len(imports_map[f]) > 0):
|
|
173
|
+
node_type = "entry"
|
|
174
|
+
entry_points.append(f)
|
|
175
|
+
elif any(k in stem_lower for k in UTIL_KEYWORDS):
|
|
176
|
+
node_type = "util"
|
|
177
|
+
utilities.append(f)
|
|
178
|
+
else:
|
|
179
|
+
node_type = "module"
|
|
180
|
+
|
|
181
|
+
nodes.append({
|
|
182
|
+
"file": f,
|
|
183
|
+
"type": node_type,
|
|
184
|
+
"imports": sorted(list(imports_map[f])),
|
|
185
|
+
"importedBy": sorted(list(imported_by_map[f]))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
# Sort edges deterministically
|
|
189
|
+
edges.sort(key=lambda e: (e["from"], e["to"]))
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"nodes": nodes,
|
|
193
|
+
"edges": edges,
|
|
194
|
+
"entryPoints": sorted(entry_points),
|
|
195
|
+
"utilities": sorted(utilities)
|
|
196
|
+
}
|
context_gen/scanner.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context_gen.scanner: Directory walker and .gitignore filtering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, List
|
|
7
|
+
import pathspec
|
|
8
|
+
|
|
9
|
+
DEFAULT_IGNORES: List[str] = [
|
|
10
|
+
"__pycache__",
|
|
11
|
+
"__pycache__/**",
|
|
12
|
+
"*.pyc",
|
|
13
|
+
".git",
|
|
14
|
+
".git/**",
|
|
15
|
+
".venv",
|
|
16
|
+
".venv/**",
|
|
17
|
+
"venv",
|
|
18
|
+
"venv/**",
|
|
19
|
+
"env",
|
|
20
|
+
"env/**",
|
|
21
|
+
"node_modules",
|
|
22
|
+
"node_modules/**",
|
|
23
|
+
".DS_Store",
|
|
24
|
+
"context",
|
|
25
|
+
"context/**",
|
|
26
|
+
".env*",
|
|
27
|
+
"secrets",
|
|
28
|
+
"secrets/**",
|
|
29
|
+
"*.pem",
|
|
30
|
+
"*.key",
|
|
31
|
+
"dist",
|
|
32
|
+
"dist/**",
|
|
33
|
+
"build",
|
|
34
|
+
"build/**",
|
|
35
|
+
"*.egg-info",
|
|
36
|
+
"*.egg-info/**"
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_gitignore(root: Path) -> pathspec.PathSpec:
|
|
41
|
+
"""
|
|
42
|
+
Loads and compiles .gitignore rules along with default ignores.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
root (Path): The project root directory.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
pathspec.PathSpec: Compiled path matcher.
|
|
49
|
+
"""
|
|
50
|
+
patterns: List[str] = list(DEFAULT_IGNORES)
|
|
51
|
+
|
|
52
|
+
gitignore_path = root / ".gitignore"
|
|
53
|
+
if gitignore_path.is_file():
|
|
54
|
+
try:
|
|
55
|
+
with open(gitignore_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
56
|
+
for line in f:
|
|
57
|
+
stripped = line.strip()
|
|
58
|
+
if stripped and not stripped.startswith("#"):
|
|
59
|
+
patterns.append(stripped)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_tree(root: Path, spec: pathspec.PathSpec, max_depth: int = 3) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Builds a visual ASCII directory tree string.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
root (Path): Project root directory.
|
|
72
|
+
spec (pathspec.PathSpec): Ignore pattern matcher.
|
|
73
|
+
max_depth (int): Max level of recursion (1-indexed).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
str: Formatted tree string.
|
|
77
|
+
"""
|
|
78
|
+
lines: List[str] = [f"{root.name}/"]
|
|
79
|
+
|
|
80
|
+
def _build_tree(current_dir: Path, prefix: str = "", depth: int = 1) -> None:
|
|
81
|
+
if depth > max_depth:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
entries = sorted(list(current_dir.iterdir()), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
86
|
+
except PermissionError:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
# Filter out ignored entries
|
|
90
|
+
visible_entries: List[Path] = []
|
|
91
|
+
for entry in entries:
|
|
92
|
+
try:
|
|
93
|
+
rel = entry.relative_to(root).as_posix()
|
|
94
|
+
except ValueError:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
rel_test = f"{rel}/" if entry.is_dir() else rel
|
|
98
|
+
if not spec.match_file(rel_test):
|
|
99
|
+
visible_entries.append(entry)
|
|
100
|
+
|
|
101
|
+
for i, entry in enumerate(visible_entries):
|
|
102
|
+
is_last = (i == len(visible_entries) - 1)
|
|
103
|
+
connector = "└── " if is_last else "├── "
|
|
104
|
+
display_name = f"{entry.name}/" if entry.is_dir() else entry.name
|
|
105
|
+
lines.append(f"{prefix}{connector}{display_name}")
|
|
106
|
+
|
|
107
|
+
if entry.is_dir():
|
|
108
|
+
sub_prefix = prefix + (" " if is_last else "│ ")
|
|
109
|
+
_build_tree(entry, sub_prefix, depth + 1)
|
|
110
|
+
|
|
111
|
+
_build_tree(root, "", 1)
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def scan_project(root_path: str, max_depth: int = 3) -> dict[str, Any]:
|
|
116
|
+
"""
|
|
117
|
+
Scans a directory, filtering against .gitignore, and constructs a file list and tree.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
root_path (str): Path to the target project directory.
|
|
121
|
+
max_depth (int): Depth limit for directory tree rendering.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
dict[str, Any]: Dictionary containing 'files' (list of relative paths) and 'tree' (string).
|
|
125
|
+
"""
|
|
126
|
+
root = Path(root_path).resolve()
|
|
127
|
+
if not root.exists() or not root.is_dir():
|
|
128
|
+
raise ValueError(f"Target path does not exist or is not a directory: {root_path}")
|
|
129
|
+
|
|
130
|
+
spec = load_gitignore(root)
|
|
131
|
+
files: List[str] = []
|
|
132
|
+
|
|
133
|
+
for item in root.rglob("*"):
|
|
134
|
+
if item.is_file():
|
|
135
|
+
try:
|
|
136
|
+
rel = item.relative_to(root).as_posix()
|
|
137
|
+
except ValueError:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if not spec.match_file(rel):
|
|
141
|
+
files.append(rel)
|
|
142
|
+
|
|
143
|
+
files = sorted(files)
|
|
144
|
+
tree_str = generate_tree(root, spec, max_depth)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"files": files,
|
|
148
|
+
"tree": tree_str
|
|
149
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: context-gen-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A modern CLI tool that scans any project, builds an AST-based dependency graph, and generates structured LLM context (agent.md / agent.json).
|
|
5
|
+
Author: yakkshit
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: llm,context,agent,cli,code-analysis,ast,graph,mcp
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: typer[all]>=0.12.0
|
|
21
|
+
Requires-Dist: rich>=13.7.0
|
|
22
|
+
Requires-Dist: pathspec>=0.12.1
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=1.1.0; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# context-gen-cli (Python Edition)
|
|
29
|
+
|
|
30
|
+
> ⚡ Fast, AST-powered CLI tool that scans any project, extracts architecture models, and generates structured LLM context (`agent.md` & `agent.json`).
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
- **100% Accurate Python Dependency Graphs**: Built using Python's native `ast` (Abstract Syntax Tree) module.
|
|
34
|
+
- **Gitignore Respect**: Uses `pathspec` to ignore unwanted directories and secrets.
|
|
35
|
+
- **Dual Output**: Generates both human-readable Markdown (`agent.md`) and MCP-compatible structured JSON (`agent.json`).
|
|
36
|
+
- **Modern CLI**: Powered by `typer` and `rich`.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
```bash
|
|
40
|
+
pip install context-gen-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
```bash
|
|
45
|
+
# Generate agent.md in context/ directory
|
|
46
|
+
context-gen .
|
|
47
|
+
|
|
48
|
+
# Generate agent.json
|
|
49
|
+
context-gen . --format json
|
|
50
|
+
|
|
51
|
+
# Custom output path
|
|
52
|
+
context-gen ./my-project -o custom/path/context.md
|
|
53
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
context_gen/__init__.py,sha256=-aT4LUzkmwYQ-vN2C_e6B6Tss3oIymGDgoKJsoD9pvY,100
|
|
2
|
+
context_gen/cli.py,sha256=hIXCo4IvUQUEmGOIVyV1PMP1yhPynwpQfB7RqEmVDZ0,3766
|
|
3
|
+
context_gen/extractor.py,sha256=HP_I9-PjDqtosXbJ1CrF7-N25WWcGNK644mkI-o51DY,6487
|
|
4
|
+
context_gen/generator.py,sha256=RjdHxlfvU3MiTZ49KrxXYz372R06322sSrozXA2J4Mc,4289
|
|
5
|
+
context_gen/graph.py,sha256=JyjfP8xID4bKs3eNAmauaBnN9Xgba1ZVxbYyMwHDCac,7356
|
|
6
|
+
context_gen/scanner.py,sha256=sC5hqsVZ7svMyYDQelYvqhdUCb07fPekW4XgnBi4tM0,4110
|
|
7
|
+
context_gen_cli-1.0.0.dist-info/METADATA,sha256=SXYh7uzTTehl67Pkm5p6KkgFflBEr-1dqv82YrlA1cg,1963
|
|
8
|
+
context_gen_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
context_gen_cli-1.0.0.dist-info/entry_points.txt,sha256=WLhR993vsDawV47KG6dBlpwfzD05pMr6St1ewlj9z6c,52
|
|
10
|
+
context_gen_cli-1.0.0.dist-info/top_level.txt,sha256=-VtsrQ0fM4J14HSdaF5LhT25F10DJaALb0vK8haFjto,12
|
|
11
|
+
context_gen_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
context_gen
|