one-ctx 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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """ctx — Combined Context MCP Server."""
ctx/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ import sys
2
+ if sys.platform == 'win32':
3
+ import asyncio
4
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
5
+ import sys
6
+ import anyio
7
+ import click
8
+ from ctx.database import (
9
+ init_project, get_project, reset_project, list_projects,
10
+ search_projects, search_logs,
11
+ )
12
+ from ctx.server import mcp_server
13
+ from ctx.git import get_git_summary
14
+ import mcp.server.stdio
15
+
16
+ @click.group()
17
+ def cli():
18
+ """ctx -- Combined Context MCP Server."""
19
+ pass
20
+
21
+ @cli.command()
22
+ @click.option("--port", default=7337, help="Port to run the server on")
23
+ @click.option("--host", default="0.0.0.0", help="Host to bind to")
24
+ def serve(port, host):
25
+ """Start the HTTP/SSE server."""
26
+ import uvicorn
27
+ print(f"[ctx] server starting on http://{host}:{port}")
28
+ print(f" SSE endpoint: http://localhost:{port}/sse")
29
+ print(f" Messages endpoint: http://localhost:{port}/messages/")
30
+ print(f" Health check: http://localhost:{port}/health\n")
31
+ print("Add to your MCP config:")
32
+ print(f' {{"mcpServers": {{"ctx": {{"url": "http://localhost:{port}/sse"}}}}}}\n')
33
+
34
+ uvicorn.run("ctx.server:app", host=host, port=port, log_level="info")
35
+
36
+ @cli.command()
37
+ def stdio():
38
+ """Start the stdio server (for direct VS Code / Cline / Codex integration)."""
39
+ async def run():
40
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
41
+ await mcp_server.run(
42
+ read_stream,
43
+ write_stream,
44
+ mcp_server.create_initialization_options()
45
+ )
46
+ anyio.run(run)
47
+
48
+ @cli.command()
49
+ @click.argument("project")
50
+ @click.option("--path", default="", help="Path to the project's git repo on disk")
51
+ def init(project, path):
52
+ """Initialize a new project context."""
53
+ result = init_project(project, repo_path=path)
54
+ if "error" in result:
55
+ print(f"[!] {result['error']}")
56
+ return
57
+ print(f"[OK] Project '{project}' initialized.")
58
+ if path:
59
+ print(f" Git repo linked: {path}")
60
+ print(" Start any AI tool and call ctx_get to load context.")
61
+
62
+ @cli.command()
63
+ @click.argument("project", required=False)
64
+ def status(project):
65
+ """View the context for a project, or list all projects."""
66
+ if not project:
67
+ projects = list_projects()
68
+ if not projects:
69
+ print("No projects found.")
70
+ return
71
+ print("=== Tracked Projects ===")
72
+ for p in projects:
73
+ print(f"- {p['project']} (last updated: {p['updated_at']})")
74
+ return
75
+
76
+ data = get_project(project)
77
+ if "error" in data:
78
+ print(f"Error: {data['error']}")
79
+ return
80
+
81
+ print(f"=== {project} ===")
82
+ print(f"\n[WHAT]\n{data.get('what', '(empty)') or '(empty)'}")
83
+ print(f"\n[DONE]\n{data.get('done', '(empty)') or '(empty)'}")
84
+ print(f"\n[NOW]\n{data.get('now', '(empty)') or '(empty)'}")
85
+ print(f"\n[MAP]\n{data.get('map', '(empty)') or '(no files mapped)'}")
86
+
87
+ # Show git info if repo_path is set
88
+ repo_path = data.get("repo_path", "")
89
+ if repo_path:
90
+ print(f"\n[GIT] repo: {repo_path}")
91
+ git_info = get_git_summary(repo_path)
92
+ if git_info:
93
+ print(f" Branch: {git_info['branch']}")
94
+ if git_info.get("recent_commits"):
95
+ print(f" Recent commits:")
96
+ for c in git_info["recent_commits"][:3]:
97
+ print(f" {c['hash']} {c['message']}")
98
+ changed = git_info.get("changed_files", {})
99
+ total_changes = len(changed.get("staged", [])) + len(changed.get("unstaged", [])) + len(changed.get("untracked", []))
100
+ if total_changes:
101
+ print(f" Changed files: {total_changes}")
102
+ else:
103
+ print(" (git not available or not a repo)")
104
+
105
+ @cli.command()
106
+ @click.argument("project")
107
+ def reset(project):
108
+ """Reset a project's context back to empty."""
109
+ reset_project(project)
110
+ print(f"[OK] Project '{project}' has been reset to empty.")
111
+
112
+ @cli.command(name="list")
113
+ def list_cmd():
114
+ """List all tracked projects."""
115
+ projects = list_projects()
116
+ if not projects:
117
+ print("No projects found.")
118
+ return
119
+ print("=== Tracked Projects ===")
120
+ for p in projects:
121
+ print(f"- {p['project']} (last updated: {p['updated_at']})")
122
+
123
+ @cli.command()
124
+ @click.argument("project")
125
+ def delete(project):
126
+ """Permanently delete a project."""
127
+ from ctx.database import delete_project
128
+ result = delete_project(project)
129
+ if "error" in result:
130
+ print(f"[!] {result['error']}")
131
+ else:
132
+ print(f"[OK] Project '{project}' deleted.")
133
+
134
+ @cli.command()
135
+ @click.argument("query")
136
+ def search(query):
137
+ """Search across all projects' context and history."""
138
+ project_matches = search_projects(query)
139
+ log_matches = search_logs(query)
140
+
141
+ if not project_matches and not log_matches:
142
+ print(f"No results found for '{query}'.")
143
+ return
144
+
145
+ if project_matches:
146
+ print(f"=== Projects matching '{query}' ===")
147
+ for m in project_matches:
148
+ buckets = ", ".join(m["matched_buckets"])
149
+ print(f"- {m['project']} (found in: {buckets})")
150
+
151
+ if log_matches:
152
+ print(f"\n=== History matching '{query}' ===")
153
+ for m in log_matches:
154
+ print(f"- [{m['project']}] [{m['tool_name']} @ {m['timestamp'][:16]}] {m['summary'][:100]}")
155
+
156
+ print(f"\nTotal: {len(project_matches)} projects, {len(log_matches)} history entries")
157
+
158
+ if __name__ == "__main__":
159
+ cli()
ctx/database.py ADDED
@@ -0,0 +1,292 @@
1
+ """SQLite storage layer for ctx.
2
+
3
+ Stores project context in four buckets: WHAT, DONE, NOW, MAP.
4
+ Everything is local — one .db file per installation.
5
+ """
6
+
7
+ import sqlite3
8
+ import os
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+
14
+ # Default DB location: ~/.ctx/ctx.db
15
+ DEFAULT_DB_DIR = Path.home() / ".ctx"
16
+ DEFAULT_DB_PATH = DEFAULT_DB_DIR / "ctx.db"
17
+
18
+
19
+ def _get_db_path() -> Path:
20
+ """Get the database path, respecting CTX_DB_PATH env var."""
21
+ custom = os.environ.get("CTX_DB_PATH")
22
+ if custom:
23
+ return Path(custom)
24
+ return DEFAULT_DB_PATH
25
+
26
+
27
+ def get_connection() -> sqlite3.Connection:
28
+ """Get a SQLite connection, creating the DB and tables if needed."""
29
+ db_path = _get_db_path()
30
+ db_path.parent.mkdir(parents=True, exist_ok=True)
31
+
32
+ conn = sqlite3.connect(str(db_path))
33
+ conn.row_factory = sqlite3.Row
34
+ conn.execute("PRAGMA journal_mode=WAL")
35
+ conn.execute("PRAGMA foreign_keys=ON")
36
+
37
+ conn.execute("""
38
+ CREATE TABLE IF NOT EXISTS projects (
39
+ name TEXT PRIMARY KEY,
40
+ what TEXT NOT NULL DEFAULT '',
41
+ done TEXT NOT NULL DEFAULT '',
42
+ now TEXT NOT NULL DEFAULT '',
43
+ map TEXT NOT NULL DEFAULT '',
44
+ repo_path TEXT NOT NULL DEFAULT '',
45
+ created_at TEXT NOT NULL,
46
+ updated_at TEXT NOT NULL
47
+ )
48
+ """)
49
+
50
+ conn.execute("""
51
+ CREATE TABLE IF NOT EXISTS update_log (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ project TEXT NOT NULL,
54
+ tool_name TEXT NOT NULL DEFAULT 'unknown',
55
+ summary TEXT NOT NULL,
56
+ timestamp TEXT NOT NULL,
57
+ FOREIGN KEY (project) REFERENCES projects(name) ON DELETE CASCADE
58
+ )
59
+ """)
60
+
61
+ # --- Migration: add columns for existing databases ---
62
+ try:
63
+ conn.execute("SELECT map FROM projects LIMIT 1")
64
+ except sqlite3.OperationalError:
65
+ conn.execute("ALTER TABLE projects ADD COLUMN map TEXT NOT NULL DEFAULT ''")
66
+
67
+ try:
68
+ conn.execute("SELECT repo_path FROM projects LIMIT 1")
69
+ except sqlite3.OperationalError:
70
+ conn.execute("ALTER TABLE projects ADD COLUMN repo_path TEXT NOT NULL DEFAULT ''")
71
+
72
+ conn.commit()
73
+ return conn
74
+
75
+
76
+ def init_project(name: str, repo_path: str = "") -> dict:
77
+ """Create a new project entry. Returns the project dict."""
78
+ conn = get_connection()
79
+ now = datetime.now(timezone.utc).isoformat()
80
+ try:
81
+ conn.execute(
82
+ "INSERT INTO projects (name, repo_path, created_at, updated_at) VALUES (?, ?, ?, ?)",
83
+ (name, repo_path, now, now),
84
+ )
85
+ conn.commit()
86
+ except sqlite3.IntegrityError:
87
+ # If project exists and repo_path is being set, update it
88
+ if repo_path:
89
+ conn.execute(
90
+ "UPDATE projects SET repo_path = ?, updated_at = ? WHERE name = ?",
91
+ (repo_path, now, name),
92
+ )
93
+ conn.commit()
94
+ else:
95
+ conn.close()
96
+ return {"error": f"Project '{name}' already exists."}
97
+ result = get_project(name, conn)
98
+ conn.close()
99
+ return result
100
+
101
+
102
+ def get_project(name: str, conn: Optional[sqlite3.Connection] = None) -> dict:
103
+ """Get the full context snapshot for a project."""
104
+ should_close = conn is None
105
+ if conn is None:
106
+ conn = get_connection()
107
+
108
+ row = conn.execute(
109
+ "SELECT * FROM projects WHERE name = ?", (name,)
110
+ ).fetchone()
111
+
112
+ if should_close:
113
+ conn.close()
114
+
115
+ if row is None:
116
+ return {"error": f"Project '{name}' not found."}
117
+
118
+ return {
119
+ "project": row["name"],
120
+ "what": row["what"],
121
+ "done": row["done"],
122
+ "now": row["now"],
123
+ "map": row["map"],
124
+ "repo_path": row["repo_path"],
125
+ "created_at": row["created_at"],
126
+ "updated_at": row["updated_at"],
127
+ }
128
+
129
+
130
+ def update_project(
131
+ name: str, what: str, done: str, now: str, map_: str,
132
+ tool_name: str, summary: str, repo_path: Optional[str] = None,
133
+ ) -> dict:
134
+ """Update a project's context buckets after merge."""
135
+ conn = get_connection()
136
+ ts = datetime.now(timezone.utc).isoformat()
137
+
138
+ if repo_path is not None:
139
+ cursor = conn.execute(
140
+ """UPDATE projects
141
+ SET what = ?, done = ?, now = ?, map = ?, repo_path = ?, updated_at = ?
142
+ WHERE name = ?""",
143
+ (what, done, now, map_, repo_path, ts, name),
144
+ )
145
+ else:
146
+ cursor = conn.execute(
147
+ """UPDATE projects
148
+ SET what = ?, done = ?, now = ?, map = ?, updated_at = ?
149
+ WHERE name = ?""",
150
+ (what, done, now, map_, ts, name),
151
+ )
152
+
153
+ if cursor.rowcount == 0:
154
+ conn.close()
155
+ return {"error": f"Project '{name}' not found. Run `ctx init {name}` first."}
156
+
157
+ # Log the raw update for audit
158
+ conn.execute(
159
+ "INSERT INTO update_log (project, tool_name, summary, timestamp) VALUES (?, ?, ?, ?)",
160
+ (name, tool_name, summary, ts),
161
+ )
162
+
163
+ conn.commit()
164
+ result = get_project(name, conn)
165
+ conn.close()
166
+ return result
167
+
168
+
169
+ def update_project_map(name: str, map_content: str) -> dict:
170
+ """Update only the MAP bucket for a project (for ctx_map tool)."""
171
+ conn = get_connection()
172
+ ts = datetime.now(timezone.utc).isoformat()
173
+
174
+ cursor = conn.execute(
175
+ "UPDATE projects SET map = ?, updated_at = ? WHERE name = ?",
176
+ (map_content, ts, name),
177
+ )
178
+
179
+ if cursor.rowcount == 0:
180
+ conn.close()
181
+ return {"error": f"Project '{name}' not found."}
182
+
183
+ conn.commit()
184
+ result = get_project(name, conn)
185
+ conn.close()
186
+ return result
187
+
188
+
189
+ def reset_project(name: str) -> dict:
190
+ """Wipe a project's context back to empty."""
191
+ conn = get_connection()
192
+ ts = datetime.now(timezone.utc).isoformat()
193
+
194
+ cursor = conn.execute(
195
+ "UPDATE projects SET what = '', done = '', now = '', map = '', updated_at = ? WHERE name = ?",
196
+ (ts, name),
197
+ )
198
+
199
+ if cursor.rowcount == 0:
200
+ conn.close()
201
+ return {"error": f"Project '{name}' not found."}
202
+
203
+ # Also clear the log
204
+ conn.execute("DELETE FROM update_log WHERE project = ?", (name,))
205
+ conn.commit()
206
+ conn.close()
207
+ return {"status": "reset", "project": name}
208
+
209
+
210
+ def list_projects() -> list[dict]:
211
+ """List all known projects with basic info."""
212
+ conn = get_connection()
213
+ rows = conn.execute(
214
+ "SELECT name, updated_at FROM projects ORDER BY updated_at DESC"
215
+ ).fetchall()
216
+ conn.close()
217
+ return [{"project": r["name"], "updated_at": r["updated_at"]} for r in rows]
218
+
219
+
220
+ def delete_project(name: str) -> dict:
221
+ """Permanently delete a project and its logs."""
222
+ conn = get_connection()
223
+ cursor = conn.execute("DELETE FROM projects WHERE name = ?", (name,))
224
+ if cursor.rowcount == 0:
225
+ conn.close()
226
+ return {"error": f"Project '{name}' not found."}
227
+ conn.commit()
228
+ conn.close()
229
+ return {"status": "deleted", "project": name}
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Cross-project search
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def search_projects(query: str) -> list[dict]:
237
+ """Search across all projects' context buckets (what/done/now/map)."""
238
+ conn = get_connection()
239
+ pattern = f"%{query}%"
240
+ rows = conn.execute(
241
+ """SELECT name, what, done, now, map, updated_at FROM projects
242
+ WHERE what LIKE ? OR done LIKE ? OR now LIKE ? OR map LIKE ?
243
+ ORDER BY updated_at DESC""",
244
+ (pattern, pattern, pattern, pattern),
245
+ ).fetchall()
246
+ conn.close()
247
+
248
+ results = []
249
+ for r in rows:
250
+ # Build a list of which buckets matched
251
+ matches = []
252
+ q_lower = query.lower()
253
+ if q_lower in r["what"].lower():
254
+ matches.append("what")
255
+ if q_lower in r["done"].lower():
256
+ matches.append("done")
257
+ if q_lower in r["now"].lower():
258
+ matches.append("now")
259
+ if q_lower in r["map"].lower():
260
+ matches.append("map")
261
+
262
+ results.append({
263
+ "project": r["name"],
264
+ "matched_buckets": matches,
265
+ "updated_at": r["updated_at"],
266
+ })
267
+
268
+ return results
269
+
270
+
271
+ def search_logs(query: str) -> list[dict]:
272
+ """Search across all projects' update history."""
273
+ conn = get_connection()
274
+ pattern = f"%{query}%"
275
+ rows = conn.execute(
276
+ """SELECT project, tool_name, summary, timestamp FROM update_log
277
+ WHERE summary LIKE ?
278
+ ORDER BY timestamp DESC
279
+ LIMIT 20""",
280
+ (pattern,),
281
+ ).fetchall()
282
+ conn.close()
283
+
284
+ return [
285
+ {
286
+ "project": r["project"],
287
+ "tool_name": r["tool_name"],
288
+ "summary": r["summary"],
289
+ "timestamp": r["timestamp"],
290
+ }
291
+ for r in rows
292
+ ]
ctx/git.py ADDED
@@ -0,0 +1,106 @@
1
+ """Git integration for ctx — Combined Context.
2
+
3
+ Detects branch, recent commits, and changed files for a project's repo.
4
+ All functions return None gracefully if git is unavailable or the path
5
+ is not a git repository. Zero dependencies — uses subprocess only.
6
+ """
7
+
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+
13
+ def _run_git(repo_path: str, *args: str, timeout: int = 5) -> Optional[str]:
14
+ """Run a git command in the given repo, return stdout or None on failure."""
15
+ try:
16
+ result = subprocess.run(
17
+ ["git", *args],
18
+ cwd=repo_path,
19
+ capture_output=True,
20
+ text=True,
21
+ timeout=timeout,
22
+ )
23
+ if result.returncode == 0:
24
+ return result.stdout.strip()
25
+ return None
26
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
27
+ return None
28
+
29
+
30
+ def is_git_repo(repo_path: str) -> bool:
31
+ """Check if the given path is inside a git repository."""
32
+ return _run_git(repo_path, "rev-parse", "--git-dir") is not None
33
+
34
+
35
+ def get_branch(repo_path: str) -> Optional[str]:
36
+ """Get the current branch name (e.g. 'main', 'feature-auth')."""
37
+ return _run_git(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
38
+
39
+
40
+ def get_recent_commits(repo_path: str, n: int = 5) -> Optional[list[dict]]:
41
+ """Get the last N commit messages with hash and author.
42
+
43
+ Returns a list of dicts: [{"hash": "abc123", "author": "name", "message": "..."}]
44
+ """
45
+ output = _run_git(
46
+ repo_path, "log", f"-{n}",
47
+ "--format=%h|%an|%s",
48
+ "--no-merges",
49
+ )
50
+ if output is None:
51
+ return None
52
+
53
+ commits = []
54
+ for line in output.splitlines():
55
+ parts = line.split("|", 2)
56
+ if len(parts) == 3:
57
+ commits.append({
58
+ "hash": parts[0],
59
+ "author": parts[1],
60
+ "message": parts[2],
61
+ })
62
+ return commits
63
+
64
+
65
+ def get_changed_files(repo_path: str) -> Optional[dict]:
66
+ """Get uncommitted changes (staged + unstaged + untracked).
67
+
68
+ Returns: {"staged": [...], "unstaged": [...], "untracked": [...]}
69
+ """
70
+ staged = _run_git(repo_path, "diff", "--cached", "--name-only")
71
+ unstaged = _run_git(repo_path, "diff", "--name-only")
72
+ untracked = _run_git(repo_path, "ls-files", "--others", "--exclude-standard")
73
+
74
+ if staged is None and unstaged is None and untracked is None:
75
+ return None
76
+
77
+ return {
78
+ "staged": [f for f in (staged or "").splitlines() if f],
79
+ "unstaged": [f for f in (unstaged or "").splitlines() if f],
80
+ "untracked": [f for f in (untracked or "").splitlines() if f],
81
+ }
82
+
83
+
84
+ def get_git_summary(repo_path: str) -> Optional[dict]:
85
+ """Get a complete git status summary for a repo.
86
+
87
+ Returns None if the path is not a git repo.
88
+ Otherwise returns: {
89
+ "branch": "main",
90
+ "recent_commits": [...],
91
+ "changed_files": {...},
92
+ }
93
+ """
94
+ if not repo_path or not Path(repo_path).exists():
95
+ return None
96
+
97
+ if not is_git_repo(repo_path):
98
+ return None
99
+
100
+ return {
101
+ "branch": get_branch(repo_path) or "unknown",
102
+ "recent_commits": get_recent_commits(repo_path, n=5) or [],
103
+ "changed_files": get_changed_files(repo_path) or {
104
+ "staged": [], "unstaged": [], "untracked": []
105
+ },
106
+ }