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.
@@ -0,0 +1,3 @@
1
+ """context-gen-cli: Graph-based project context generator for LLM agents."""
2
+
3
+ __version__ = "1.0.0"
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()
@@ -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
+ }
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ context-gen = context_gen.cli:app
@@ -0,0 +1 @@
1
+ context_gen