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.
- ctx_bridge-0.1.0.dist-info/METADATA +262 -0
- ctx_bridge-0.1.0.dist-info/RECORD +16 -0
- ctx_bridge-0.1.0.dist-info/WHEEL +5 -0
- ctx_bridge-0.1.0.dist-info/entry_points.txt +3 -0
- ctx_bridge-0.1.0.dist-info/licenses/LICENSE +21 -0
- ctx_bridge-0.1.0.dist-info/top_level.txt +1 -0
- ctx_tool/__init__.py +2 -0
- ctx_tool/__main__.py +4 -0
- ctx_tool/cli.py +235 -0
- ctx_tool/config.py +102 -0
- ctx_tool/detector.py +113 -0
- ctx_tool/exporter.py +176 -0
- ctx_tool/git_utils.py +140 -0
- ctx_tool/session.py +59 -0
- ctx_tool/templates.py +20 -0
- ctx_tool/tree.py +71 -0
|
@@ -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
|
+
[](https://opensource.org/licenses/MIT)
|
|
24
|
+
[](https://www.python.org/downloads/)
|
|
25
|
+
[](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,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
ctx_tool/__main__.py
ADDED
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)
|