context-gen-cli 1.2.5__tar.gz → 1.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/PKG-INFO +1 -1
  2. context_gen_cli-1.3.3/context_gen/cli.py +242 -0
  3. context_gen_cli-1.3.3/context_gen/distill.py +107 -0
  4. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen_cli.egg-info/PKG-INFO +1 -1
  5. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen_cli.egg-info/SOURCES.txt +1 -0
  6. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/pyproject.toml +1 -1
  7. context_gen_cli-1.2.5/context_gen/cli.py +0 -507
  8. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/README.md +0 -0
  9. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen/__init__.py +0 -0
  10. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen/extractor.py +0 -0
  11. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen/generator.py +0 -0
  12. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen/graph.py +0 -0
  13. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen/mcp/__init__.py +0 -0
  14. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen/mcp/advanced_tools.py +0 -0
  15. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen/scanner.py +0 -0
  16. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen_cli.egg-info/dependency_links.txt +0 -0
  17. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen_cli.egg-info/entry_points.txt +0 -0
  18. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen_cli.egg-info/requires.txt +0 -0
  19. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/context_gen_cli.egg-info/top_level.txt +0 -0
  20. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/setup.cfg +0 -0
  21. {context_gen_cli-1.2.5 → context_gen_cli-1.3.3}/tests/test_advanced_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-gen-cli
3
- Version: 1.2.5
3
+ Version: 1.3.3
4
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
5
  Author: yakkshit
6
6
  License-Expression: MIT
