ctxl-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctxl/__init__.py +3 -0
- ctxl/checkpoint.py +130 -0
- ctxl/cli.py +272 -0
- ctxl/init.py +92 -0
- ctxl/mapper.py +439 -0
- ctxl_cli-0.1.0.dist-info/METADATA +175 -0
- ctxl_cli-0.1.0.dist-info/RECORD +11 -0
- ctxl_cli-0.1.0.dist-info/WHEEL +5 -0
- ctxl_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ctxl_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ctxl_cli-0.1.0.dist-info/top_level.txt +1 -0
ctxl/__init__.py
ADDED
ctxl/checkpoint.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctx checkpoint — Session state manager.
|
|
3
|
+
|
|
4
|
+
Tracks what the AI agent has learned during a session (files edited,
|
|
5
|
+
schemas discovered, errors resolved) and saves a compressed checkpoint.
|
|
6
|
+
This allows you to safely run /clear in Copilot Chat without losing context.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CHECKPOINT_DIR = ".ctx_checkpoints"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ensure_dir(project_root: str) -> Path:
|
|
19
|
+
"""Ensure the checkpoint directory exists."""
|
|
20
|
+
path = Path(project_root).resolve() / CHECKPOINT_DIR
|
|
21
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_checkpoint(
|
|
26
|
+
project_root: str,
|
|
27
|
+
task: str,
|
|
28
|
+
completed_steps: List[str],
|
|
29
|
+
current_state: str,
|
|
30
|
+
next_steps: List[str],
|
|
31
|
+
files_modified: Optional[List[str]] = None,
|
|
32
|
+
key_discoveries: Optional[List[str]] = None,
|
|
33
|
+
errors_resolved: Optional[List[str]] = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Create a checkpoint file capturing the current session state.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
project_root: Path to the project root directory.
|
|
40
|
+
task: The high-level task description.
|
|
41
|
+
completed_steps: List of steps already completed.
|
|
42
|
+
current_state: Brief description of where things stand right now.
|
|
43
|
+
next_steps: List of planned next steps.
|
|
44
|
+
files_modified: List of files that were modified.
|
|
45
|
+
key_discoveries: Key things learned (schemas, configs, etc.).
|
|
46
|
+
errors_resolved: Errors that were encountered and resolved.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Path to the created checkpoint file.
|
|
50
|
+
"""
|
|
51
|
+
checkpoint_dir = _ensure_dir(project_root)
|
|
52
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
53
|
+
filename = f"checkpoint_{timestamp}.md"
|
|
54
|
+
filepath = checkpoint_dir / filename
|
|
55
|
+
|
|
56
|
+
lines = []
|
|
57
|
+
lines.append(f"# Session Checkpoint")
|
|
58
|
+
lines.append(f"_Saved at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}_\n")
|
|
59
|
+
|
|
60
|
+
lines.append(f"## Task")
|
|
61
|
+
lines.append(f"{task}\n")
|
|
62
|
+
|
|
63
|
+
lines.append(f"## Current State")
|
|
64
|
+
lines.append(f"{current_state}\n")
|
|
65
|
+
|
|
66
|
+
if completed_steps:
|
|
67
|
+
lines.append("## Completed Steps")
|
|
68
|
+
for step in completed_steps:
|
|
69
|
+
lines.append(f"- [x] {step}")
|
|
70
|
+
lines.append("")
|
|
71
|
+
|
|
72
|
+
if next_steps:
|
|
73
|
+
lines.append("## Next Steps")
|
|
74
|
+
for step in next_steps:
|
|
75
|
+
lines.append(f"- [ ] {step}")
|
|
76
|
+
lines.append("")
|
|
77
|
+
|
|
78
|
+
if files_modified:
|
|
79
|
+
lines.append("## Files Modified")
|
|
80
|
+
for f in files_modified:
|
|
81
|
+
lines.append(f"- `{f}`")
|
|
82
|
+
lines.append("")
|
|
83
|
+
|
|
84
|
+
if key_discoveries:
|
|
85
|
+
lines.append("## Key Discoveries")
|
|
86
|
+
for d in key_discoveries:
|
|
87
|
+
lines.append(f"- {d}")
|
|
88
|
+
lines.append("")
|
|
89
|
+
|
|
90
|
+
if errors_resolved:
|
|
91
|
+
lines.append("## Errors Resolved")
|
|
92
|
+
for e in errors_resolved:
|
|
93
|
+
lines.append(f"- {e}")
|
|
94
|
+
lines.append("")
|
|
95
|
+
|
|
96
|
+
lines.append("---")
|
|
97
|
+
lines.append("_Paste this into your active file or Copilot Chat after running `/clear`._")
|
|
98
|
+
|
|
99
|
+
content = "\n".join(lines)
|
|
100
|
+
filepath.write_text(content, encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
return str(filepath)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def list_checkpoints(project_root: str) -> List[Dict]:
|
|
106
|
+
"""List all checkpoints in the project, newest first."""
|
|
107
|
+
checkpoint_dir = Path(project_root).resolve() / CHECKPOINT_DIR
|
|
108
|
+
if not checkpoint_dir.exists():
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
checkpoints = []
|
|
112
|
+
for f in sorted(checkpoint_dir.glob("checkpoint_*.md"), reverse=True):
|
|
113
|
+
stat = f.stat()
|
|
114
|
+
checkpoints.append({
|
|
115
|
+
"filename": f.name,
|
|
116
|
+
"path": str(f),
|
|
117
|
+
"created": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
|
118
|
+
"size_bytes": stat.st_size,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return checkpoints
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_latest_checkpoint(project_root: str) -> Optional[str]:
|
|
125
|
+
"""Read the content of the most recent checkpoint."""
|
|
126
|
+
checkpoints = list_checkpoints(project_root)
|
|
127
|
+
if not checkpoints:
|
|
128
|
+
return None
|
|
129
|
+
latest_path = checkpoints[0]["path"]
|
|
130
|
+
return Path(latest_path).read_text(encoding="utf-8")
|
ctxl/cli.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctxl — Context Engineering CLI for AI agents.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
ctxl map — Generate a compressed codebase skeleton (zero AI, pure parsing)
|
|
6
|
+
ctxl init — Generate .github/copilot-instructions.md for focused AI context
|
|
7
|
+
ctxl checkpoint — Save/view session state for safe /clear workflows
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich import print as rprint
|
|
15
|
+
|
|
16
|
+
from ctxl import __version__
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
@click.version_option(version=__version__, prog_name="ctxl")
|
|
23
|
+
def main():
|
|
24
|
+
"""ctxl — Context Engineering CLI for AI agents.
|
|
25
|
+
|
|
26
|
+
Reduce token waste and prevent hallucination by giving AI agents
|
|
27
|
+
precisely the context they need. Zero AI models used — pure parsing.
|
|
28
|
+
"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ─── ctxl map ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
@main.command()
|
|
35
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
36
|
+
@click.option("-o", "--output", default=None, help="Write output to a file instead of stdout.")
|
|
37
|
+
@click.option("-e", "--ext", multiple=True, help="Filter by file extension (e.g., -e .py -e .js). Defaults to all supported.")
|
|
38
|
+
@click.option("--clipboard", is_flag=True, help="Copy the output to clipboard.")
|
|
39
|
+
def map(path, output, ext, clipboard):
|
|
40
|
+
"""Generate a compressed codebase skeleton.
|
|
41
|
+
|
|
42
|
+
Parses source files using Tree-sitter and extracts only structural
|
|
43
|
+
information — function signatures, class definitions, and imports.
|
|
44
|
+
No AI models, no API calls, no tokens burned.
|
|
45
|
+
|
|
46
|
+
\b
|
|
47
|
+
Examples:
|
|
48
|
+
ctxl map # Map current directory
|
|
49
|
+
ctxl map ./src # Map a specific directory
|
|
50
|
+
ctxl map -e .py -e .java # Only Python and Java files
|
|
51
|
+
ctxl map -o codebase.md # Save to file
|
|
52
|
+
ctxl map --clipboard # Copy to clipboard for pasting into AI chat
|
|
53
|
+
"""
|
|
54
|
+
from ctxl.mapper import map_directory, map_file, format_map_output
|
|
55
|
+
from pathlib import Path as P
|
|
56
|
+
|
|
57
|
+
target = P(path).resolve()
|
|
58
|
+
|
|
59
|
+
with console.status("[bold cyan]Parsing codebase with Tree-sitter...", spinner="dots"):
|
|
60
|
+
if target.is_file():
|
|
61
|
+
skeleton = map_file(str(target))
|
|
62
|
+
if skeleton is None:
|
|
63
|
+
console.print(f"[red]✗[/red] Unsupported file type: {target.suffix}")
|
|
64
|
+
raise SystemExit(1)
|
|
65
|
+
result = f"## {target.name}\n```\n{skeleton}\n```"
|
|
66
|
+
file_count = 1
|
|
67
|
+
else:
|
|
68
|
+
extensions = list(ext) if ext else None
|
|
69
|
+
skeletons = map_directory(str(target), extensions)
|
|
70
|
+
if not skeletons:
|
|
71
|
+
console.print("[yellow]⚠[/yellow] No supported source files found.")
|
|
72
|
+
raise SystemExit(0)
|
|
73
|
+
result = format_map_output(skeletons)
|
|
74
|
+
file_count = len(skeletons)
|
|
75
|
+
|
|
76
|
+
if output:
|
|
77
|
+
out_path = P(output)
|
|
78
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
out_path.write_text(result, encoding="utf-8")
|
|
80
|
+
console.print(f"[green]✓[/green] Codebase map written to [bold]{output}[/bold] ({file_count} files)")
|
|
81
|
+
elif clipboard:
|
|
82
|
+
try:
|
|
83
|
+
import pyperclip
|
|
84
|
+
pyperclip.copy(result)
|
|
85
|
+
console.print(f"[green]✓[/green] Codebase map copied to clipboard ({file_count} files)")
|
|
86
|
+
except ImportError:
|
|
87
|
+
console.print("[yellow]⚠[/yellow] Install `pyperclip` for clipboard support. Printing to stdout instead.\n")
|
|
88
|
+
console.print(result)
|
|
89
|
+
else:
|
|
90
|
+
console.print(result)
|
|
91
|
+
|
|
92
|
+
# Show token savings estimate
|
|
93
|
+
_show_savings(path, result, file_count)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _show_savings(path: str, result: str, file_count: int):
|
|
97
|
+
"""Show estimated token savings."""
|
|
98
|
+
from pathlib import Path as P
|
|
99
|
+
import os
|
|
100
|
+
|
|
101
|
+
target = P(path).resolve()
|
|
102
|
+
raw_chars = 0
|
|
103
|
+
|
|
104
|
+
if target.is_file():
|
|
105
|
+
try:
|
|
106
|
+
raw_chars = target.stat().st_size
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
else:
|
|
110
|
+
from ctxl.mapper import LANGUAGE_MAP, IGNORE_PATTERNS, IGNORE_EXTENSIONS
|
|
111
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
112
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORE_PATTERNS and not d.startswith(".")]
|
|
113
|
+
for fname in filenames:
|
|
114
|
+
fpath = P(dirpath) / fname
|
|
115
|
+
if fpath.suffix.lower() in LANGUAGE_MAP and fpath.suffix.lower() not in IGNORE_EXTENSIONS:
|
|
116
|
+
try:
|
|
117
|
+
raw_chars += fpath.stat().st_size
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
if raw_chars > 0:
|
|
122
|
+
compressed_chars = len(result)
|
|
123
|
+
# Rough token estimate: ~4 chars per token
|
|
124
|
+
raw_tokens = raw_chars // 4
|
|
125
|
+
compressed_tokens = compressed_chars // 4
|
|
126
|
+
ratio = round((1 - compressed_tokens / raw_tokens) * 100) if raw_tokens > 0 else 0
|
|
127
|
+
console.print(
|
|
128
|
+
Panel(
|
|
129
|
+
f"[dim]Raw source:[/dim] ~{raw_tokens:,} tokens\n"
|
|
130
|
+
f"[dim]Skeleton:[/dim] ~{compressed_tokens:,} tokens\n"
|
|
131
|
+
f"[bold green]Savings:[/bold green] ~{ratio}% token reduction",
|
|
132
|
+
title="[bold]Token Savings Estimate[/bold]",
|
|
133
|
+
border_style="green",
|
|
134
|
+
width=45,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ─── ctxl init ─────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
@main.command()
|
|
142
|
+
@click.argument("task", type=str)
|
|
143
|
+
@click.option("-d", "--directory", default=".", type=click.Path(exists=True), help="Project root directory.")
|
|
144
|
+
@click.option("-f", "--focus", multiple=True, help="Files to focus on (can specify multiple: -f file1.py -f file2.py).")
|
|
145
|
+
@click.option("--no-map", is_flag=True, help="Skip codebase map generation.")
|
|
146
|
+
@click.option("-o", "--output", default=None, help="Custom output path (default: .github/copilot-instructions.md).")
|
|
147
|
+
def init(task, directory, focus, no_map, output):
|
|
148
|
+
"""Generate a task-focused .github/copilot-instructions.md
|
|
149
|
+
|
|
150
|
+
GitHub Copilot natively reads this file for workspace-level instructions.
|
|
151
|
+
This command generates an optimized version tailored to your current task,
|
|
152
|
+
optionally embedding the codebase skeleton so Copilot understands your
|
|
153
|
+
project structure without reading every file.
|
|
154
|
+
|
|
155
|
+
\b
|
|
156
|
+
Examples:
|
|
157
|
+
ctxl init "Fix the data pipeline ETL bug"
|
|
158
|
+
ctxl init "Add user authentication" -f auth.py -f models.py
|
|
159
|
+
ctxl init "Refactor tests" --no-map
|
|
160
|
+
"""
|
|
161
|
+
from ctxl.init import generate_instructions
|
|
162
|
+
|
|
163
|
+
focus_files = list(focus) if focus else None
|
|
164
|
+
|
|
165
|
+
with console.status("[bold cyan]Generating Copilot instructions...", spinner="dots"):
|
|
166
|
+
result_path = generate_instructions(
|
|
167
|
+
project_root=directory,
|
|
168
|
+
task=task,
|
|
169
|
+
focus_files=focus_files,
|
|
170
|
+
include_map=not no_map,
|
|
171
|
+
output_path=output,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
console.print(f"[green]✓[/green] Instructions written to [bold]{result_path}[/bold]")
|
|
175
|
+
console.print("[dim]Copilot will automatically read this file for context.[/dim]")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ─── ctxl checkpoint ───────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
@main.group(name="checkpoint")
|
|
181
|
+
def checkpoint_group():
|
|
182
|
+
"""Save and restore session state for safe /clear workflows."""
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@checkpoint_group.command(name="save")
|
|
187
|
+
@click.option("-d", "--directory", default=".", type=click.Path(exists=True), help="Project root directory.")
|
|
188
|
+
@click.option("-t", "--task", required=True, help="High-level task description.")
|
|
189
|
+
@click.option("--done", multiple=True, help="Completed step (can specify multiple).")
|
|
190
|
+
@click.option("--state", required=True, help="Brief description of current state.")
|
|
191
|
+
@click.option("--next", "next_steps", multiple=True, help="Planned next step (can specify multiple).")
|
|
192
|
+
@click.option("--file", "files", multiple=True, help="File that was modified.")
|
|
193
|
+
@click.option("--learned", multiple=True, help="Key discovery or insight.")
|
|
194
|
+
@click.option("--error", "errors", multiple=True, help="Error that was resolved.")
|
|
195
|
+
def checkpoint_save(directory, task, done, state, next_steps, files, learned, errors):
|
|
196
|
+
"""Save a session checkpoint.
|
|
197
|
+
|
|
198
|
+
Captures what you've done, what you know, and what's next — in a compressed
|
|
199
|
+
format. After saving, you can safely run /clear in Copilot Chat and paste
|
|
200
|
+
the checkpoint content to restore context.
|
|
201
|
+
|
|
202
|
+
\b
|
|
203
|
+
Example:
|
|
204
|
+
ctxl checkpoint save \\
|
|
205
|
+
-t "Fix ETL pipeline" \\
|
|
206
|
+
--done "Found the bug in clean_data()" \\
|
|
207
|
+
--done "Updated the schema validation" \\
|
|
208
|
+
--state "Pipeline runs but output has wrong column order" \\
|
|
209
|
+
--next "Fix column ordering in transform()" \\
|
|
210
|
+
--file "data_pipeline.py" \\
|
|
211
|
+
--learned "The users table has columns: id, name, email, created_at"
|
|
212
|
+
"""
|
|
213
|
+
from ctxl.checkpoint import create_checkpoint
|
|
214
|
+
|
|
215
|
+
result_path = create_checkpoint(
|
|
216
|
+
project_root=directory,
|
|
217
|
+
task=task,
|
|
218
|
+
completed_steps=list(done),
|
|
219
|
+
current_state=state,
|
|
220
|
+
next_steps=list(next_steps),
|
|
221
|
+
files_modified=list(files) if files else None,
|
|
222
|
+
key_discoveries=list(learned) if learned else None,
|
|
223
|
+
errors_resolved=list(errors) if errors else None,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
console.print(f"[green]✓[/green] Checkpoint saved to [bold]{result_path}[/bold]")
|
|
227
|
+
console.print("[dim]You can now safely run /clear in Copilot Chat.[/dim]")
|
|
228
|
+
console.print("[dim]Paste the checkpoint content into your next message to restore context.[/dim]")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@checkpoint_group.command(name="list")
|
|
232
|
+
@click.option("-d", "--directory", default=".", type=click.Path(exists=True), help="Project root directory.")
|
|
233
|
+
def checkpoint_list(directory):
|
|
234
|
+
"""List all saved checkpoints."""
|
|
235
|
+
from ctxl.checkpoint import list_checkpoints
|
|
236
|
+
from rich.table import Table
|
|
237
|
+
|
|
238
|
+
checkpoints = list_checkpoints(directory)
|
|
239
|
+
|
|
240
|
+
if not checkpoints:
|
|
241
|
+
console.print("[yellow]⚠[/yellow] No checkpoints found.")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
table = Table(title="Session Checkpoints", border_style="cyan")
|
|
245
|
+
table.add_column("#", style="dim", width=4)
|
|
246
|
+
table.add_column("Created", style="green")
|
|
247
|
+
table.add_column("Filename")
|
|
248
|
+
table.add_column("Size", justify="right")
|
|
249
|
+
|
|
250
|
+
for i, cp in enumerate(checkpoints, 1):
|
|
251
|
+
size_str = f"{cp['size_bytes']:,} B"
|
|
252
|
+
table.add_row(str(i), cp["created"], cp["filename"], size_str)
|
|
253
|
+
|
|
254
|
+
console.print(table)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@checkpoint_group.command(name="show")
|
|
258
|
+
@click.option("-d", "--directory", default=".", type=click.Path(exists=True), help="Project root directory.")
|
|
259
|
+
def checkpoint_show(directory):
|
|
260
|
+
"""Show the latest checkpoint content."""
|
|
261
|
+
from ctxl.checkpoint import get_latest_checkpoint
|
|
262
|
+
|
|
263
|
+
content = get_latest_checkpoint(directory)
|
|
264
|
+
if content is None:
|
|
265
|
+
console.print("[yellow]⚠[/yellow] No checkpoints found. Run `ctxl checkpoint save` first.")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
console.print(Panel(content, title="[bold]Latest Checkpoint[/bold]", border_style="cyan"))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|
ctxl/init.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctxl init — Generate optimized .github/copilot-instructions.md
|
|
3
|
+
|
|
4
|
+
Creates a task-focused instruction file that GitHub Copilot reads natively.
|
|
5
|
+
Optionally integrates the codebase map to give Copilot structural awareness.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ctxl.mapper import map_directory, format_map_output
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_INSTRUCTIONS_TEMPLATE = """# Copilot Context Instructions
|
|
17
|
+
_Auto-generated by `ctxl init` on {timestamp}_
|
|
18
|
+
|
|
19
|
+
## Current Task
|
|
20
|
+
{task_description}
|
|
21
|
+
|
|
22
|
+
## Focus Files
|
|
23
|
+
{focus_files}
|
|
24
|
+
|
|
25
|
+
## Codebase Structure
|
|
26
|
+
{codebase_map}
|
|
27
|
+
|
|
28
|
+
## Guidelines
|
|
29
|
+
- Focus ONLY on the files and task described above.
|
|
30
|
+
- Do NOT read or modify files outside the focus list unless explicitly asked.
|
|
31
|
+
- Keep responses concise to preserve context window space.
|
|
32
|
+
- If you need to understand a function's implementation, ask — don't guess.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_instructions(
|
|
37
|
+
project_root: str,
|
|
38
|
+
task: str,
|
|
39
|
+
focus_files: Optional[list] = None,
|
|
40
|
+
include_map: bool = True,
|
|
41
|
+
output_path: Optional[str] = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Generate a .github/copilot-instructions.md file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
project_root: Path to the project root directory.
|
|
48
|
+
task: Description of the current task.
|
|
49
|
+
focus_files: List of file paths to focus on (relative to project root).
|
|
50
|
+
include_map: Whether to include the codebase skeleton map.
|
|
51
|
+
output_path: Custom output path. Defaults to .github/copilot-instructions.md.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The path to the generated instructions file.
|
|
55
|
+
"""
|
|
56
|
+
root = Path(project_root).resolve()
|
|
57
|
+
|
|
58
|
+
# Build focus files section
|
|
59
|
+
if focus_files:
|
|
60
|
+
focus_section = "\n".join(f"- `{f}`" for f in focus_files)
|
|
61
|
+
else:
|
|
62
|
+
focus_section = "_No specific focus files set. Copilot will use its default context._"
|
|
63
|
+
|
|
64
|
+
# Build codebase map section
|
|
65
|
+
if include_map:
|
|
66
|
+
skeletons = map_directory(str(root))
|
|
67
|
+
if skeletons:
|
|
68
|
+
codebase_map = format_map_output(skeletons)
|
|
69
|
+
else:
|
|
70
|
+
codebase_map = "_No supported source files found for mapping._"
|
|
71
|
+
else:
|
|
72
|
+
codebase_map = "_Codebase map not included. Run `ctxl map` to generate one._"
|
|
73
|
+
|
|
74
|
+
# Fill the template
|
|
75
|
+
content = DEFAULT_INSTRUCTIONS_TEMPLATE.format(
|
|
76
|
+
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
77
|
+
task_description=task,
|
|
78
|
+
focus_files=focus_section,
|
|
79
|
+
codebase_map=codebase_map,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Determine output path
|
|
83
|
+
if output_path is None:
|
|
84
|
+
out_dir = root / ".github"
|
|
85
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
out_file = out_dir / "copilot-instructions.md"
|
|
87
|
+
else:
|
|
88
|
+
out_file = Path(output_path)
|
|
89
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
out_file.write_text(content, encoding="utf-8")
|
|
92
|
+
return str(out_file)
|
ctxl/mapper.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctx map — Codebase skeleton generator using Tree-sitter.
|
|
3
|
+
|
|
4
|
+
Parses source code files using Tree-sitter ASTs and extracts only
|
|
5
|
+
structural information (function signatures, class definitions, imports)
|
|
6
|
+
to produce a compressed codebase map. Zero AI, zero tokens, pure parsing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import tree_sitter_python as tspython
|
|
14
|
+
import tree_sitter_javascript as tsjavascript
|
|
15
|
+
import tree_sitter_java as tsjava
|
|
16
|
+
import tree_sitter_typescript as tstypescript
|
|
17
|
+
from tree_sitter import Language, Parser, Node
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ─── Language Registry ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
LANGUAGE_MAP: Dict[str, Tuple] = {
|
|
23
|
+
".py": ("python", tspython),
|
|
24
|
+
".js": ("javascript", tsjavascript),
|
|
25
|
+
".jsx": ("javascript", tsjavascript),
|
|
26
|
+
".java": ("java", tsjava),
|
|
27
|
+
".ts": ("typescript", tstypescript),
|
|
28
|
+
".tsx": ("typescript", tstypescript),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# File patterns to always ignore
|
|
32
|
+
IGNORE_PATTERNS = {
|
|
33
|
+
"__pycache__", ".git", ".venv", "venv", "node_modules", ".mypy_cache",
|
|
34
|
+
".pytest_cache", "dist", "build", ".egg-info", ".tox", ".nox",
|
|
35
|
+
"__pypackages__", ".ctx_output",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
IGNORE_EXTENSIONS = {
|
|
39
|
+
".pyc", ".pyo", ".class", ".o", ".so", ".dll", ".exe",
|
|
40
|
+
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
|
|
41
|
+
".zip", ".tar", ".gz", ".bz2",
|
|
42
|
+
".lock", ".log",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_parser(extension: str) -> Optional[Parser]:
|
|
47
|
+
"""Create a Tree-sitter parser for the given file extension."""
|
|
48
|
+
if extension not in LANGUAGE_MAP:
|
|
49
|
+
return None
|
|
50
|
+
lang_name, lang_module = LANGUAGE_MAP[extension]
|
|
51
|
+
language = Language(lang_module.language())
|
|
52
|
+
parser = Parser(language)
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_node_text(node: Node, source: bytes) -> str:
|
|
57
|
+
"""Extract the text of a node from source bytes."""
|
|
58
|
+
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ─── Python Extractor ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _line_num(node: Node) -> int:
|
|
64
|
+
"""Get 1-indexed line number from a Tree-sitter node."""
|
|
65
|
+
return node.start_point[0] + 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_python(tree, source: bytes) -> List[str]:
|
|
69
|
+
"""Extract structural skeleton from a Python AST."""
|
|
70
|
+
lines = []
|
|
71
|
+
root = tree.root_node
|
|
72
|
+
|
|
73
|
+
# Pass 1: Collect imports
|
|
74
|
+
imports = []
|
|
75
|
+
for child in root.children:
|
|
76
|
+
if child.type in ("import_statement", "import_from_statement"):
|
|
77
|
+
imports.append(_get_node_text(child, source).strip())
|
|
78
|
+
|
|
79
|
+
if imports:
|
|
80
|
+
# Compress imports into a single summary line
|
|
81
|
+
lines.append("imports: " + "; ".join(imports))
|
|
82
|
+
lines.append("")
|
|
83
|
+
|
|
84
|
+
# Pass 2: Collect top-level assignments (constants/configs)
|
|
85
|
+
for child in root.children:
|
|
86
|
+
if child.type == "expression_statement":
|
|
87
|
+
expr = child.children[0] if child.children else None
|
|
88
|
+
if expr and expr.type == "assignment":
|
|
89
|
+
target = _get_node_text(expr.children[0], source).strip()
|
|
90
|
+
ln = _line_num(child)
|
|
91
|
+
lines.append(f"L{ln}: {target} = ...")
|
|
92
|
+
|
|
93
|
+
# Pass 3: Collect top-level functions and classes
|
|
94
|
+
for child in root.children:
|
|
95
|
+
if child.type == "function_definition":
|
|
96
|
+
lines.append("")
|
|
97
|
+
lines.append(_extract_python_function(child, source, indent=0))
|
|
98
|
+
elif child.type == "decorated_definition":
|
|
99
|
+
# Handle decorated functions/classes
|
|
100
|
+
for sub in child.children:
|
|
101
|
+
if sub.type == "function_definition":
|
|
102
|
+
decorator_text = []
|
|
103
|
+
for d in child.children:
|
|
104
|
+
if d.type == "decorator":
|
|
105
|
+
decorator_text.append(f"L{_line_num(d)}: {_get_node_text(d, source).strip()}")
|
|
106
|
+
lines.append("")
|
|
107
|
+
for dt in decorator_text:
|
|
108
|
+
lines.append(dt)
|
|
109
|
+
lines.append(_extract_python_function(sub, source, indent=0))
|
|
110
|
+
elif sub.type == "class_definition":
|
|
111
|
+
decorator_text = []
|
|
112
|
+
for d in child.children:
|
|
113
|
+
if d.type == "decorator":
|
|
114
|
+
decorator_text.append(f"L{_line_num(d)}: {_get_node_text(d, source).strip()}")
|
|
115
|
+
lines.append("")
|
|
116
|
+
for dt in decorator_text:
|
|
117
|
+
lines.append(dt)
|
|
118
|
+
lines.extend(_extract_python_class(sub, source))
|
|
119
|
+
elif child.type == "class_definition":
|
|
120
|
+
lines.append("")
|
|
121
|
+
lines.extend(_extract_python_class(child, source))
|
|
122
|
+
|
|
123
|
+
return lines
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _extract_python_function(node: Node, source: bytes, indent: int = 0) -> str:
|
|
127
|
+
"""Extract a function signature (no body) with line number."""
|
|
128
|
+
prefix = " " * indent
|
|
129
|
+
name = ""
|
|
130
|
+
params = ""
|
|
131
|
+
return_type = ""
|
|
132
|
+
ln = _line_num(node)
|
|
133
|
+
|
|
134
|
+
for child in node.children:
|
|
135
|
+
if child.type == "identifier":
|
|
136
|
+
name = _get_node_text(child, source)
|
|
137
|
+
elif child.type == "parameters":
|
|
138
|
+
params = _get_node_text(child, source)
|
|
139
|
+
elif child.type == "type":
|
|
140
|
+
return_type = f" -> {_get_node_text(child, source)}"
|
|
141
|
+
|
|
142
|
+
return f"{prefix}L{ln}: def {name}{params}{return_type}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _extract_python_class(node: Node, source: bytes) -> List[str]:
|
|
146
|
+
"""Extract a class definition with its method signatures."""
|
|
147
|
+
lines = []
|
|
148
|
+
class_name = ""
|
|
149
|
+
superclasses = ""
|
|
150
|
+
docstring = ""
|
|
151
|
+
ln = _line_num(node)
|
|
152
|
+
|
|
153
|
+
for child in node.children:
|
|
154
|
+
if child.type == "identifier":
|
|
155
|
+
class_name = _get_node_text(child, source)
|
|
156
|
+
elif child.type == "argument_list":
|
|
157
|
+
superclasses = _get_node_text(child, source)
|
|
158
|
+
|
|
159
|
+
class_line = f"L{ln}: class {class_name}"
|
|
160
|
+
if superclasses:
|
|
161
|
+
class_line += superclasses
|
|
162
|
+
class_line += ":"
|
|
163
|
+
lines.append(class_line)
|
|
164
|
+
|
|
165
|
+
# Walk class body for methods and class-level docstring
|
|
166
|
+
body = None
|
|
167
|
+
for child in node.children:
|
|
168
|
+
if child.type == "block":
|
|
169
|
+
body = child
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
if body:
|
|
173
|
+
for i, child in enumerate(body.children):
|
|
174
|
+
# Get class docstring (first expression statement with a string)
|
|
175
|
+
if i == 0 and child.type == "expression_statement":
|
|
176
|
+
expr = child.children[0] if child.children else None
|
|
177
|
+
if expr and expr.type == "string":
|
|
178
|
+
docstring = _get_node_text(expr, source).strip()
|
|
179
|
+
lines.append(f" {docstring}")
|
|
180
|
+
|
|
181
|
+
if child.type == "function_definition":
|
|
182
|
+
lines.append(_extract_python_function(child, source, indent=1))
|
|
183
|
+
elif child.type == "decorated_definition":
|
|
184
|
+
for sub in child.children:
|
|
185
|
+
if sub.type == "decorator":
|
|
186
|
+
lines.append(f" {_get_node_text(sub, source).strip()}")
|
|
187
|
+
elif sub.type == "function_definition":
|
|
188
|
+
lines.append(_extract_python_function(sub, source, indent=1))
|
|
189
|
+
|
|
190
|
+
return lines
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ─── JavaScript/TypeScript Extractor ───────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def _extract_js(tree, source: bytes) -> List[str]:
|
|
196
|
+
"""Extract structural skeleton from JavaScript/TypeScript AST."""
|
|
197
|
+
lines = []
|
|
198
|
+
root = tree.root_node
|
|
199
|
+
|
|
200
|
+
for child in root.children:
|
|
201
|
+
if child.type in ("import_statement",):
|
|
202
|
+
lines.append(f"L{_line_num(child)}: {_get_node_text(child, source).strip()}")
|
|
203
|
+
elif child.type == "function_declaration":
|
|
204
|
+
lines.append(_extract_js_function(child, source))
|
|
205
|
+
elif child.type == "class_declaration":
|
|
206
|
+
lines.extend(_extract_js_class(child, source))
|
|
207
|
+
elif child.type in ("export_statement",):
|
|
208
|
+
for sub in child.children:
|
|
209
|
+
if sub.type == "function_declaration":
|
|
210
|
+
lines.append("export " + _extract_js_function(sub, source))
|
|
211
|
+
elif sub.type == "class_declaration":
|
|
212
|
+
cls_lines = _extract_js_class(sub, source)
|
|
213
|
+
if cls_lines:
|
|
214
|
+
cls_lines[0] = "export " + cls_lines[0]
|
|
215
|
+
lines.extend(cls_lines)
|
|
216
|
+
elif sub.type == "lexical_declaration":
|
|
217
|
+
lines.append("export " + _extract_js_variable(sub, source))
|
|
218
|
+
elif child.type == "lexical_declaration":
|
|
219
|
+
lines.append(_extract_js_variable(child, source))
|
|
220
|
+
|
|
221
|
+
return lines
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _extract_js_function(node: Node, source: bytes) -> str:
|
|
225
|
+
"""Extract a JS function signature with line number."""
|
|
226
|
+
name = ""
|
|
227
|
+
params = ""
|
|
228
|
+
ln = _line_num(node)
|
|
229
|
+
for child in node.children:
|
|
230
|
+
if child.type == "identifier":
|
|
231
|
+
name = _get_node_text(child, source)
|
|
232
|
+
elif child.type == "formal_parameters":
|
|
233
|
+
params = _get_node_text(child, source)
|
|
234
|
+
return f"L{ln}: function {name}{params}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _extract_js_class(node: Node, source: bytes) -> List[str]:
|
|
238
|
+
"""Extract a JS class with method signatures and line numbers."""
|
|
239
|
+
lines = []
|
|
240
|
+
class_name = ""
|
|
241
|
+
ln = _line_num(node)
|
|
242
|
+
for child in node.children:
|
|
243
|
+
if child.type == "identifier":
|
|
244
|
+
class_name = _get_node_text(child, source)
|
|
245
|
+
lines.append(f"L{ln}: class {class_name}:")
|
|
246
|
+
|
|
247
|
+
body = None
|
|
248
|
+
for child in node.children:
|
|
249
|
+
if child.type == "class_body":
|
|
250
|
+
body = child
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
if body:
|
|
254
|
+
for child in body.children:
|
|
255
|
+
if child.type == "method_definition":
|
|
256
|
+
name = ""
|
|
257
|
+
params = ""
|
|
258
|
+
mln = _line_num(child)
|
|
259
|
+
for sub in child.children:
|
|
260
|
+
if sub.type == "property_identifier":
|
|
261
|
+
name = _get_node_text(sub, source)
|
|
262
|
+
elif sub.type == "formal_parameters":
|
|
263
|
+
params = _get_node_text(sub, source)
|
|
264
|
+
lines.append(f" L{mln}: {name}{params}")
|
|
265
|
+
|
|
266
|
+
return lines
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_js_variable(node: Node, source: bytes) -> str:
|
|
270
|
+
"""Extract a top-level variable/const declaration with line number."""
|
|
271
|
+
ln = _line_num(node)
|
|
272
|
+
text = _get_node_text(node, source).strip()
|
|
273
|
+
# Truncate the value — just show the declaration name
|
|
274
|
+
if "=" in text:
|
|
275
|
+
left = text.split("=")[0].strip()
|
|
276
|
+
return f"L{ln}: {left} = ..."
|
|
277
|
+
return f"L{ln}: {text}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ─── Java Extractor ────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def _extract_java(tree, source: bytes) -> List[str]:
|
|
283
|
+
"""Extract structural skeleton from Java AST."""
|
|
284
|
+
lines = []
|
|
285
|
+
root = tree.root_node
|
|
286
|
+
|
|
287
|
+
for child in root.children:
|
|
288
|
+
if child.type == "package_declaration":
|
|
289
|
+
lines.append(_get_node_text(child, source).strip())
|
|
290
|
+
elif child.type == "import_declaration":
|
|
291
|
+
lines.append(_get_node_text(child, source).strip())
|
|
292
|
+
elif child.type == "class_declaration":
|
|
293
|
+
lines.extend(_extract_java_class(child, source))
|
|
294
|
+
|
|
295
|
+
return lines
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _extract_java_class(node: Node, source: bytes) -> List[str]:
|
|
299
|
+
"""Extract a Java class with method signatures."""
|
|
300
|
+
lines = []
|
|
301
|
+
class_header_parts = []
|
|
302
|
+
|
|
303
|
+
for child in node.children:
|
|
304
|
+
if child.type in ("modifiers",):
|
|
305
|
+
class_header_parts.append(_get_node_text(child, source))
|
|
306
|
+
elif child.type == "identifier":
|
|
307
|
+
class_header_parts.append(f"class {_get_node_text(child, source)}")
|
|
308
|
+
elif child.type == "superclass":
|
|
309
|
+
class_header_parts.append(_get_node_text(child, source))
|
|
310
|
+
|
|
311
|
+
lines.append(f"L{_line_num(node)}: " + " ".join(class_header_parts) + ":")
|
|
312
|
+
|
|
313
|
+
body = None
|
|
314
|
+
for child in node.children:
|
|
315
|
+
if child.type == "class_body":
|
|
316
|
+
body = child
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
if body:
|
|
320
|
+
for child in body.children:
|
|
321
|
+
if child.type == "method_declaration":
|
|
322
|
+
sig_parts = []
|
|
323
|
+
for sub in child.children:
|
|
324
|
+
if sub.type in ("modifiers",):
|
|
325
|
+
sig_parts.append(_get_node_text(sub, source))
|
|
326
|
+
elif sub.type in ("void_type", "type_identifier", "integral_type",
|
|
327
|
+
"boolean_type", "floating_point_type", "generic_type",
|
|
328
|
+
"array_type"):
|
|
329
|
+
sig_parts.append(_get_node_text(sub, source))
|
|
330
|
+
elif sub.type == "identifier":
|
|
331
|
+
sig_parts.append(_get_node_text(sub, source))
|
|
332
|
+
elif sub.type == "formal_parameters":
|
|
333
|
+
sig_parts.append(_get_node_text(sub, source))
|
|
334
|
+
lines.append(f" L{_line_num(child)}: " + " ".join(sig_parts))
|
|
335
|
+
elif child.type == "constructor_declaration":
|
|
336
|
+
sig_parts = []
|
|
337
|
+
for sub in child.children:
|
|
338
|
+
if sub.type in ("modifiers",):
|
|
339
|
+
sig_parts.append(_get_node_text(sub, source))
|
|
340
|
+
elif sub.type == "identifier":
|
|
341
|
+
sig_parts.append(_get_node_text(sub, source))
|
|
342
|
+
elif sub.type == "formal_parameters":
|
|
343
|
+
sig_parts.append(_get_node_text(sub, source))
|
|
344
|
+
lines.append(" " + " ".join(sig_parts))
|
|
345
|
+
|
|
346
|
+
return lines
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ─── Dispatcher ────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
EXTRACTORS = {
|
|
352
|
+
"python": _extract_python,
|
|
353
|
+
"javascript": _extract_js,
|
|
354
|
+
"typescript": _extract_js, # TS shares structural similarities with JS
|
|
355
|
+
"java": _extract_java,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def map_file(filepath: str) -> Optional[str]:
|
|
360
|
+
"""
|
|
361
|
+
Generate a compressed skeleton for a single file.
|
|
362
|
+
Returns None if the file type is unsupported.
|
|
363
|
+
"""
|
|
364
|
+
path = Path(filepath)
|
|
365
|
+
ext = path.suffix.lower()
|
|
366
|
+
|
|
367
|
+
parser = _get_parser(ext)
|
|
368
|
+
if parser is None:
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
lang_name = LANGUAGE_MAP[ext][0]
|
|
372
|
+
extractor = EXTRACTORS.get(lang_name)
|
|
373
|
+
if extractor is None:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
source = path.read_bytes()
|
|
378
|
+
except (OSError, IOError):
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
tree = parser.parse(source)
|
|
382
|
+
lines = extractor(tree, source)
|
|
383
|
+
|
|
384
|
+
if not lines:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
return "\n".join(lines)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def map_directory(directory: str, extensions: Optional[List[str]] = None) -> Dict[str, str]:
|
|
391
|
+
"""
|
|
392
|
+
Walk a directory and generate skeletons for all supported files.
|
|
393
|
+
Returns a dict of {relative_path: skeleton_text}.
|
|
394
|
+
"""
|
|
395
|
+
root = Path(directory).resolve()
|
|
396
|
+
results: Dict[str, str] = {}
|
|
397
|
+
|
|
398
|
+
if extensions is None:
|
|
399
|
+
extensions = list(LANGUAGE_MAP.keys())
|
|
400
|
+
|
|
401
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
402
|
+
# Prune ignored directories
|
|
403
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORE_PATTERNS
|
|
404
|
+
and not d.startswith(".")]
|
|
405
|
+
|
|
406
|
+
for fname in sorted(filenames):
|
|
407
|
+
fpath = Path(dirpath) / fname
|
|
408
|
+
ext = fpath.suffix.lower()
|
|
409
|
+
|
|
410
|
+
if ext in IGNORE_EXTENSIONS:
|
|
411
|
+
continue
|
|
412
|
+
if ext not in extensions:
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
skeleton = map_file(str(fpath))
|
|
416
|
+
if skeleton:
|
|
417
|
+
rel_path = str(fpath.relative_to(root)).replace("\\", "/")
|
|
418
|
+
results[rel_path] = skeleton
|
|
419
|
+
|
|
420
|
+
return results
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def format_map_output(skeletons: Dict[str, str]) -> str:
|
|
424
|
+
"""Format all skeletons into a single markdown document."""
|
|
425
|
+
if not skeletons:
|
|
426
|
+
return "# Codebase Map\n\n_No supported source files found._\n"
|
|
427
|
+
|
|
428
|
+
sections = []
|
|
429
|
+
sections.append("# Codebase Map")
|
|
430
|
+
sections.append(f"_Generated by `ctx map` — {len(skeletons)} file(s) indexed._\n")
|
|
431
|
+
|
|
432
|
+
for filepath, skeleton in skeletons.items():
|
|
433
|
+
sections.append(f"## {filepath}")
|
|
434
|
+
sections.append("```")
|
|
435
|
+
sections.append(skeleton)
|
|
436
|
+
sections.append("```")
|
|
437
|
+
sections.append("")
|
|
438
|
+
|
|
439
|
+
return "\n".join(sections)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctxl-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Context Engineering CLI — Reduce AI agent token waste with compressed codebase skeletons, task-focused instructions, and session checkpoints. Zero AI, pure parsing.
|
|
5
|
+
Author: Prashanth Reddy
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/prashanthreddy/ctxl
|
|
8
|
+
Project-URL: Repository, https://github.com/prashanthreddy/ctxl
|
|
9
|
+
Project-URL: Issues, https://github.com/prashanthreddy/ctxl/issues
|
|
10
|
+
Keywords: context-engineering,llm,ai-agents,token-optimization,copilot,tree-sitter,codebase-map,developer-tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: click>=8.1
|
|
26
|
+
Requires-Dist: tree-sitter>=0.24
|
|
27
|
+
Requires-Dist: tree-sitter-python>=0.23
|
|
28
|
+
Requires-Dist: tree-sitter-javascript>=0.23
|
|
29
|
+
Requires-Dist: tree-sitter-java>=0.23
|
|
30
|
+
Requires-Dist: tree-sitter-typescript>=0.23
|
|
31
|
+
Requires-Dist: rich>=13.0
|
|
32
|
+
Provides-Extra: clipboard
|
|
33
|
+
Requires-Dist: pyperclip>=1.8; extra == "clipboard"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
36
|
+
Requires-Dist: build; extra == "dev"
|
|
37
|
+
Requires-Dist: twine; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# ctxl — Context Engineering CLI for AI Agents
|
|
41
|
+
|
|
42
|
+
**Reduce token waste. Prevent hallucination. Zero AI used.**
|
|
43
|
+
|
|
44
|
+
`ctxl` (pronounced "contextual") is a developer CLI tool that helps you manage the context window of AI coding agents (GitHub Copilot, Cursor, Claude, etc.) by generating compressed codebase skeletons, task-focused instructions, and session checkpoints — all using deterministic parsing, not AI.
|
|
45
|
+
|
|
46
|
+
## The Problem
|
|
47
|
+
|
|
48
|
+
AI coding agents (Copilot, Cursor, etc.) read your entire codebase to build context. A 3,000-line PySpark file burns ~750 tokens every time the agent references it. Over a session, your context window fills with noise, leading to:
|
|
49
|
+
|
|
50
|
+
- 🔴 **Hallucination** — the model starts making things up
|
|
51
|
+
- 🔴 **Token waste** — you pay for irrelevant context
|
|
52
|
+
- 🔴 **Lost focus** — the agent forgets your actual task
|
|
53
|
+
|
|
54
|
+
## The Solution
|
|
55
|
+
|
|
56
|
+
`ctxl` gives you three commands that prepare your environment *before* the AI agent reads it:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
ctxl map # Generate a compressed codebase skeleton (~95% token reduction)
|
|
60
|
+
ctxl init # Generate task-focused Copilot instructions
|
|
61
|
+
ctxl checkpoint # Save session state for safe /clear workflows
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Zero AI models. Zero API calls. Zero tokens burned by this tool.**
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install ctxl-cli
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
### `ctxl map` — Codebase Skeleton
|
|
75
|
+
|
|
76
|
+
Generate a compressed structural map of your codebase with line numbers:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ctxl map # Map current directory
|
|
80
|
+
ctxl map ./src # Map a specific directory
|
|
81
|
+
ctxl map -e .py # Only Python files
|
|
82
|
+
ctxl map -o codebase.md # Save to file
|
|
83
|
+
ctxl map --clipboard # Copy to clipboard for pasting into AI chat
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Before (raw file, ~750 tokens):**
|
|
87
|
+
```python
|
|
88
|
+
class DataPipeline:
|
|
89
|
+
def __init__(self, spark, config):
|
|
90
|
+
self.spark = spark
|
|
91
|
+
self.config = config
|
|
92
|
+
self.source_path = config.get("source_path", "/data/raw")
|
|
93
|
+
# ... 60 more lines of implementation
|
|
94
|
+
|
|
95
|
+
def clean_data(self, df, drop_nulls=True):
|
|
96
|
+
string_cols = [f.name for f in df.schema.fields ...]
|
|
97
|
+
# ... 20 more lines
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**After (`ctxl map` output, ~50 tokens):**
|
|
101
|
+
```
|
|
102
|
+
L22: class DataPipeline:
|
|
103
|
+
L25: def __init__(self, spark: SparkSession, config: Dict)
|
|
104
|
+
L33: def load_data(self, table_name: str, filters: Optional[Dict] = None) -> DataFrame
|
|
105
|
+
L42: def clean_data(self, df: DataFrame, drop_nulls: bool = True) -> DataFrame
|
|
106
|
+
L52: def transform(self, df: DataFrame, rules: List[Dict]) -> DataFrame
|
|
107
|
+
L66: def validate(self, df: DataFrame) -> bool
|
|
108
|
+
L75: def save(self, df: DataFrame, partition_cols: List[str] = None) -> str
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Line numbers (`L22:`, `L42:`) let the AI agent navigate directly to the right location.
|
|
112
|
+
|
|
113
|
+
### `ctxl init` — Copilot Instructions
|
|
114
|
+
|
|
115
|
+
Generate a `.github/copilot-instructions.md` file that GitHub Copilot reads natively:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
ctxl init "Fix the data pipeline ETL bug"
|
|
119
|
+
ctxl init "Add authentication" -f auth.py -f models.py
|
|
120
|
+
ctxl init "Refactor tests" --no-map
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `ctxl checkpoint` — Session State
|
|
124
|
+
|
|
125
|
+
Save your progress before running `/clear` in Copilot Chat:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
ctxl checkpoint save \
|
|
129
|
+
-t "Fix ETL pipeline" \
|
|
130
|
+
--done "Found the bug in clean_data()" \
|
|
131
|
+
--state "Pipeline runs but output has wrong column order" \
|
|
132
|
+
--next "Fix column ordering in transform()" \
|
|
133
|
+
--file "data_pipeline.py"
|
|
134
|
+
|
|
135
|
+
ctxl checkpoint list # List all checkpoints
|
|
136
|
+
ctxl checkpoint show # Show latest checkpoint
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Supported Languages
|
|
140
|
+
|
|
141
|
+
`ctxl map` uses [Tree-sitter](https://tree-sitter.github.io/) for parsing and supports:
|
|
142
|
+
|
|
143
|
+
| Language | Extensions |
|
|
144
|
+
|----------|-----------|
|
|
145
|
+
| Python | `.py` |
|
|
146
|
+
| JavaScript | `.js`, `.jsx` |
|
|
147
|
+
| TypeScript | `.ts`, `.tsx` |
|
|
148
|
+
| Java | `.java` |
|
|
149
|
+
|
|
150
|
+
More languages can be added easily via Tree-sitter grammars.
|
|
151
|
+
|
|
152
|
+
## How It Works
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
Your Codebase (10,000+ tokens)
|
|
156
|
+
│
|
|
157
|
+
▼
|
|
158
|
+
Tree-sitter Parser (deterministic, local, free)
|
|
159
|
+
│
|
|
160
|
+
▼
|
|
161
|
+
AST → Extract signatures + line numbers
|
|
162
|
+
│
|
|
163
|
+
▼
|
|
164
|
+
Compressed Skeleton (~500 tokens)
|
|
165
|
+
│
|
|
166
|
+
▼
|
|
167
|
+
AI Agent reads skeleton instead of raw code
|
|
168
|
+
│
|
|
169
|
+
▼
|
|
170
|
+
90-95% fewer tokens burned 🎉
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ctxl/__init__.py,sha256=UpoJyjx2hepXR8KF3H6OenKfBk-IvUk3nkMJ98PGzfA,77
|
|
2
|
+
ctxl/checkpoint.py,sha256=GPkKDXRAGXlZvi5g_9TYtLd326xT5GXK7Pa3UXucNdc,4002
|
|
3
|
+
ctxl/cli.py,sha256=mFFjlMBFoqFBoUXJ4vEtPbTYmVX8P2bEadHCFscOrHE,11300
|
|
4
|
+
ctxl/init.py,sha256=_ihi6HACeKWoQHr7RkvqOp8L_LX7s52TTgDaIYSdxqo,2809
|
|
5
|
+
ctxl/mapper.py,sha256=X2Pydd890vdXx9GuqlTg9FDTTef_EY7yphHTfUpioTI,16315
|
|
6
|
+
ctxl_cli-0.1.0.dist-info/licenses/LICENSE,sha256=eA0xRKuj6ea-BZ9y3v1fbypmbxCUGR0yqwYjQpGUpdE,1072
|
|
7
|
+
ctxl_cli-0.1.0.dist-info/METADATA,sha256=0gne2cohmBs3tyF8H32PZi3KqI9yvA14Wnm8LMH6FMk,6120
|
|
8
|
+
ctxl_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
ctxl_cli-0.1.0.dist-info/entry_points.txt,sha256=qD7gAhtk7VXHnpW37TUR7D2dyb7jyVJGKINRtxqJUa4,39
|
|
10
|
+
ctxl_cli-0.1.0.dist-info/top_level.txt,sha256=t3CHnQUVd8wlny-CPnhlsl2KMHowoik24WLCEMBvxQA,5
|
|
11
|
+
ctxl_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prashanth Reddy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ctxl
|