ctx-bridge 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.
@@ -0,0 +1,262 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctx-bridge
3
+ Version: 0.1.0
4
+ Summary: CLI tool to snapshot project context, git state, diffs, and progress logs for AI assistant session handoffs.
5
+ Author: Maaz Korejo
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/Maazkorejo/CTX-Bridge
8
+ Keywords: ai,cli,context,prompt,git,developer-tools,handoff,bridge
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: click>=8.0.0
13
+ Requires-Dist: rich>=13.0.0
14
+ Requires-Dist: gitpython>=3.1.0
15
+ Requires-Dist: pyperclip>=1.8.0
16
+ Requires-Dist: pathspec>=0.11.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # 🌁 CTX-Bridge (`ctx-bridge` / `bridge`)
22
+
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
24
+ [![Python Version](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/downloads/)
25
+ [![Code Style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
26
+
27
+ > **Bridge the context gap between AI coding assistants.**
28
+ > Snapshot your git state, code diffs, directory structure, project metadata, and running progress logs into a clean, paste-ready handoff summary — so your next AI session picks up **exactly where the last one left off.**
29
+
30
+ ---
31
+
32
+ ## ⚡ The Problem
33
+
34
+ When coding with AI assistants (**Cursor, GitHub Copilot, Windsurf, Claude Code, ChatGPT**), you inevitably hit context window limits or session resets mid-task. Switching tools or starting a fresh chat forces you to re-explain everything from scratch — wasting **time, tokens, and focus**.
35
+
36
+ ## 🚀 The Solution: `CTX-Bridge`
37
+
38
+ `CTX-Bridge` acts as persistent cognitive memory for your developer workspace. With simple terminal commands, it captures what was done, what files were touched, uncommitted git diffs, project dependencies, and an ASCII tree structure — framing it instantly into your clipboard and a saved snapshot document.
39
+
40
+ ```
41
+ [ AI Assistant Session 1 ]
42
+ │
43
+ ▼ (Run: bridge export)
44
+ ┌───────────────────────┐
45
+ │ CTX-Bridge Engine │ ──► Saved .ctx/exports/handoff.md
46
+ └───────────────────────┘ ──► Copied to Clipboard
47
+ │
48
+ ▼ (Paste into New Chat)
49
+ [ AI Assistant Session 2 ]
50
+ ```
51
+
52
+ ---
53
+
54
+ ## ✨ Features
55
+
56
+ - 🌐 **Web Chat Session Handoff (`bridge chat`)**: Purpose-built for web browser AI chats. Wrap copied code/notes into a formatted prompt instantly.
57
+ - 🌿 **Git State Integration**: Extracts active branch, recent commit logs, staged, unstaged, and untracked files.
58
+ - 📝 **Code Diff Capture**: Captures working tree `git diff` output with configurable line limit truncation.
59
+ - 🌲 **Smart Directory Tree**: Generates ASCII project structure respecting `.gitignore` patterns with directory symlink loop protection.
60
+ - 🔍 **Multi-Language Auto-Detection**: Automatically identifies project language and framework:
61
+ - **Python** (FastAPI, Django, Flask, `pyproject.toml`, `requirements.txt`)
62
+ - **JavaScript / TypeScript** (Next.js, React, Vue, Express, NestJS)
63
+ - **Rust** (`Cargo.toml`) | **Go** (`go.mod`) | **Java/Kotlin** (Maven, Gradle)
64
+ - **C# / .NET** (`*.csproj`, `*.sln`) | **Ruby** (`Gemfile`) | **PHP** (`composer.json`)
65
+ - 📋 **Running Session Logs**: Add timestamped progress notes while coding (`bridge log "..."`).
66
+ - 🤖 **AI Assistant Presets**: Tailored prompt framing preambles for **Cursor**, **GitHub Copilot**, **Claude**, **ChatGPT**, and **Generic AI**.
67
+ - 🛡️ **Security Safeguards**:
68
+ - Automatically appends `.ctx/` to `.gitignore` on initialization.
69
+ - Automatically redacts sensitive files (`.env*`, `*.pem`, `*.key`, `id_rsa*`, `credentials.json`) from diffs and status reports.
70
+
71
+ ---
72
+
73
+ ## 📦 Installation
74
+
75
+ ```bash
76
+ # Clone repository
77
+ git clone https://github.com/Maazkorejo/CTX-Bridge.git
78
+ cd CTX-Bridge
79
+
80
+ # Install locally in editable mode
81
+ pip install -e .
82
+ ```
83
+
84
+ After installation, both `ctx-bridge` and the shortcut `bridge` are available in your terminal:
85
+ ```bash
86
+ bridge --version
87
+ # Output: ctx-bridge, version 0.1.0
88
+ ```
89
+
90
+ ---
91
+
92
+ ## 🛠️ Detailed Command Reference
93
+
94
+ You can invoke commands using either `bridge` or `ctx-bridge`.
95
+
96
+ ### 1. `bridge chat`
97
+ Purpose-built for web browser AI chat sessions (no repository required). Formats copied code and task goals into a paste-ready handoff prompt and copies it to your clipboard.
98
+
99
+ ```bash
100
+ # Interactive mode (prompts for goal, code/clipboard, and next task)
101
+ bridge chat
102
+
103
+ # Auto-grab whatever you just copied from your browser chat:
104
+ bridge chat --from-clipboard
105
+
106
+ # Command-line flags mode:
107
+ bridge chat --goal "FastAPI Auth Service" --task "Add password hashing" --template claude
108
+ ```
109
+
110
+ ### 2. `bridge init`
111
+ Initializes `.ctx/` configuration directory and `progress.md` in your project root. Automatically appends `.ctx/` to `.gitignore`.
112
+
113
+ ```bash
114
+ bridge init
115
+ # Custom project name:
116
+ bridge init --name "my-awesome-service"
117
+ ```
118
+
119
+ ### 2. `bridge log <message>`
120
+ Appends a timestamped progress note to `.ctx/progress.md` so the AI knows what has already been accomplished.
121
+
122
+ ```bash
123
+ bridge log "Created user authentication endpoints in /api/auth"
124
+ bridge log "Fixed JWT token verification bug in middleware"
125
+ ```
126
+
127
+ ### 3. `bridge status`
128
+ Displays a rich terminal table showing project metadata, active git branch, modified files count, and logged entries.
129
+
130
+ ```bash
131
+ bridge status
132
+ ```
133
+
134
+ ### 4. `bridge export`
135
+ Generates the complete context snapshot markdown report, copies it to your **system clipboard**, and saves a snapshot copy to `.ctx/exports/handoff_<timestamp>.md`.
136
+
137
+ ```bash
138
+ # Export with default generic AI framing
139
+ bridge export
140
+
141
+ # Export framed specifically for Cursor IDE or Claude
142
+ bridge export --template cursor
143
+ bridge export --template claude
144
+
145
+ # Export Flags:
146
+ bridge export --no-clipboard # Do not copy to system clipboard
147
+ bridge export --no-file # Do not save .md file to disk
148
+ bridge export --stdout # Output markdown directly to stdout
149
+ ```
150
+
151
+ ### 5. `bridge reset`
152
+ Clears current progress entries in `.ctx/progress.md` for a fresh session.
153
+
154
+ ```bash
155
+ bridge reset
156
+ ```
157
+
158
+ ---
159
+
160
+ ## 📄 Example Export Snapshot Output
161
+
162
+ Here is what `bridge export` copies to your clipboard:
163
+
164
+ ```markdown
165
+ # 🔄 Context Handoff — my-fastapi-app
166
+
167
+ > **Generated at**: 2026-08-13 05:15:00
168
+ > [CURSOR HANDOFF SESSION] You are Cursor AI assistant. Resume the active coding task using the project context below.
169
+
170
+ ## 📋 Session Progress Log
171
+ - **[2026-08-13 04:30:12]** Created database schema for users and auth tokens
172
+ - **[2026-08-13 05:10:45]** Implemented JWT token verification middleware
173
+
174
+ ## 🌿 Git State
175
+ - **Branch**: `feature/auth`
176
+ - **Staged Files**: `app/auth.py`, `app/main.py`
177
+ - **Unstaged Files**: `tests/test_auth.py`
178
+
179
+ **Recent Commits**:
180
+ - `a1b2c3d` — Initial project structure
181
+ - `e5f6g7h` — Add database config
182
+
183
+ ## 📝 Code Changes (git diff)
184
+ ```diff
185
+ --- a/app/auth.py
186
+ +++ b/app/auth.py
187
+ @@ -12,4 +12,4 @@ def verify_token(token: str):
188
+ - return jwt.decode(token, SECRET)
189
+ + return jwt.decode(token, SECRET, algorithms=["HS256"])
190
+ ```
191
+
192
+ ## 📁 Project Structure (`*` = modified)
193
+ ```
194
+ my-fastapi-app/
195
+ ├── app/
196
+ │ ├── __init__.py
197
+ │ ├── auth.py *
198
+ │ └── main.py *
199
+ ├── tests/
200
+ │ └── test_auth.py *
201
+ ├── pyproject.toml
202
+ └── requirements.txt
203
+ ```
204
+
205
+ ## 🔧 Project Info
206
+ - **Type**: Python
207
+ - **Framework**: FastAPI
208
+ - **Key Dependencies**: fastapi, uvicorn, pyjwt, pydantic
209
+ ```
210
+
211
+ ---
212
+
213
+ ## ⚙️ Configuration (`.ctx/config.json`)
214
+
215
+ When you run `bridge init`, `.ctx/config.json` is created:
216
+
217
+ ```json
218
+ {
219
+ "project_name": "my-project",
220
+ "project_type": "Python",
221
+ "template": "generic",
222
+ "export_format": "markdown",
223
+ "max_diff_lines": 300,
224
+ "max_tree_depth": 4,
225
+ "ignore_patterns": [
226
+ "node_modules",
227
+ ".git",
228
+ "__pycache__",
229
+ ".venv",
230
+ "venv",
231
+ "dist",
232
+ "build",
233
+ ".ctx"
234
+ ]
235
+ }
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 🧪 Running Tests
241
+
242
+ ```bash
243
+ # Install development dependencies
244
+ pip install -e .[dev]
245
+
246
+ # Run full test suite
247
+ python -m pytest tests/ -v
248
+ ```
249
+
250
+ ---
251
+
252
+ ## 👤 Author & Maintainer
253
+
254
+ **Maaz Korejo**
255
+ - GitHub: [@Maazkorejo](https://github.com/Maazkorejo)
256
+ - Repository: [CTX-Bridge](https://github.com/Maazkorejo/CTX-Bridge)
257
+
258
+ ---
259
+
260
+ ## 📜 License
261
+
262
+ Distributed under the [MIT License](LICENSE).
@@ -0,0 +1,16 @@
1
+ ctx_bridge-0.1.0.dist-info/licenses/LICENSE,sha256=_k1R-l6uFzyI_xwFxcz4dW0qxJD4jAiHlB7fQY75oFo,1089
2
+ ctx_tool/__init__.py,sha256=0O8cTmdJkj5IlWZHH6kcn5GdId30NKLrFBUC5M5qRi0,52
3
+ ctx_tool/__main__.py,sha256=0-Jiq5KVjR3UiGFOJ16da-iJTRH9jOd2gw3BQ1dWJIA,71
4
+ ctx_tool/cli.py,sha256=iL5Aiiid2_ksioCNMkX9ZmHEZxC8iDM05FrZxqlPQVo,10177
5
+ ctx_tool/config.py,sha256=YwU0N5pFd9fVMAw0cVB7ghfZumlVvpAupHrVJyzJkBA,3653
6
+ ctx_tool/detector.py,sha256=ZgZZLmvcsvGJTihMAFkoM2NqZuZAUv_7I2Vw863M7tw,4109
7
+ ctx_tool/exporter.py,sha256=pg12T8NGs4IgF9gMsW2xf3UKE46g-ieBj8FBat2KeI4,5890
8
+ ctx_tool/git_utils.py,sha256=ENHalsZBJTWoE55RMyKbcmB3seT9-D3kfcAyxpZmx_o,4471
9
+ ctx_tool/session.py,sha256=NwNeIvqrrHhDvjfp2nNn6oceKKO8McXpFTDIp57GQ6M,1867
10
+ ctx_tool/templates.py,sha256=an8KdUBVSsIclZwCj45zszA1EcT7G2YPyVF26Y4IZdo,1448
11
+ ctx_tool/tree.py,sha256=LvGhhBB4DNAhg1kRun7Qa_H8GQtz_PPSB9pDxRK2NyA,2591
12
+ ctx_bridge-0.1.0.dist-info/METADATA,sha256=ptgERFiC2G6W2qThtUG2z5DCt0MpBk3c6WvA1ovWm4g,8722
13
+ ctx_bridge-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ ctx_bridge-0.1.0.dist-info/entry_points.txt,sha256=1sDPmd4zOQs1jwxFJiv4iMnG61606z9ntwwd3RcPqQU,74
15
+ ctx_bridge-0.1.0.dist-info/top_level.txt,sha256=KbDA_67Z_yoRPLAjS7lmaNF6nOEXzQAqV5RS4KU5SAw,9
16
+ ctx_bridge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bridge = ctx_tool.cli:cli
3
+ ctx-bridge = ctx_tool.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maaz Korejo
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
+ ctx_tool
ctx_tool/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """promtext-ctx package."""
2
+ __version__ = "0.1.0"
ctx_tool/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ctx_tool.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
ctx_tool/cli.py ADDED
@@ -0,0 +1,235 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+ import click
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+
8
+ from ctx_tool import __version__
9
+ from ctx_tool.config import find_ctx_root, init_ctx_dir, is_initialized, load_config, save_config
10
+ from ctx_tool.detector import detect_project_type
11
+ from ctx_tool.exporter import export_chat_context, export_context
12
+ from ctx_tool.git_utils import get_branch, get_git_status, get_recent_commits, get_repo
13
+ from ctx_tool.session import get_entries, log_entry, reset_session
14
+ import pyperclip
15
+
16
+ console = Console()
17
+
18
+
19
+ @click.group(invoke_without_command=True)
20
+ @click.version_option(version=__version__, prog_name="ctx-bridge")
21
+ @click.pass_context
22
+ def cli(ctx):
23
+ """ctx-bridge (`bridge`): AI Context Handoff CLI Tool for AI coding assistants."""
24
+ if ctx.invoked_subcommand is None:
25
+ console.print(
26
+ Panel.fit(
27
+ "[bold cyan]ctx-bridge[/bold cyan] — AI Coding Context Handoff Tool\n\n"
28
+ "Commands:\n"
29
+ " [green]ctx-bridge chat[/green] (or [green]bridge chat[/green]) Format web chat code & notes into handoff prompt\n"
30
+ " [green]ctx-bridge init[/green] (or [green]bridge init[/green]) Initialize context tracking\n"
31
+ " [green]ctx-bridge log[/green] (or [green]bridge log[/green]) Add progress note to session log\n"
32
+ " [green]ctx-bridge status[/green] (or [green]bridge status[/green]) Show project tracking overview\n"
33
+ " [green]ctx-bridge export[/green] (or [green]bridge export[/green]) Export repo handoff to clipboard & markdown\n"
34
+ " [green]ctx-bridge reset[/green] (or [green]bridge reset[/green]) Clear progress log for a fresh session\n\n"
35
+ "Run [bold yellow]ctx-bridge --help[/bold yellow] for detailed usage options.",
36
+ title="ctx-bridge CLI",
37
+ )
38
+ )
39
+
40
+
41
+ @cli.command("init")
42
+ @click.option("--name", "-n", help="Project name override")
43
+ def init_cmd(name: Optional[str]):
44
+ """Initialize context tracking (.ctx directory and default config)."""
45
+ root = Path.cwd().resolve()
46
+ proj_name = name or root.name
47
+ proj_type, _ = detect_project_type(root)
48
+
49
+ ctx_dir, config = init_ctx_dir(project_name=proj_name, project_type=proj_type, root=root)
50
+ console.print(f"[bold green]✓[/bold green] Context tracking initialized in [bold]{root}[/bold]")
51
+ console.print(f" Project: [cyan]{proj_name}[/cyan] ({proj_type})")
52
+ console.print(f" Config: [dim]{ctx_dir / 'config.json'}[/dim]")
53
+ console.print(f" Log: [dim]{ctx_dir / 'progress.md'}[/dim]")
54
+
55
+
56
+ @cli.command("log")
57
+ @click.argument("message", required=True)
58
+ def log_cmd(message: str):
59
+ """Add a progress note to the current session log."""
60
+ root = find_ctx_root()
61
+ if not root:
62
+ console.print("[bold red]Error:[/bold red] .ctx repository not initialized. Run [yellow]ctx init[/yellow] first.")
63
+ return
64
+
65
+ success = log_entry(message, root=root)
66
+ if success:
67
+ console.print(f"[bold green]📝 Logged:[/bold green] {message}")
68
+ else:
69
+ console.print("[bold red]Failed to write log entry.[/bold red]")
70
+
71
+
72
+ @cli.command("status")
73
+ def status_cmd():
74
+ """Display project context status, git state, and session log summary."""
75
+ root = find_ctx_root()
76
+ if not root:
77
+ console.print("[bold yellow]Notice:[/bold yellow] .ctx repository not initialized in this directory.")
78
+ return
79
+
80
+ config = load_config(root)
81
+ repo = get_repo(root)
82
+ branch = get_branch(repo)
83
+ status = get_git_status(repo)
84
+ entries = get_entries(root)
85
+
86
+ table = Table(title=f"📊 Context Status — {config.get('project_name', root.name)}")
87
+ table.add_column("Property", style="cyan")
88
+ table.add_column("Value", style="bold white")
89
+
90
+ table.add_row("Root Directory", str(root))
91
+ table.add_row("Project Type", config.get("project_type", "Unknown"))
92
+ table.add_row("Git Branch", branch)
93
+ table.add_row("Staged Files", str(len(status["staged"])))
94
+ table.add_row("Unstaged Files", str(len(status["unstaged"])))
95
+ table.add_row("Untracked Files", str(len(status["untracked"])))
96
+ table.add_row("Logged Entries", str(len(entries)))
97
+ table.add_row("Template", config.get("template", "generic"))
98
+
99
+ console.print(table)
100
+
101
+ if entries:
102
+ console.print("\n[bold]Recent Session Logs:[/bold]")
103
+ for entry in entries[-5:]:
104
+ console.print(f" {entry}")
105
+
106
+
107
+ @cli.command("export")
108
+ @click.option("--template", "-t", type=click.Choice(["generic", "cursor", "copilot", "claude", "chatgpt"]), help="AI assistant template preset")
109
+ @click.option("--no-clipboard", is_flag=True, help="Do not copy export to clipboard")
110
+ @click.option("--no-file", is_flag=True, help="Do not save export markdown file")
111
+ @click.option("--stdout", is_flag=True, help="Print exported markdown to stdout")
112
+ def export_cmd(template: Optional[str], no_clipboard: bool, no_file: bool, stdout: bool):
113
+ """Generate handoff markdown, copy to clipboard, and save snapshot file."""
114
+ root = find_ctx_root()
115
+ if not root:
116
+ # Auto-init if not initialized
117
+ init_ctx_dir(project_name=Path.cwd().name, project_type=detect_project_type()[0])
118
+ root = Path.cwd()
119
+
120
+ markdown_text, out_file, clipboard_ok = export_context(
121
+ template_name=template or "generic",
122
+ root_path=root,
123
+ save_to_file=not no_file,
124
+ copy_to_clipboard=not no_clipboard,
125
+ )
126
+
127
+ if stdout:
128
+ console.print(markdown_text)
129
+ return
130
+
131
+ console.print("\n[bold green]🚀 Context Export Complete![/bold green]\n")
132
+
133
+ if not no_clipboard:
134
+ if clipboard_ok:
135
+ console.print(" [bold green]📋 Copied to Clipboard![/bold green] Ready to paste into your AI assistant.")
136
+ else:
137
+ console.print(" [yellow]⚠️ Could not access system clipboard. Export was generated below.[/yellow]")
138
+
139
+ if out_file and not no_file:
140
+ console.print(f" [bold blue]💾 Snapshot Saved:[/bold blue] [dim]{out_file}[/dim]")
141
+
142
+ console.print("\n[dim]Tip: Open your new AI chat window and press Ctrl+V / Cmd+V to paste handoff context.[/dim]")
143
+
144
+
145
+ @cli.command("reset")
146
+ @click.confirmation_option(prompt="Are you sure you want to clear current session logs?")
147
+ def reset_cmd():
148
+ """Reset session progress log."""
149
+ root = find_ctx_root()
150
+ if not root:
151
+ console.print("[bold red]Error:[/bold red] .ctx repository not initialized.")
152
+ return
153
+
154
+ if reset_session(root):
155
+ console.print("[bold green]✓ Session progress log reset successfully.[/bold green]")
156
+ else:
157
+ console.print("[bold red]Failed to reset session log.[/bold red]")
158
+
159
+
160
+ @cli.command("chat")
161
+ @click.option("--goal", "-g", help="Project or task description goal")
162
+ @click.option("--code", "-c", help="Code snippet or context notes")
163
+ @click.option("--task", "-k", help="Next task / where you left off")
164
+ @click.option("--from-clipboard", "-i", is_flag=True, help="Automatically grab raw copied text/code from clipboard")
165
+ @click.option("--template", "-t", type=click.Choice(["generic", "cursor", "copilot", "claude", "chatgpt"]), help="AI assistant template preset")
166
+ @click.option("--stdout", is_flag=True, help="Print handoff directly to stdout without panel")
167
+ def chat_cmd(
168
+ goal: Optional[str],
169
+ code: Optional[str],
170
+ task: Optional[str],
171
+ from_clipboard: bool,
172
+ template: Optional[str],
173
+ stdout: bool,
174
+ ):
175
+ """Format web chat code & notes into a paste-ready AI handoff prompt."""
176
+ target_template = template or "claude"
177
+
178
+ goal_val = goal or ""
179
+ code_val = code or ""
180
+ task_val = task or ""
181
+
182
+ if from_clipboard:
183
+ try:
184
+ cb_content = pyperclip.paste()
185
+ if cb_content and cb_content.strip():
186
+ code_val = cb_content.strip()
187
+ console.print("[bold green]✓ Imported code/text from system clipboard.[/bold green]")
188
+ except Exception:
189
+ console.print("[yellow]⚠️ Could not access system clipboard.[/yellow]")
190
+
191
+ # Interactive prompt if no inputs given
192
+ if not goal_val and not code_val and not task_val and not from_clipboard and not stdout:
193
+ console.print(
194
+ Panel.fit(
195
+ "[bold cyan]🌐 Web Chat AI Handoff Builder[/bold cyan]\n"
196
+ "Build a paste-ready handoff prompt from your web browser chat session.",
197
+ title="bridge chat",
198
+ )
199
+ )
200
+ goal_val = click.prompt("🎯 What is your project goal / topic?", default="", show_default=False)
201
+
202
+ use_cb = click.confirm("📋 Copy code/text from system clipboard?", default=True)
203
+ if use_cb:
204
+ try:
205
+ code_val = pyperclip.paste()
206
+ if code_val:
207
+ console.print("[dim]Imported clipboard text successfully.[/dim]")
208
+ except Exception:
209
+ pass
210
+
211
+ if not code_val:
212
+ code_val = click.prompt("💻 Paste latest code snippet or context notes", default="", show_default=False)
213
+
214
+ task_val = click.prompt("📍 Next task / Where did you leave off?", default="", show_default=False)
215
+
216
+ markdown_text, clipboard_ok = export_chat_context(
217
+ goal=goal_val,
218
+ code_or_context=code_val,
219
+ next_task=task_val,
220
+ template_name=target_template,
221
+ copy_to_clipboard=True,
222
+ )
223
+
224
+ if stdout:
225
+ console.print(markdown_text)
226
+ return
227
+
228
+ console.print("\n[bold green]🚀 Web Chat Handoff Ready![/bold green]\n")
229
+ if clipboard_ok:
230
+ console.print(" [bold green]📋 Copied to Clipboard![/bold green] Paste (`Ctrl+V`) into your new AI chat window.")
231
+ else:
232
+ console.print(" [yellow]⚠️ Clipboard copy unavailable. Copy the markdown below manually:[/yellow]")
233
+
234
+ console.print("\n" + Panel(markdown_text, title="Handoff Prompt Preview", border_style="cyan"))
235
+
ctx_tool/config.py ADDED
@@ -0,0 +1,102 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional, Tuple
4
+
5
+ CTX_DIR_NAME = ".ctx"
6
+ CONFIG_FILE_NAME = "config.json"
7
+ EXPORTS_DIR_NAME = "exports"
8
+
9
+
10
+ def find_ctx_root(start_path: Optional[Path] = None) -> Optional[Path]:
11
+ """Find the root directory containing .ctx by searching upward."""
12
+ curr = (start_path or Path.cwd()).resolve()
13
+ for parent in [curr] + list(curr.parents):
14
+ if (parent / CTX_DIR_NAME).is_dir():
15
+ return parent
16
+ return None
17
+
18
+
19
+ def get_ctx_dir(root: Optional[Path] = None) -> Path:
20
+ """Return .ctx directory path for given root or current working dir."""
21
+ base = root or find_ctx_root() or Path.cwd()
22
+ return base / CTX_DIR_NAME
23
+
24
+
25
+ def is_initialized(root: Optional[Path] = None) -> bool:
26
+ """Check if .ctx directory exists in current project."""
27
+ return get_ctx_dir(root).exists()
28
+
29
+
30
+ def init_ctx_dir(project_name: str, project_type: str, root: Optional[Path] = None) -> Tuple[Path, Dict[str, Any]]:
31
+ """Initialize .ctx directory and default config."""
32
+ base = root or Path.cwd()
33
+ ctx_dir = base / CTX_DIR_NAME
34
+ ctx_dir.mkdir(exist_ok=True)
35
+
36
+ exports_dir = ctx_dir / EXPORTS_DIR_NAME
37
+ exports_dir.mkdir(exist_ok=True)
38
+
39
+ config_path = ctx_dir / CONFIG_FILE_NAME
40
+ default_config: Dict[str, Any] = {
41
+ "project_name": project_name,
42
+ "project_type": project_type,
43
+ "template": "generic",
44
+ "export_format": "markdown",
45
+ "max_diff_lines": 300,
46
+ "max_tree_depth": 4,
47
+ "ignore_patterns": ["node_modules", ".git", "__pycache__", ".venv", "venv", "dist", "build", ".ctx"],
48
+ }
49
+
50
+ if not config_path.exists():
51
+ with open(config_path, "w", encoding="utf-8") as f:
52
+ json.dump(default_config, f, indent=2)
53
+
54
+ progress_path = ctx_dir / "progress.md"
55
+ if not progress_path.exists():
56
+ with open(progress_path, "w", encoding="utf-8") as f:
57
+ f.write("# Session Progress Log\n\n")
58
+
59
+ # Auto-add .ctx/ to .gitignore to prevent accidental commit of sensitive snapshots
60
+ gitignore_path = base / ".gitignore"
61
+ try:
62
+ if gitignore_path.exists():
63
+ content = gitignore_path.read_text(encoding="utf-8")
64
+ if ".ctx" not in content and ".ctx/" not in content:
65
+ with open(gitignore_path, "a", encoding="utf-8") as f:
66
+ if not content.endswith("\n"):
67
+ f.write("\n")
68
+ f.write("\n# AI Context Handoff snapshot directory\n.ctx/\n")
69
+ else:
70
+ with open(gitignore_path, "w", encoding="utf-8") as f:
71
+ f.write("# AI Context Handoff snapshot directory\n.ctx/\n")
72
+ except Exception:
73
+ pass
74
+
75
+ return ctx_dir, default_config
76
+
77
+
78
+ def load_config(root: Optional[Path] = None) -> Dict[str, Any]:
79
+ """Load config from .ctx/config.json."""
80
+ ctx_dir = get_ctx_dir(root)
81
+ config_path = ctx_dir / CONFIG_FILE_NAME
82
+ if not config_path.exists():
83
+ return {}
84
+ try:
85
+ with open(config_path, "r", encoding="utf-8") as f:
86
+ return json.load(f)
87
+ except Exception:
88
+ return {}
89
+
90
+
91
+ def save_config(config: Dict[str, Any], root: Optional[Path] = None) -> bool:
92
+ """Save config dictionary to .ctx/config.json."""
93
+ ctx_dir = get_ctx_dir(root)
94
+ if not ctx_dir.exists():
95
+ return False
96
+ config_path = ctx_dir / CONFIG_FILE_NAME
97
+ try:
98
+ with open(config_path, "w", encoding="utf-8") as f:
99
+ json.dump(config, f, indent=2)
100
+ return True
101
+ except Exception:
102
+ return False
ctx_tool/detector.py ADDED
@@ -0,0 +1,113 @@
1
+ import json
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+
7
+ def detect_project_type(root_path: Optional[Path] = None) -> Tuple[str, Dict[str, Any]]:
8
+ """Detect project language, framework, and metadata from root directory."""
9
+ root = (root_path or Path.cwd()).resolve()
10
+
11
+ metadata: Dict[str, Any] = {
12
+ "language": "Unknown",
13
+ "framework": "Generic",
14
+ "key_dependencies": [],
15
+ "version": "0.1.0",
16
+ }
17
+
18
+ # Python
19
+ if (root / "pyproject.toml").exists() or (root / "setup.py").exists() or (root / "requirements.txt").exists():
20
+ metadata["language"] = "Python"
21
+ deps = []
22
+ if (root / "requirements.txt").exists():
23
+ try:
24
+ content = (root / "requirements.txt").read_text(encoding="utf-8")
25
+ for line in content.splitlines():
26
+ line = line.strip()
27
+ if line and not line.startswith("#") and not line.startswith("-"):
28
+ pkg = re.split(r"[<>=!~;#]", line)[0].strip()
29
+ if pkg:
30
+ deps.append(pkg)
31
+ except Exception:
32
+ pass
33
+
34
+ if (root / "pyproject.toml").exists():
35
+ try:
36
+ content = (root / "pyproject.toml").read_text(encoding="utf-8")
37
+ if "fastapi" in content.lower():
38
+ metadata["framework"] = "FastAPI"
39
+ elif "django" in content.lower():
40
+ metadata["framework"] = "Django"
41
+ elif "flask" in content.lower():
42
+ metadata["framework"] = "Flask"
43
+ except Exception:
44
+ pass
45
+
46
+ metadata["key_dependencies"] = deps[:10]
47
+ return "Python", metadata
48
+
49
+ # Node.js / JavaScript / TypeScript
50
+ if (root / "package.json").exists():
51
+ try:
52
+ pkg_data = json.loads((root / "package.json").read_text(encoding="utf-8"))
53
+ is_ts = (root / "tsconfig.json").exists()
54
+ metadata["language"] = "TypeScript" if is_ts else "JavaScript"
55
+
56
+ all_deps = {
57
+ **pkg_data.get("dependencies", {}),
58
+ **pkg_data.get("devDependencies", {}),
59
+ }
60
+
61
+ if "next" in all_deps:
62
+ metadata["framework"] = "Next.js"
63
+ elif "react" in all_deps:
64
+ metadata["framework"] = "React"
65
+ elif "vue" in all_deps:
66
+ metadata["framework"] = "Vue"
67
+ elif "express" in all_deps:
68
+ metadata["framework"] = "Express"
69
+ elif "@nestjs/core" in all_deps:
70
+ metadata["framework"] = "NestJS"
71
+
72
+ metadata["key_dependencies"] = list(all_deps.keys())[:10]
73
+ metadata["version"] = pkg_data.get("version", "0.1.0")
74
+ except Exception:
75
+ metadata["language"] = "JavaScript/TypeScript"
76
+
77
+ return metadata["language"], metadata
78
+
79
+ # Rust
80
+ if (root / "Cargo.toml").exists():
81
+ metadata["language"] = "Rust"
82
+ metadata["framework"] = "Cargo Project"
83
+ return "Rust", metadata
84
+
85
+ # Go
86
+ if (root / "go.mod").exists():
87
+ metadata["language"] = "Go"
88
+ metadata["framework"] = "Go Module"
89
+ return "Go", metadata
90
+
91
+ # Java / Kotlin
92
+ if (root / "pom.xml").exists() or (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
93
+ metadata["language"] = "Java/Kotlin"
94
+ metadata["framework"] = "Maven/Gradle"
95
+ return "Java/Kotlin", metadata
96
+
97
+ # C# / .NET
98
+ if list(root.glob("*.csproj")) or list(root.glob("*.sln")):
99
+ metadata["language"] = "C#"
100
+ metadata["framework"] = ".NET Project"
101
+ return "C#", metadata
102
+
103
+ # Ruby
104
+ if (root / "Gemfile").exists():
105
+ metadata["language"] = "Ruby"
106
+ return "Ruby", metadata
107
+
108
+ # PHP
109
+ if (root / "composer.json").exists():
110
+ metadata["language"] = "PHP"
111
+ return "PHP", metadata
112
+
113
+ return "Generic", metadata
ctx_tool/exporter.py ADDED
@@ -0,0 +1,176 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional, Tuple
4
+ import pyperclip
5
+
6
+ from ctx_tool.config import find_ctx_root, get_ctx_dir, load_config
7
+ from ctx_tool.detector import detect_project_type
8
+ from ctx_tool.git_utils import get_branch, get_git_diff, get_git_status, get_recent_commits, get_repo
9
+ from ctx_tool.session import get_entries
10
+ from ctx_tool.templates import get_template_preamble
11
+ from ctx_tool.tree import generate_tree
12
+
13
+
14
+ def export_context(
15
+ template_name: str = "generic",
16
+ root_path: Optional[Path] = None,
17
+ save_to_file: bool = True,
18
+ copy_to_clipboard: bool = True,
19
+ ) -> Tuple[str, Optional[Path], bool]:
20
+ """Generate handoff markdown snapshot, optionally save file and copy to clipboard.
21
+
22
+ Returns:
23
+ (markdown_text, output_file_path, clipboard_success_boolean)
24
+ """
25
+ root = find_ctx_root(root_path) or Path.cwd()
26
+ config = load_config(root)
27
+
28
+ proj_name = config.get("project_name", root.name)
29
+ proj_type, meta = detect_project_type(root)
30
+ template_key = template_name or config.get("template", "generic")
31
+
32
+ repo = get_repo(root)
33
+ branch = get_branch(repo)
34
+ commits = get_recent_commits(repo, limit=5)
35
+ git_status = get_git_status(repo)
36
+ diff_text = get_git_diff(repo, max_lines=config.get("max_diff_lines", 300))
37
+
38
+ modified_files = set(git_status["staged"] + git_status["unstaged"])
39
+ tree_text = generate_tree(
40
+ root_path=root,
41
+ max_depth=config.get("max_tree_depth", 4),
42
+ ignore_patterns=config.get("ignore_patterns", None),
43
+ modified_files=modified_files,
44
+ )
45
+
46
+ log_entries = get_entries(root)
47
+ preamble = get_template_preamble(template_key)
48
+
49
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
50
+
51
+ # Build Markdown
52
+ parts = []
53
+ parts.append(f"# 🔄 Context Handoff — {proj_name}\n")
54
+ parts.append(f"> **Generated at**: {now_str}\n")
55
+ parts.append(f"> {preamble}\n")
56
+
57
+ # Session Progress
58
+ parts.append("## 📋 Session Progress Log")
59
+ if log_entries:
60
+ for entry in log_entries:
61
+ parts.append(entry)
62
+ else:
63
+ parts.append("*No progress entries recorded for this session yet. (Use `ctx log \"...\"` to add notes)*")
64
+ parts.append("")
65
+
66
+ # Git State
67
+ parts.append("## 🌿 Git State")
68
+ parts.append(f"- **Branch**: `{branch}`")
69
+ if git_status["staged"]:
70
+ parts.append(f"- **Staged Files**: {', '.join(f'`{f}`' for f in git_status['staged'][:5])}")
71
+ if git_status["unstaged"]:
72
+ parts.append(f"- **Unstaged Files**: {', '.join(f'`{f}`' for f in git_status['unstaged'][:5])}")
73
+ if git_status["untracked"]:
74
+ parts.append(f"- **Untracked Files**: {', '.join(f'`{f}`' for f in git_status['untracked'][:5])}")
75
+
76
+ if commits:
77
+ parts.append("\n**Recent Commits**:")
78
+ for c in commits:
79
+ parts.append(f"- `{c['hexsha']}` — {c['message']}")
80
+ parts.append("")
81
+
82
+ # Git Diff
83
+ parts.append("## 📝 Code Changes (git diff)")
84
+ parts.append("```diff")
85
+ parts.append(diff_text)
86
+ parts.append("```\n")
87
+
88
+ # File Tree
89
+ parts.append("## 📁 Project Structure (`*` = modified)")
90
+ parts.append("```")
91
+ parts.append(tree_text)
92
+ parts.append("```\n")
93
+
94
+ # Metadata
95
+ parts.append("## 🔧 Project Info")
96
+ parts.append(f"- **Type**: {proj_type}")
97
+ if meta.get("framework") != "Generic":
98
+ parts.append(f"- **Framework**: {meta.get('framework')}")
99
+ if meta.get("key_dependencies"):
100
+ parts.append(f"- **Key Dependencies**: {', '.join(meta['key_dependencies'][:8])}")
101
+
102
+ markdown_content = "\n".join(parts)
103
+
104
+ # Save to file
105
+ out_file = None
106
+ if save_to_file:
107
+ ctx_dir = get_ctx_dir(root)
108
+ exports_dir = ctx_dir / "exports"
109
+ exports_dir.mkdir(exist_ok=True)
110
+ filename = f"handoff_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
111
+ out_file = exports_dir / filename
112
+ try:
113
+ out_file.write_text(markdown_content, encoding="utf-8")
114
+ except Exception:
115
+ out_file = None
116
+
117
+ # Copy to clipboard
118
+ clipboard_ok = False
119
+ if copy_to_clipboard:
120
+ try:
121
+ pyperclip.copy(markdown_content)
122
+ clipboard_ok = True
123
+ except Exception:
124
+ clipboard_ok = False
125
+
126
+ return markdown_content, out_file, clipboard_ok
127
+
128
+
129
+ def export_chat_context(
130
+ goal: str = "",
131
+ code_or_context: str = "",
132
+ next_task: str = "",
133
+ template_name: str = "generic",
134
+ copy_to_clipboard: bool = True,
135
+ ) -> Tuple[str, bool]:
136
+ """Generate web chat handoff prompt, formatted for AI assistants.
137
+
138
+ Returns:
139
+ (markdown_text, clipboard_success_boolean)
140
+ """
141
+ preamble = get_template_preamble(template_name)
142
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
143
+
144
+ parts = []
145
+ parts.append("# 🌐 Web Chat Session Handoff\n")
146
+ parts.append(f"> **Generated at**: {now_str}\n")
147
+ parts.append(f"> {preamble}\n")
148
+
149
+ if goal.strip():
150
+ parts.append("## 🎯 Project / Task Goal")
151
+ parts.append(goal.strip())
152
+ parts.append("")
153
+
154
+ if code_or_context.strip():
155
+ parts.append("## 💻 Context & Code Snapshot")
156
+ parts.append("```")
157
+ parts.append(code_or_context.strip())
158
+ parts.append("```\n")
159
+
160
+ if next_task.strip():
161
+ parts.append("## 📍 Next Task / Where We Left Off")
162
+ parts.append(next_task.strip())
163
+ parts.append("")
164
+
165
+ markdown_content = "\n".join(parts)
166
+
167
+ clipboard_ok = False
168
+ if copy_to_clipboard:
169
+ try:
170
+ pyperclip.copy(markdown_content)
171
+ clipboard_ok = True
172
+ except Exception:
173
+ clipboard_ok = False
174
+
175
+ return markdown_content, clipboard_ok
176
+
ctx_tool/git_utils.py ADDED
@@ -0,0 +1,140 @@
1
+ import fnmatch
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Optional
4
+ import git
5
+
6
+ SENSITIVE_PATTERNS = [
7
+ "*.env*",
8
+ "*.pem",
9
+ "*.key",
10
+ "id_rsa*",
11
+ "id_ed25519*",
12
+ "*credentials*.json",
13
+ "*secret*",
14
+ ]
15
+
16
+
17
+ def is_sensitive_file(filename: str) -> bool:
18
+ """Check if file matches sensitive pattern list."""
19
+ name = Path(filename).name.lower()
20
+ return any(fnmatch.fnmatch(name, pat.lower()) for pat in SENSITIVE_PATTERNS)
21
+
22
+
23
+ def get_repo(path: Optional[Path] = None) -> Optional[git.Repo]:
24
+ """Get GitPython Repo object or None if not a git repo."""
25
+ try:
26
+ start_path = path or Path.cwd()
27
+ return git.Repo(start_path, search_parent_directories=True)
28
+ except (git.InvalidGitRepositoryError, git.NoSuchPathError):
29
+ return None
30
+
31
+
32
+ def is_git_repo(path: Optional[Path] = None) -> bool:
33
+ """Check if given path is inside a git repository."""
34
+ return get_repo(path) is not None
35
+
36
+
37
+ def get_branch(repo: Optional[git.Repo]) -> str:
38
+ """Get active branch name or 'HEAD detached'."""
39
+ if not repo:
40
+ return "N/A (Not a git repository)"
41
+ try:
42
+ return repo.active_branch.name
43
+ except TypeError:
44
+ return "HEAD (detached)"
45
+ except Exception:
46
+ return "unknown"
47
+
48
+
49
+ def get_git_status(repo: Optional[git.Repo]) -> Dict[str, List[str]]:
50
+ """Return dictionary of staged, unstaged, untracked, and deleted files."""
51
+ if not repo:
52
+ return {"staged": [], "unstaged": [], "untracked": [], "deleted": []}
53
+
54
+ staged = []
55
+ unstaged = []
56
+ deleted = []
57
+
58
+ try:
59
+ # Diff between HEAD and index (staged)
60
+ if repo.head.is_valid():
61
+ staged_diffs = repo.index.diff(repo.head.commit)
62
+ staged = [d.a_path for d in staged_diffs if d.a_path]
63
+ else:
64
+ staged_diffs = repo.index.diff(git.NULL_TREE)
65
+ staged = [d.a_path for d in staged_diffs if d.a_path]
66
+
67
+ # Diff between index and working tree (unstaged)
68
+ unstaged_diffs = repo.index.diff(None)
69
+ for d in unstaged_diffs:
70
+ if d.deleted_file and d.a_path:
71
+ deleted.append(d.a_path)
72
+ elif d.a_path:
73
+ unstaged.append(d.a_path)
74
+ untracked = list(repo.untracked_files or [])
75
+ except Exception:
76
+ pass
77
+
78
+ return {
79
+ "staged": [f for f in set(staged) if not is_sensitive_file(f)],
80
+ "unstaged": [f for f in set(unstaged) if not is_sensitive_file(f)],
81
+ "untracked": [f for f in untracked if not is_sensitive_file(f)],
82
+ "deleted": [f for f in set(deleted) if not is_sensitive_file(f)],
83
+ }
84
+
85
+
86
+ def get_git_diff(repo: Optional[git.Repo], max_lines: int = 300) -> str:
87
+ """Get working tree git diff (staged + unstaged), truncated if larger than max_lines."""
88
+ if not repo:
89
+ return ""
90
+
91
+ diff_text = ""
92
+ try:
93
+ # Unstaged diff
94
+ diff_text += repo.git.diff()
95
+ # Staged diff
96
+ if repo.head.is_valid():
97
+ staged_diff = repo.git.diff("--cached")
98
+ if staged_diff:
99
+ if diff_text:
100
+ diff_text += "\n\n# --- Staged Changes ---\n\n"
101
+ diff_text += staged_diff
102
+ except Exception:
103
+ return ""
104
+
105
+ if not diff_text.strip():
106
+ return "No uncommitted code changes detected."
107
+
108
+ lines = diff_text.splitlines()
109
+ if len(lines) > max_lines:
110
+ truncated_lines = lines[:max_lines]
111
+ truncated_lines.append(
112
+ f"\n... [Diff truncated: {len(lines) - max_lines} more lines omitted for length. Use `git diff` for full diff]"
113
+ )
114
+ return "\n".join(truncated_lines)
115
+
116
+ return diff_text
117
+
118
+
119
+ def get_recent_commits(repo: Optional[git.Repo], limit: int = 5) -> List[Dict[str, str]]:
120
+ """Return list of recent commit dictionaries."""
121
+ if not repo:
122
+ return []
123
+
124
+ commits = []
125
+ try:
126
+ if not repo.head.is_valid():
127
+ return []
128
+ for commit in repo.iter_commits(max_count=limit):
129
+ commits.append(
130
+ {
131
+ "hexsha": commit.hexsha[:7],
132
+ "message": commit.message.split("\n")[0],
133
+ "author": str(commit.author),
134
+ "committed_date": str(commit.committed_datetime),
135
+ }
136
+ )
137
+ except Exception:
138
+ pass
139
+
140
+ return commits
ctx_tool/session.py ADDED
@@ -0,0 +1,59 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import List, Optional
4
+ from ctx_tool.config import find_ctx_root, get_ctx_dir
5
+
6
+
7
+ def get_progress_file(root: Optional[Path] = None) -> Path:
8
+ """Get path to .ctx/progress.md."""
9
+ ctx_dir = get_ctx_dir(root)
10
+ return ctx_dir / "progress.md"
11
+
12
+
13
+ def log_entry(message: str, root: Optional[Path] = None) -> bool:
14
+ """Append a timestamped log entry to progress.md."""
15
+ progress_file = get_progress_file(root)
16
+ if not progress_file.parent.exists():
17
+ return False
18
+
19
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
20
+ entry = f"- **[{timestamp}]** {message}\n"
21
+
22
+ try:
23
+ if not progress_file.exists():
24
+ with open(progress_file, "w", encoding="utf-8") as f:
25
+ f.write("# Session Progress Log\n\n")
26
+
27
+ with open(progress_file, "a", encoding="utf-8") as f:
28
+ f.write(entry)
29
+ return True
30
+ except Exception:
31
+ return False
32
+
33
+
34
+ def get_entries(root: Optional[Path] = None) -> List[str]:
35
+ """Read all log entries from progress.md."""
36
+ progress_file = get_progress_file(root)
37
+ if not progress_file.exists():
38
+ return []
39
+
40
+ try:
41
+ lines = progress_file.read_text(encoding="utf-8").splitlines()
42
+ entries = [line.strip() for line in lines if line.strip().startswith("- **[")]
43
+ return entries
44
+ except Exception:
45
+ return []
46
+
47
+
48
+ def reset_session(root: Optional[Path] = None) -> bool:
49
+ """Reset progress.md to clean state."""
50
+ progress_file = get_progress_file(root)
51
+ if not progress_file.parent.exists():
52
+ return False
53
+
54
+ try:
55
+ with open(progress_file, "w", encoding="utf-8") as f:
56
+ f.write("# Session Progress Log\n\n")
57
+ return True
58
+ except Exception:
59
+ return False
ctx_tool/templates.py ADDED
@@ -0,0 +1,20 @@
1
+ from typing import Dict
2
+
3
+ TEMPLATES: Dict[str, str] = {
4
+ "generic": """You are picking up work on this project session. Below is a structured snapshot of the workspace, including progress log, git state, code diffs, and project structure. Read carefully and continue the implementation seamlessly.""",
5
+ "cursor": """[CURSOR HANDOFF SESSION]
6
+ You are Cursor AI assistant. Resume the active coding task using the project context below. Follow project conventions and build upon the recent uncommitted changes and progress logs.""",
7
+ "copilot": """[GITHUB COPILOT HANDOFF]
8
+ System Prompt / Context Handoff:
9
+ Use the provided session progress log, git diff, and project tree to continue answering requests and modifying code.""",
10
+ "claude": """[CLAUDE CODE / CLAUDE SESSION HANDOFF]
11
+ You are Claude assisting in a software development task.
12
+ Below is the current workspace state. Use this information to understand where the task currently stands, what changes were made, and what to work on next.""",
13
+ "chatgpt": """[CHATGPT CODE SESSION HANDOFF]
14
+ Below is the full development context snapshot (git branch, diffs, progress logs, and directory tree). Please acknowledge receipt and continue assisting with the next step.""",
15
+ }
16
+
17
+
18
+ def get_template_preamble(template_name: str) -> str:
19
+ """Return template preamble string by name or generic default."""
20
+ return TEMPLATES.get(template_name.lower(), TEMPLATES["generic"])
ctx_tool/tree.py ADDED
@@ -0,0 +1,71 @@
1
+ from pathlib import Path
2
+ from typing import List, Optional, Set
3
+ import pathspec
4
+
5
+
6
+ def load_gitignore(root: Path) -> Optional[pathspec.PathSpec]:
7
+ """Load .gitignore file as pathspec matcher."""
8
+ gitignore_file = root / ".gitignore"
9
+ if gitignore_file.exists():
10
+ try:
11
+ patterns = gitignore_file.read_text(encoding="utf-8").splitlines()
12
+ return pathspec.PathSpec.from_lines("gitignore", patterns)
13
+ except Exception:
14
+ pass
15
+ return None
16
+
17
+
18
+ def generate_tree(
19
+ root_path: Optional[Path] = None,
20
+ max_depth: int = 4,
21
+ ignore_patterns: Optional[List[str]] = None,
22
+ modified_files: Optional[Set[str]] = None,
23
+ ) -> str:
24
+ """Generate ASCII directory tree representation."""
25
+ root = (root_path or Path.cwd()).resolve()
26
+ spec = load_gitignore(root)
27
+ custom_ignores = set(ignore_patterns or [".git", "__pycache__", ".venv", "venv", "node_modules", ".ctx"])
28
+ modified = modified_files or set()
29
+
30
+ lines = [f"{root.name}/"]
31
+
32
+ def _walk(directory: Path, prefix: str = "", depth: int = 1):
33
+ if depth > max_depth:
34
+ return
35
+
36
+ try:
37
+ entries = sorted(list(directory.iterdir()), key=lambda x: (not x.is_dir(), x.name.lower()))
38
+ except PermissionError:
39
+ return
40
+
41
+ filtered_entries = []
42
+ for entry in entries:
43
+ name = entry.name
44
+ if name in custom_ignores:
45
+ continue
46
+
47
+ rel_path_str = str(entry.relative_to(root)).replace("\\", "/")
48
+ if spec and spec.match_file(rel_path_str):
49
+ continue
50
+
51
+ filtered_entries.append(entry)
52
+
53
+ count = len(filtered_entries)
54
+ for i, entry in enumerate(filtered_entries):
55
+ is_last = i == (count - 1)
56
+ connector = "└── " if is_last else "├── "
57
+ rel_path_str = str(entry.relative_to(root)).replace("\\", "/")
58
+
59
+ mod_marker = " *" if rel_path_str in modified else ""
60
+
61
+ if entry.is_dir() and not entry.is_symlink():
62
+ lines.append(f"{prefix}{connector}{entry.name}/{mod_marker}")
63
+ new_prefix = prefix + (" " if is_last else "│ ")
64
+ _walk(entry, new_prefix, depth + 1)
65
+ elif entry.is_dir() and entry.is_symlink():
66
+ lines.append(f"{prefix}{connector}{entry.name}/ (symlink){mod_marker}")
67
+ else:
68
+ lines.append(f"{prefix}{connector}{entry.name}{mod_marker}")
69
+
70
+ _walk(root)
71
+ return "\n".join(lines)