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 ADDED
@@ -0,0 +1,3 @@
1
+ """ctxl — Context Engineering CLI for AI agents."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ctxl = ctxl.cli:main
@@ -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