@@ -0,0 +1,242 @@
1
+ """
2
+ context_gen.cli: Typer-based CLI entry point for context-gen-cli (Python Edition).
3
+ """
4
+
5
+ import json
6
+ import time
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
16
+
17
+ from context_gen.scanner import scan_project
18
+ from context_gen.extractor import extract_metadata
19
+ from context_gen.graph import build_dependency_graph
20
+ from context_gen.generator import generate_agent_md, generate_agent_json, generate_agent_yaml
21
+ from context_gen.distill import distill_query
22
+
23
+ app = typer.Typer(
24
+ name="context-gen",
25
+ help="⚡ Generate graph-based project context & distill minimal AI prompts (ctx).",
26
+ add_completion=False,
27
+ no_args_is_help=False,
28
+ )
29
+ console = Console()
30
+
31
+
32
+ class OutputFormat(str, Enum):
33
+ ALL = "all"
34
+ MD = "md"
35
+ JSON = "json"
36
+ YAML = "yaml"
37
+
38
+
39
+ def print_python_banner() -> None:
40
+ banner = r"""
41
+ ___ _ _ __
42
+ / __| |_ \ \/ /
43
+ | (__| _| > <
44
+ \___|\__|/_/\_\
45
+ """
46
+ console.print(f"[bold magenta]{banner}[/bold magenta]")
47
+ console.print(
48
+ Panel.fit(
49
+ "[bold cyan]⚡ context-gen-cli (ctx)[/bold cyan] — [magenta]Senior Engineer Context & Knowledge Graph Engine[/magenta]",
50
+ border_style="cyan",
51
+ )
52
+ )
53
+ console.print("")
54
+
55
+
56
+ def execute_generation_with_progress(
57
+ target_dir: Path,
58
+ format_choice: OutputFormat,
59
+ output_override: Optional[Path] = None,
60
+ max_depth: int = 3,
61
+ ) -> dict:
62
+ with Progress(
63
+ SpinnerColumn("dots", style="cyan"),
64
+ TextColumn("[bold blue]{task.description}"),
65
+ BarColumn(bar_width=35, complete_style="magenta", finished_style="green"),
66
+ TaskProgressColumn(),
67
+ TimeElapsedColumn(),
68
+ console=console,
69
+ ) as progress:
70
+ t1 = progress.add_task("🔍 Scanning workspace files & security guards...", total=100)
71
+ scan_result = scan_project(str(target_dir), max_depth=max_depth)
72
+ progress.update(t1, completed=100)
73
+
74
+ t2 = progress.add_task("📦 Extracting metadata & tech stack signals...", total=100)
75
+ files = scan_result["files"]
76
+ tree = scan_result["tree"]
77
+ metadata = extract_metadata(str(target_dir), files)
78
+ progress.update(t2, completed=100)
79
+
80
+ t3 = progress.add_task("🕸️ Building AST module dependency graph...", total=100)
81
+ graph = build_dependency_graph(files, str(target_dir))
82
+ progress.update(t3, completed=100)
83
+
84
+ t4 = progress.add_task(f"✍️ Generating context artifacts ({format_choice.value})...", total=100)
85
+
86
+ output_dir_name = "context"
87
+ config_file = target_dir / "context" / ".context-gen.json"
88
+ if not config_file.is_file():
89
+ config_file = target_dir / ".context-gen.json"
90
+
91
+ if config_file.is_file():
92
+ try:
93
+ cfg = json.loads(config_file.read_text(encoding="utf-8"))
94
+ output_dir_name = cfg.get("outputDir", "context")
95
+ except Exception:
96
+ pass
97
+
98
+ dest_dir = target_dir / output_dir_name
99
+ dest_dir.mkdir(parents=True, exist_ok=True)
100
+ generated_files: List[Path] = []
101
+
102
+ if output_override:
103
+ out_file = output_override.resolve()
104
+ out_file.parent.mkdir(parents=True, exist_ok=True)
105
+ if format_choice == OutputFormat.JSON:
106
+ out_file.write_text(json.dumps(generate_agent_json(metadata, graph, tree, str(target_dir)), indent=2), encoding="utf-8")
107
+ elif format_choice == OutputFormat.YAML:
108
+ out_file.write_text(generate_agent_yaml(metadata, graph, tree, str(target_dir)), encoding="utf-8")
109
+ else:
110
+ out_file.write_text(generate_agent_md(metadata, graph, tree), encoding="utf-8")
111
+ generated_files.append(out_file)
112
+ else:
113
+ formats_to_build = ["md", "json", "yaml"] if format_choice == OutputFormat.ALL else [format_choice.value]
114
+ if "md" in formats_to_build:
115
+ f = dest_dir / "agent.md"
116
+ f.write_text(generate_agent_md(metadata, graph, tree), encoding="utf-8")
117
+ generated_files.append(f)
118
+ if "json" in formats_to_build:
119
+ f = dest_dir / "agent.json"
120
+ f.write_text(json.dumps(generate_agent_json(metadata, graph, tree, str(target_dir)), indent=2), encoding="utf-8")
121
+ generated_files.append(f)
122
+ if "yaml" in formats_to_build:
123
+ f = dest_dir / "agent.yaml"
124
+ f.write_text(generate_agent_yaml(metadata, graph, tree, str(target_dir)), encoding="utf-8")
125
+ generated_files.append(f)
126
+
127
+ progress.update(t4, completed=100)
128
+
129
+ console.print("")
130
+ return {
131
+ "files": files,
132
+ "graph": graph,
133
+ "metadata": metadata,
134
+ "generated_files": generated_files,
135
+ "output_dir": dest_dir,
136
+ }
137
+
138
+
139
+ def run_watch_loop(target_dir: Path, format_choice: OutputFormat, max_depth: int = 3) -> None:
140
+ console.print(
141
+ Panel(
142
+ f"[bold cyan]👀 Watch Mode Active[/bold cyan]\n"
143
+ f"[dim]Monitoring:[/dim] [green]{target_dir}[/green]\n"
144
+ f"[dim]Auto-updating context on save. Press [bold]Ctrl+C[/bold] to stop.[/dim]",
145
+ border_style="cyan",
146
+ )
147
+ )
148
+
149
+ def get_snapshot():
150
+ snap = {}
151
+ for p in target_dir.rglob("*"):
152
+ if p.is_file():
153
+ rel = p.relative_to(target_dir).as_posix()
154
+ if not any(t in rel for t in ["context/", "node_modules/", ".git/", "__pycache__/", ".venv/", "agent."]):
155
+ try:
156
+ snap[rel] = p.stat().st_mtime
157
+ except Exception:
158
+ pass
159
+ return snap
160
+
161
+ last_snapshot = get_snapshot()
162
+ try:
163
+ while True:
164
+ time.sleep(1.0)
165
+ current = get_snapshot()
166
+ if current != last_snapshot:
167
+ last_snapshot = current
168
+ ts = datetime.now().strftime("%H:%M:%S")
169
+ console.print(f"[dim][{ts}][/dim] [bold green]🔄 Change detected. Syncing context...[/bold green]")
170
+ execute_generation_with_progress(target_dir, format_choice, max_depth=max_depth)
171
+ except KeyboardInterrupt:
172
+ console.print("\n[bold yellow]👋 Stopping watch mode...[/bold yellow]")
173
+
174
+
175
+ @app.callback(invoke_without_command=True)
176
+ def main(
177
+ ctx: typer.Context,
178
+ path: Path = typer.Argument(default=Path("."), help="Target project directory."),
179
+ project: Optional[Path] = typer.Option(None, "--project", "-p", help="Target project directory."),
180
+ default_mode: bool = typer.Option(False, "--default", "-d", help="⚡ Generate all formats + live watch mode."),
181
+ format: OutputFormat = typer.Option(OutputFormat.ALL, "--format", "-f", help="Output format: all, md, json, yaml."),
182
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Custom output path."),
183
+ watch: bool = typer.Option(False, "--watch", "-w", help="Watch directory for changes."),
184
+ max_depth: int = typer.Option(3, "--max-depth", min=1, max=10, help="Max scan depth."),
185
+ ) -> None:
186
+ if ctx.invoked_subcommand is not None:
187
+ return
188
+
189
+ target_dir = (project or path).resolve()
190
+ print_python_banner()
191
+ res = execute_generation_with_progress(target_dir, format, output_override=output, max_depth=max_depth)
192
+
193
+ table = Table(show_header=False, box=None)
194
+ table.add_row("[bold]📂 Scanned Files:[/bold]", f"[cyan]{len(res['files'])}[/cyan]")
195
+ table.add_row("[bold]🕸️ Graph Edges:[/bold]", f"[magenta]{len(res['graph']['edges'])}[/magenta]")
196
+ table.add_row("[bold]⚡ Tech Stack:[/bold]", f"[green]{', '.join(res['metadata']['techStack'])}[/green]")
197
+ table.add_row("[bold]📁 Destination:[/bold]", f"[yellow]{res['output_dir']}[/yellow]")
198
+ table.add_row("[bold]�� Artifacts:[/bold]", f"[bold cyan]{', '.join(f.name for f in res['generated_files'])}[/bold cyan]")
199
+
200
+ console.print(Panel(table, title="[bold green]✅ Context Generated Successfully[/bold green]", border_style="green"))
201
+ console.print(
202
+ Panel(
203
+ "[bold cyan]ctx distill \"add feature\"[/bold cyan] → 🧠 Get minimal Senior Engineer context plan\n"
204
+ "[bold cyan]ctx -d[/bold cyan] → ⚡ All-in-one generate + live watcher\n"
205
+ "[bold cyan]ctx update -f json[/bold cyan] → 🔄 Quick format refresh",
206
+ title="[bold]💡 Quick Commands[/bold]",
207
+ border_style="blue",
208
+ )
209
+ )
210
+
211
+ if default_mode or watch:
212
+ run_watch_loop(target_dir, format, max_depth=max_depth)
213
+
214
+
215
+ @app.command()
216
+ def distill(
217
+ prompt: str = typer.Argument(..., help="Task or feature request to distill context for."),
218
+ budget: int = typer.Option(250, "--budget", "-b", help="Max token budget."),
219
+ project: Path = typer.Option(Path("."), "--project", "-p", help="Target project directory."),
220
+ ) -> None:
221
+ """🧠 Distill minimal high-signal context using Entropy + Knowledge Graph."""
222
+ res = distill_query(str(project.resolve()), prompt, budget=budget)
223
+ if res["status"] == "needs_clarification":
224
+ console.print(Panel(res["output"], title="[bold yellow]⚠️ Clarification Needed[/bold yellow]", border_style="yellow"))
225
+ else:
226
+ console.print(Panel(res["output"], title="[bold cyan]🧠 Senior Engineer Context Plan[/bold cyan]", border_style="cyan"))
227
+
228
+
229
+ @app.command()
230
+ def update(
231
+ path: Path = typer.Argument(default=Path("."), help="Target project directory."),
232
+ format: OutputFormat = typer.Option(OutputFormat.ALL, "--format", "-f", help="Output format."),
233
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Custom output path."),
234
+ ) -> None:
235
+ """🔄 Regenerate and update project context files."""
236
+ target_dir = path.resolve()
237
+ res = execute_generation_with_progress(target_dir, format, output_override=output)
238
+ console.print(f"[bold green]✓ Context updated in {res['output_dir']}![/bold green]")
239
+
240
+
241
+ if __name__ == "__main__":
242
+ app()
@@ -0,0 +1,107 @@
1
+ import os
2
+ import re
3
+ import math
4
+ from pathlib import Path
5
+
6
+ IGNORE_DIRS = {
7
+ "node_modules", ".git", ".next", "dist", "build", ".graviton",
8
+ "target", "__pycache__", ".venv", "venv", ".dart_tool", "bin", "obj",
9
+ "test", "tests", "context", "public",
10
+ }
11
+
12
+ SUPPORTED_EXT = {
13
+ ".ts": "ts", ".tsx": "ts", ".js": "js", ".jsx": "js",
14
+ ".py": "python", ".rs": "rust", ".go": "go", ".dart": "dart", ".cs": "csharp",
15
+ }
16
+
17
+ def get_hardware_budget(file_count: int) -> dict:
18
+ total_cores = os.cpu_count() or 2
19
+ allocated = 2 if (total_cores >= 6 and file_count >= 80) else 1
20
+ return {"total_cores": total_cores, "allocated_threads": allocated}
21
+
22
+ def scan_signatures(root_dir: Path) -> list[dict]:
23
+ sigs = []
24
+ for dirpath, dirnames, filenames in os.walk(root_dir):
25
+ dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS and not d.startswith(".") and not d.endswith(".egg-info")]
26
+ for fname in filenames:
27
+ ext = Path(fname).suffix
28
+ if ext not in SUPPORTED_EXT:
29
+ continue
30
+ full_path = Path(dirpath) / fname
31
+ rel_path = str(full_path.relative_to(root_dir))
32
+ try:
33
+ code = full_path.read_text(encoding="utf-8", errors="ignore")
34
+ except Exception:
35
+ continue
36
+
37
+ imports = re.findall(r"(?:import|from|use|using)\s+['\"]?([\w./:-]+)", code)[:6]
38
+ exports = re.findall(r"(?:export\s+(?:async\s+)?(?:function|const|class)|def|class|pub\s+fn|func)\s+([A-Za-z_]\w*)", code)[:6]
39
+ tokens = max(10, len(f"{rel_path} {' '.join(imports)} {' '.join(exports)}") // 4)
40
+ sigs.append({
41
+ "path": rel_path,
42
+ "lang": SUPPORTED_EXT[ext],
43
+ "imports": list(dict.fromkeys(imports)),
44
+ "exports": list(dict.fromkeys(exports)),
45
+ "tokens": tokens,
46
+ })
47
+ return sigs
48
+
49
+ def distill_query(root_dir: str, prompt: str, budget: int = 250) -> dict:
50
+ root = Path(root_dir).resolve()
51
+ sigs = scan_signatures(root)
52
+ hw = get_hardware_budget(len(sigs))
53
+
54
+ if not sigs:
55
+ return {"status": "needs_clarification", "output": "No source files found. Which directory contains your code?"}
56
+
57
+ words = [w for w in re.split(r"\W+", prompt.lower()) if len(w) > 2]
58
+ scored = []
59
+ for s in sigs:
60
+ haystack = f"{s['path']} {' '.join(s['imports'])} {' '.join(s['exports'])}".lower()
61
+ hits = sum(1 for w in words if w in haystack)
62
+ scored.append((s, 0.1 + hits * 0.45))
63
+
64
+ max_s = max(sc for _, sc in scored)
65
+ exps = [math.exp((sc - max_s) / 0.35) for _, sc in scored]
66
+ total_exp = sum(exps)
67
+ probs = [e / total_exp for e in exps]
68
+ initial_h = -sum(p * math.log2(p) for p in probs if p > 1e-6)
69
+
70
+ ranked = sorted(
71
+ [(s, p, (p * initial_h * 1.6), (p * initial_h * 1.6) / s["tokens"]) for (s, _), p in zip(scored, probs)],
72
+ key=lambda x: x[3],
73
+ reverse=True,
74
+ )
75
+
76
+ entropy = initial_h
77
+ picked = []
78
+ used_tokens = 0
79
+ for sig, prob, delta_h, _ in ranked:
80
+ if used_tokens + sig["tokens"] > budget or entropy < 1.5:
81
+ break
82
+ if prob < 0.05 and picked:
83
+ break
84
+ picked.append(sig)
85
+ used_tokens += sig["tokens"]
86
+ entropy = max(0.35, entropy - delta_h)
87
+
88
+ if entropy >= 1.5 or (len(words) <= 3 and ranked[0][1] < 0.22):
89
+ sample = ranked[0][0]["path"] if ranked else "src/main.py"
90
+ output = (
91
+ f"CLARIFICATION NEEDED (Before Writing Code):\n"
92
+ f"1. **Implementation Pattern:** Should I extend an existing module? (e.g., *Follow `{sample}`* or *Create a standalone file*)\n"
93
+ f"2. **Side Effects:** Should this persist state/files or run read-only?"
94
+ )
95
+ return {"status": "needs_clarification", "output": output}
96
+
97
+ refs = "\n".join(
98
+ f" • {p['path']} [{p['lang']}] — exports: [{', '.join(p['exports'][:4])}], imports: [{', '.join(p['imports'][:4])}]"
99
+ for p in picked
100
+ )
101
+ output = (
102
+ f"### Senior Engineer Context Plan ({used_tokens} tokens | CPU: {hw['allocated_threads']} core)\n"
103
+ f"- **Task:** {prompt}\n"
104
+ f"- **Primary Files to Reference:**\n{refs}\n"
105
+ f"- **Architecture Guardrail:** Match existing imports, function signatures, and module boundaries."
106
+ )
107
+ return {"status": "ready", "output": output}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-gen-cli
3
- Version: 1.2.5
3
+ Version: 1.3.3
4
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
5
  Author: yakkshit
6
6
  License-Expression: MIT
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  context_gen/__init__.py
4
4
  context_gen/cli.py
5
+ context_gen/distill.py
5
6
  context_gen/extractor.py
6
7
  context_gen/generator.py
7
8
  context_gen/graph.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "context-gen-cli"
7
- version = "1.2.5"
7
+ version = "1.3.3"
8
8
  description = "A modern CLI tool that scans any project, builds an AST-based dependency graph, and generates structured LLM context (agent.md / agent.json)."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "yakkshit" }]
@@ -1,507 +0,0 @@
1
- """
2
- context_gen.cli: Typer-based CLI entry point for context-gen-cli (Python Edition).
3
- """
4
-
5
- import json
6
- import time
7
- from datetime import datetime
8
- from enum import Enum
9
- from pathlib import Path
10
- from typing import List, Optional
11
- import typer
12
- from rich.console import Console
13
- from rich.panel import Panel
14
- from rich.table import Table
15
- from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
16
- from rich.text import Text
17
-
18
- from context_gen.scanner import scan_project
19
- from context_gen.extractor import extract_metadata
20
- from context_gen.graph import build_dependency_graph
21
- from context_gen.generator import generate_agent_md, generate_agent_json, generate_agent_yaml
22
-
23
- app = typer.Typer(
24
- name="context-gen",
25
- help="⚡ Generate graph-based project context (agent.md, agent.json, agent.yaml) for LLMs.",
26
- add_completion=False,
27
- no_args_is_help=False
28
- )
29
- console = Console()
30
-
31
-
32
- class OutputFormat(str, Enum):
33
- """Supported output formats."""
34
- ALL = "all"
35
- MD = "md"
36
- JSON = "json"
37
- YAML = "yaml"
38
-
39
-
40
- def execute_generation(
41
- target_dir: Path,
42
- format_choice: OutputFormat,
43
- output_override: Optional[Path] = None,
44
- max_depth: int = 3,
45
- verbose: bool = True
46
- ) -> dict:
47
- """Scans and generates the requested context format files."""
48
- scan_result = scan_project(str(target_dir), max_depth=max_depth)
49
- files = scan_result["files"]
50
- tree = scan_result["tree"]
51
-
52
- metadata = extract_metadata(str(target_dir), files)
53
- graph = build_dependency_graph(files, str(target_dir))
54
-
55
- # Read output directory from context/.context-gen.json or legacy .context-gen.json
56
- output_dir_name = "context"
57
- config_file = target_dir / "context" / ".context-gen.json"
58
- if not config_file.is_file():
59
- config_file = target_dir / ".context-gen.json"
60
-
61
- if config_file.is_file():
62
- try:
63
- with open(config_file, "r", encoding="utf-8") as f:
64
- cfg = json.load(f)
65
- output_dir_name = cfg.get("outputDir", "context")
66
- except Exception:
67
- pass
68
-
69
- dest_dir = target_dir / output_dir_name
70
- dest_dir.mkdir(parents=True, exist_ok=True)
71
-
72
- generated_files: List[Path] = []
73
-
74
- if output_override:
75
- out_file = output_override.resolve()
76
- out_file.parent.mkdir(parents=True, exist_ok=True)
77
- if format_choice == OutputFormat.JSON:
78
- json_data = generate_agent_json(metadata, graph, tree, str(target_dir))
79
- out_file.write_text(json.dumps(json_data, indent=2), encoding="utf-8")
80
- elif format_choice == OutputFormat.YAML:
81
- yaml_text = generate_agent_yaml(metadata, graph, tree, str(target_dir))
82
- out_file.write_text(yaml_text, encoding="utf-8")
83
- else:
84
- md_text = generate_agent_md(metadata, graph, tree)
85
- out_file.write_text(md_text, encoding="utf-8")
86
- generated_files.append(out_file)
87
- else:
88
- # Determine formats to generate
89
- formats_to_build = ["md", "json", "yaml"] if format_choice == OutputFormat.ALL else [format_choice.value]
90
-
91
- if "md" in formats_to_build:
92
- md_file = dest_dir / "agent.md"
93
- md_text = generate_agent_md(metadata, graph, tree)
94
- md_file.write_text(md_text, encoding="utf-8")
95
- generated_files.append(md_file)
96
-
97
- if "json" in formats_to_build:
98
- json_file = dest_dir / "agent.json"
99
- json_data = generate_agent_json(metadata, graph, tree, str(target_dir))
100
- json_file.write_text(json.dumps(json_data, indent=2), encoding="utf-8")
101
- generated_files.append(json_file)
102
-
103
- if "yaml" in formats_to_build:
104
- yaml_file = dest_dir / "agent.yaml"
105
- yaml_text = generate_agent_yaml(metadata, graph, tree, str(target_dir))
106
- yaml_file.write_text(yaml_text, encoding="utf-8")
107
- generated_files.append(yaml_file)
108
-
109
- return {
110
- "files": files,
111
- "graph": graph,
112
- "metadata": metadata,
113
- "generated_files": generated_files,
114
- "output_dir": dest_dir
115
- }
116
-
117
-
118
- def run_watch_loop(target_dir: Path, format_choice: OutputFormat, max_depth: int = 3) -> None:
119
- """Runs a live background file watch loop that regenerates context on changes."""
120
- console.print(
121
- Panel(
122
- f"[bold cyan]👀 Watch Mode Active[/bold cyan]\n"
123
- f"[dim]Monitoring workspace:[/dim] [green]{target_dir}[/green]\n"
124
- f"[dim]Auto-updating context files on save. Press [bold]Ctrl+C[/bold] to exit.[/dim]",
125
- border_style="cyan"
126
- )
127
- )
128
-
129
- last_snapshot = {}
130
-
131
- def get_snapshot():
132
- snap = {}
133
- for p in target_dir.rglob("*"):
134
- if p.is_file():
135
- rel = p.relative_to(target_dir).as_posix()
136
- if not any(token in rel for token in ["context/", "node_modules/", ".git/", "__pycache__/", ".venv/", "agent."]):
137
- try:
138
- snap[rel] = p.stat().st_mtime
139
- except Exception:
140
- pass
141
- return snap
142
-
143
- last_snapshot = get_snapshot()
144
-
145
- try:
146
- while True:
147
- time.sleep(1.0)
148
- current_snapshot = get_snapshot()
149
-
150
- changed = False
151
- for f, mtime in current_snapshot.items():
152
- if f not in last_snapshot or last_snapshot[f] != mtime:
153
- changed = True
154
- break
155
-
156
- if not changed and set(last_snapshot.keys()) != set(current_snapshot.keys()):
157
- changed = True
158
-
159
- if changed:
160
- last_snapshot = current_snapshot
161
- time_str = datetime.now().strftime("%H:%M:%S")
162
- console.print(f"[dim][{time_str}][/dim] [bold green]🔄 Project changed. Regenerating context files...[/bold green]")
163
- execute_generation(target_dir, format_choice, max_depth=max_depth, verbose=False)
164
- console.print(f"[dim][{time_str}][/dim] [bold cyan]✓ Context files updated & synced in context/![/bold cyan]")
165
- except KeyboardInterrupt:
166
- console.print("\n[bold yellow]👋 Stopping watch mode...[/bold yellow]")
167
-
168
-
169
- @app.command()
170
- def main(
171
- path: Path = typer.Argument(
172
- default=Path("."),
173
- help="Target project directory to analyze.",
174
- file_okay=False,
175
- dir_okay=True,
176
- readable=True,
177
- resolve_path=True,
178
- ),
179
- project: Optional[Path] = typer.Option(
180
- None,
181
- "--project",
182
- "-p",
183
- help="Target project directory to analyze.",
184
- file_okay=False,
185
- dir_okay=True,
186
- readable=True,
187
- resolve_path=True,
188
- ),
189
- default_mode: bool = typer.Option(
190
- False,
191
- "--default",
192
- "-d",
193
- help="⚡ Zero-config default mode: generate all formats in context/ and run live auto-sync watcher.",
194
- ),
195
- format: OutputFormat = typer.Option(
196
- OutputFormat.ALL,
197
- "--format",
198
- "-f",
199
- help="Output format: 'all', 'md', 'json', or 'yaml'.",
200
- case_sensitive=False,
201
- ),
202
- mcp: bool = typer.Option(
203
- False,
204
- "--mcp",
205
- "-m",
206
- help="Start MCP server mode.",
207
- ),
208
- interactive: bool = typer.Option(
209
- False,
210
- "--interactive",
211
- "-i",
212
- help="Run in interactive mode.",
213
- ),
214
- output: Optional[Path] = typer.Option(
215
- None,
216
- "--output",
217
- "-o",
218
- help="Custom output file path (e.g. context/agent.md or custom.json).",
219
- ),
220
- watch: bool = typer.Option(
221
- False,
222
- "--watch",
223
- "-w",
224
- help="Watch directory and auto-regenerate context on file changes.",
225
- ),
226
- max_depth: int = typer.Option(
227
- 3,
228
- "--max-depth",
229
- help="Maximum directory depth to scan.",
230
- min=1,
231
- max=10,
232
- ),
233
- ) -> None:
234
- """
235
- Scan a project, build an AST-powered dependency graph, and generate LLM context.
236
- """
237
- target_dir = (project or path).resolve()
238
-
239
- # If -d / --default is supplied, generate all formats and keep watch running
240
- def print_python_banner():
241
- """Renders a stylish ASCII gradient banner in Python CLI."""
242
- banner = """
243
- ___ _ _ __
244
- / __| |_ \ \/ /
245
- | (__| _| > <
246
- \___|\__|/_/\_\
247
- """
248
- console.print(f"[bold magenta]{banner}[/bold magenta]")
249
- console.print(Panel.fit("[bold cyan]⚡ context-gen-cli (ctx)[/bold cyan] — [magenta]AST AI Project Context Generator[/magenta]", border_style="cyan"))
250
- console.print("")
251
-
252
-
253
- def execute_generation_with_progress(
254
- target_dir: Path,
255
- format_choice: OutputFormat,
256
- output_override: Optional[Path] = None,
257
- max_depth: int = 3
258
- ) -> dict:
259
- """Executes the pipeline with rich progress bar animation."""
260
- with Progress(
261
- SpinnerColumn("dots", style="cyan"),
262
- TextColumn("[bold blue]{task.description}"),
263
- BarColumn(bar_width=35, complete_style="magenta", finished_style="green"),
264
- TaskProgressColumn(),
265
- TimeElapsedColumn(),
266
- console=console
267
- ) as progress:
268
- t1 = progress.add_task("🔍 Scanning workspace files & security ignores...", total=100)
269
- scan_result = scan_project(str(target_dir), max_depth=max_depth)
270
- time.sleep(0.08)
271
- progress.update(t1, completed=100)
272
-
273
- t2 = progress.add_task("📦 Extracting metadata & tech stack signals...", total=100)
274
- files = scan_result["files"]
275
- tree = scan_result["tree"]
276
- metadata = extract_metadata(str(target_dir), files)
277
- time.sleep(0.08)
278
- progress.update(t2, completed=100)
279
-
280
- t3 = progress.add_task("🕸️ Parsing AST module dependency graph...", total=100)
281
- graph = build_dependency_graph(files, str(target_dir))
282
- time.sleep(0.08)
283
- progress.update(t3, completed=100)
284
-
285
- t4 = progress.add_task(f"✍️ Generating context artifacts ({format_choice.value})...", total=100)
286
-
287
- output_dir_name = "context"
288
- config_file = target_dir / "context" / ".context-gen.json"
289
- if not config_file.is_file():
290
- config_file = target_dir / ".context-gen.json"
291
-
292
- if config_file.is_file():
293
- try:
294
- with open(config_file, "r", encoding="utf-8") as f:
295
- cfg = json.load(f)
296
- output_dir_name = cfg.get("outputDir", "context")
297
- except Exception:
298
- pass
299
-
300
- dest_dir = target_dir / output_dir_name
301
- dest_dir.mkdir(parents=True, exist_ok=True)
302
- generated_files: List[Path] = []
303
-
304
- if output_override:
305
- out_file = output_override.resolve()
306
- out_file.parent.mkdir(parents=True, exist_ok=True)
307
- if format_choice == OutputFormat.JSON:
308
- json_data = generate_agent_json(metadata, graph, tree, str(target_dir))
309
- out_file.write_text(json.dumps(json_data, indent=2), encoding="utf-8")
310
- elif format_choice == OutputFormat.YAML:
311
- yaml_text = generate_agent_yaml(metadata, graph, tree, str(target_dir))
312
- out_file.write_text(yaml_text, encoding="utf-8")
313
- else:
314
- md_text = generate_agent_md(metadata, graph, tree)
315
- out_file.write_text(md_text, encoding="utf-8")
316
- generated_files.append(out_file)
317
- else:
318
- formats_to_build = ["md", "json", "yaml"] if format_choice == OutputFormat.ALL else [format_choice.value]
319
-
320
- if "md" in formats_to_build:
321
- md_file = dest_dir / "agent.md"
322
- md_text = generate_agent_md(metadata, graph, tree)
323
- md_file.write_text(md_text, encoding="utf-8")
324
- generated_files.append(md_file)
325
-
326
- if "json" in formats_to_build:
327
- json_file = dest_dir / "agent.json"
328
- json_data = generate_agent_json(metadata, graph, tree, str(target_dir))
329
- json_file.write_text(json.dumps(json_data, indent=2), encoding="utf-8")
330
- generated_files.append(json_file)
331
-
332
- if "yaml" in formats_to_build:
333
- yaml_file = dest_dir / "agent.yaml"
334
- yaml_text = generate_agent_yaml(metadata, graph, tree, str(target_dir))
335
- yaml_file.write_text(yaml_text, encoding="utf-8")
336
- generated_files.append(yaml_file)
337
-
338
- time.sleep(0.08)
339
- progress.update(t4, completed=100)
340
-
341
- console.print("")
342
- return {
343
- "files": files,
344
- "graph": graph,
345
- "metadata": metadata,
346
- "generated_files": generated_files,
347
- "output_dir": dest_dir
348
- }
349
-
350
-
351
- @app.command()
352
- def main(
353
- path: Path = typer.Argument(
354
- default=Path("."),
355
- help="Target project directory to analyze.",
356
- file_okay=False,
357
- dir_okay=True,
358
- readable=True,
359
- resolve_path=True,
360
- ),
361
- project: Optional[Path] = typer.Option(
362
- None,
363
- "--project",
364
- "-p",
365
- help="Target project directory to analyze.",
366
- file_okay=False,
367
- dir_okay=True,
368
- readable=True,
369
- resolve_path=True,
370
- ),
371
- default_mode: bool = typer.Option(
372
- False,
373
- "--default",
374
- "-d",
375
- help="⚡ Zero-config default mode: generate all formats in context/ and run live auto-sync watcher.",
376
- ),
377
- format: OutputFormat = typer.Option(
378
- OutputFormat.ALL,
379
- "--format",
380
- "-f",
381
- help="Output format: 'all', 'md', 'json', or 'yaml'.",
382
- case_sensitive=False,
383
- ),
384
- mcp: bool = typer.Option(
385
- False,
386
- "--mcp",
387
- "-m",
388
- help="Start MCP server mode.",
389
- ),
390
- interactive: bool = typer.Option(
391
- False,
392
- "--interactive",
393
- "-i",
394
- help="Run in interactive mode.",
395
- ),
396
- output: Optional[Path] = typer.Option(
397
- None,
398
- "--output",
399
- "-o",
400
- help="Custom output file path (e.g. context/agent.md or custom.json).",
401
- ),
402
- watch: bool = typer.Option(
403
- False,
404
- "--watch",
405
- "-w",
406
- help="Watch directory and auto-regenerate context on file changes.",
407
- ),
408
- max_depth: int = typer.Option(
409
- 3,
410
- "--max-depth",
411
- help="Maximum directory depth to scan.",
412
- min=1,
413
- max=10,
414
- ),
415
- ) -> None:
416
- """
417
- Scan a project, build an AST-powered dependency graph, and generate LLM context.
418
- """
419
- target_dir = (project or path).resolve()
420
- print_python_banner()
421
-
422
- # If -d / --default is supplied, generate all formats and keep watch running
423
- if default_mode:
424
- console.print(
425
- Panel.fit(
426
- f"[bold cyan]⚡ context-gen-cli (Python All-In-One Mode -d)[/bold cyan]\n"
427
- f"[dim]Target:[/dim] [green]{target_dir}[/green]\n"
428
- f"[dim]Mode:[/dim] [yellow]Auto-Generate All Formats + Live Background Watcher[/yellow]",
429
- border_style="cyan"
430
- )
431
- )
432
- res = execute_generation_with_progress(target_dir, OutputFormat.ALL, max_depth=max_depth)
433
- table = Table(show_header=False, box=None)
434
- table.add_row("[bold]Scanned Files:[/bold]", f"[cyan]{len(res['files'])}[/cyan]")
435
- table.add_row("[bold]AST Graph Edges:[/bold]", f"[cyan]{len(res['graph']['edges'])}[/cyan]")
436
- table.add_row("[bold]Tech Stack:[/bold]", f"[green]{', '.join(res['metadata']['techStack'])}[/green]")
437
- table.add_row("[bold]Generated Files:[/bold]", f"[yellow]{', '.join(f.name for f in res['generated_files'])}[/yellow]")
438
- console.print(Panel(table, title="[bold green]✅ Context Generated in context/[/bold green]", border_style="green"))
439
- run_watch_loop(target_dir, OutputFormat.ALL, max_depth=max_depth)
440
- return
441
-
442
- res = execute_generation_with_progress(target_dir, format, output_override=output, max_depth=max_depth)
443
-
444
- # Display completion summary
445
- table = Table(show_header=False, box=None)
446
- table.add_row("[bold]Scanned Files:[/bold]", f"[cyan]{len(res['files'])}[/cyan]")
447
- table.add_row("[bold]AST Graph Edges:[/bold]", f"[cyan]{len(res['graph']['edges'])}[/cyan]")
448
- table.add_row("[bold]Tech Stack:[/bold]", f"[green]{', '.join(res['metadata']['techStack'])}[/green]")
449
- table.add_row("[bold]Output Destination:[/bold]", f"[yellow]{res['output_dir']}[/yellow]")
450
- table.add_row("[bold]Generated Files:[/bold]", f"[green]{', '.join(f.name for f in res['generated_files'])}[/green]")
451
-
452
- console.print(
453
- Panel(
454
- table,
455
- title="[bold green]✅ Context Generated Successfully[/bold green]",
456
- border_style="green"
457
- )
458
- )
459
-
460
- tips = (
461
- "[bold cyan]ctx -w[/bold cyan] (or [cyan]ctx --watch[/cyan]) → Keep context fresh (auto-syncs on save)\n"
462
- "[bold cyan]ctx -d[/bold cyan] (or [cyan]ctx --default[/cyan]) → ⚡ All-in-one (generate + live watcher)\n"
463
- "[bold cyan]ctx update -f json[/bold cyan] → Quick format change (md, json, yaml, all)\n"
464
- "[bold cyan]ctx -o <dir>[/bold cyan] → Custom output folder\n"
465
- f"[dim]Config saved in:[/dim] [yellow]{res['output_dir'] / '.context-gen.json'}[/yellow]"
466
- )
467
- console.print(Panel(tips, title="[bold]💡 Pro-Tips & Next Steps[/bold]", border_style="blue"))
468
-
469
- if watch:
470
- run_watch_loop(target_dir, format, max_depth=max_depth)
471
-
472
-
473
- @app.command()
474
- def update(
475
- path: Path = typer.Argument(
476
- default=Path("."),
477
- help="Target project directory to analyze.",
478
- file_okay=False,
479
- dir_okay=True,
480
- readable=True,
481
- resolve_path=True,
482
- ),
483
- format: OutputFormat = typer.Option(
484
- OutputFormat.ALL,
485
- "--format",
486
- "-f",
487
- help="Output format: 'all', 'md', 'json', or 'yaml'.",
488
- case_sensitive=False,
489
- ),
490
- output: Optional[Path] = typer.Option(
491
- None,
492
- "--output",
493
- "-o",
494
- help="Custom output file path.",
495
- ),
496
- ) -> None:
497
- """
498
- Regenerate and update project context files.
499
- """
500
- target_dir = path.resolve()
501
- with console.status("[bold green]Updating project context files...[/bold green]"):
502
- res = execute_generation(target_dir, format, output_override=output)
503
- console.print(f"[bold green]✓ Context updated in {res['output_dir']}![/bold green]")
504
-
505
-
506
- if __name__ == "__main__":
507
- app()