contexta-ai 2.0.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,299 @@
1
+ Metadata-Version: 2.4
2
+ Name: contexta-ai
3
+ Version: 2.0.0
4
+ Summary: AI-native codebase context engine. Scans projects and serves intelligent context packs to Claude Code, Cursor, Windsurf, and any MCP-compatible tool.
5
+ Project-URL: Homepage, https://github.com/pablokaua03/contexta
6
+ Project-URL: Repository, https://github.com/pablokaua03/contexta
7
+ Author-email: Pablo Kaua <help@spacegroupusa.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,claude,codebase,context,cursor,developer-tools,mcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: charset-normalizer<4,>=3.4
20
+ Requires-Dist: mcp[cli]<2,>=1.9
21
+ Requires-Dist: pathspec<1.1,>=0.12
22
+ Requires-Dist: rapidfuzz<4,>=3.14
23
+ Requires-Dist: tiktoken<1,>=0.12
24
+ Requires-Dist: tree-sitter-language-pack<2,>=1.6
25
+ Requires-Dist: tree-sitter<0.26,>=0.25
26
+ Provides-Extra: all-ai
27
+ Requires-Dist: anthropic<1,>=0.52; extra == 'all-ai'
28
+ Requires-Dist: google-genai<2,>=1.14; extra == 'all-ai'
29
+ Requires-Dist: openai<2,>=1.82; extra == 'all-ai'
30
+ Provides-Extra: claude
31
+ Requires-Dist: anthropic<1,>=0.52; extra == 'claude'
32
+ Provides-Extra: gemini
33
+ Requires-Dist: google-genai<2,>=1.14; extra == 'gemini'
34
+ Provides-Extra: gui
35
+ Provides-Extra: openai
36
+ Requires-Dist: openai<2,>=1.82; extra == 'openai'
37
+ Description-Content-Type: text/markdown
38
+
39
+ <div align="center">
40
+
41
+ # Contexta
42
+
43
+ **AI-native codebase context engine. Serves intelligent project context to Claude Code, Cursor, Windsurf, and any MCP-compatible tool — or generates curated context packs for manual use.**
44
+
45
+ [![Python](https://img.shields.io/badge/Python-3.11%2B-blue?logo=python&logoColor=white)](https://python.org)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
47
+ [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]()
48
+ [![Version](https://img.shields.io/badge/Version-2.0.0-purple)]()
49
+ [![MCP](https://img.shields.io/badge/MCP-Compatible-orange)]()
50
+
51
+ <br>
52
+
53
+ [<img src="https://img.shields.io/badge/pip%20install%20contexta--ai-3776AB?style=for-the-badge&logo=pypi&logoColor=white" height="42">](https://pypi.org/project/contexta-ai/)
54
+ &nbsp;&nbsp;
55
+ [<img src="https://img.shields.io/badge/Download%20for%20Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" height="42">](https://github.com/pablokaua03/Contexta/releases/latest/download/contexta.exe)
56
+ &nbsp;&nbsp;
57
+ [<img src="https://img.shields.io/badge/Download%20for%20Linux-E95420?style=for-the-badge&logo=linux&logoColor=white" height="42">](https://github.com/pablokaua03/Contexta/releases/latest/download/contexta-linux.tar.gz)
58
+
59
+ > Install via pip, download the portable executable, or run from source.
60
+
61
+ <br>
62
+
63
+ <picture>
64
+ <source media="(prefers-color-scheme: dark)" srcset="assets/dark.png">
65
+ <source media="(prefers-color-scheme: light)" srcset="assets/white.png">
66
+ <img alt="Contexta interface preview" src="assets/dark.png">
67
+ </picture>
68
+
69
+ </div>
70
+
71
+ ---
72
+
73
+ ## What is Contexta
74
+
75
+ Contexta analyzes codebases and serves intelligent, curated context to AI tools. Instead of dumping files blindly, it understands project structure, detects frameworks, ranks files by importance, and delivers exactly what an AI needs.
76
+
77
+ **Three ways to use it:**
78
+
79
+ | Mode | Command | What it does |
80
+ |---|---|---|
81
+ | **MCP Server** | `contexta serve` | Runs as a tool server — Claude Code, Cursor, Windsurf call it automatically |
82
+ | **CLI** | `contexta ./project --pack onboarding` | Generates a context pack Markdown file |
83
+ | **GUI** | `contexta` | Desktop app with visual controls |
84
+
85
+ ---
86
+
87
+ ## MCP Server (recommended)
88
+
89
+ The MCP server is the primary way to use Contexta. AI coding assistants call it as a tool to understand your project — no copy-paste needed.
90
+
91
+ ### Setup
92
+
93
+ Add to your MCP configuration (Claude Code, Cursor, Windsurf, etc.):
94
+
95
+ ```json
96
+ {
97
+ "mcpServers": {
98
+ "contexta": {
99
+ "command": "contexta",
100
+ "args": ["serve"]
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ Or if running from source:
107
+
108
+ ```json
109
+ {
110
+ "mcpServers": {
111
+ "contexta": {
112
+ "command": "python",
113
+ "args": ["/path/to/contexta_mcp.py"]
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ ### Available tools
120
+
121
+ | Tool | What it does |
122
+ |---|---|
123
+ | `scan_project` | Quick fingerprint — type, language, frameworks, deps, entry points |
124
+ | `get_architecture` | Module relationships, folder structure, risks, key patterns |
125
+ | `generate_context` | Full AI-optimized context pack with all preset options |
126
+ | `find_files` | Search files by name, extension, or keyword |
127
+ | `read_files` | Read specific file contents |
128
+ | `list_packs` | List available pack presets |
129
+ | `cache_status` | Show cache hit/miss statistics |
130
+ | `refresh_cache` | Force-refresh cached analysis |
131
+
132
+ ### Smart cache
133
+
134
+ Results are cached in memory with automatic invalidation. First call analyzes the project (~700ms), subsequent calls return in ~2ms until files change on disk. The cache monitors file mtimes and manifest files — when you edit code, the next call recomputes automatically.
135
+
136
+ ---
137
+
138
+ ## Install
139
+
140
+ ### Option A: pip (recommended)
141
+
142
+ ```bash
143
+ pip install contexta-ai
144
+ ```
145
+
146
+ Optional AI API integrations:
147
+
148
+ ```bash
149
+ pip install contexta-ai[claude] # Claude API
150
+ pip install contexta-ai[gemini] # Gemini API
151
+ pip install contexta-ai[openai] # OpenAI API
152
+ pip install contexta-ai[all-ai] # All three
153
+ ```
154
+
155
+ ### Option B: Portable executables
156
+
157
+ - **Windows**: download [`contexta.exe`](https://github.com/pablokaua03/Contexta/releases/latest/download/contexta.exe)
158
+ - **Linux**: download [`contexta-linux.tar.gz`](https://github.com/pablokaua03/Contexta/releases/latest/download/contexta-linux.tar.gz)
159
+
160
+ ### Option C: From source
161
+
162
+ ```bash
163
+ git clone https://github.com/pablokaua03/Contexta.git
164
+ cd Contexta
165
+ pip install -r requirements.txt
166
+ python contexta.py
167
+ ```
168
+
169
+ ---
170
+
171
+ ## CLI usage
172
+
173
+ ```bash
174
+ # Generate context packs
175
+ contexta ./project # default pack
176
+ contexta ./project --pack onboarding # understand a new codebase
177
+ contexta ./project --pack raw_files # clean path + content dump
178
+ contexta ./project --pack pr_review --diff --copy # review changes
179
+ contexta ./project --mode debug --focus "auth flow" # debug a specific area
180
+
181
+ # AI API integration — send context + question directly to an AI
182
+ contexta ./project --ask "what are the main security risks?"
183
+ contexta ./project --ask "explain the architecture" --provider claude
184
+
185
+ # Configure AI API keys
186
+ contexta --configure-ai
187
+ ```
188
+
189
+ ### CLI flags
190
+
191
+ | Flag | Description |
192
+ |---|---|
193
+ | `--pack` | Preset: `custom`, `chatgpt`, `onboarding`, `pr_review`, `risk_review`, `debug`, `backend`, `frontend`, `changes_related`, `raw_files` |
194
+ | `--mode` | Context mode: `full`, `debug`, `feature`, `diff`, `onboarding`, `refactor` |
195
+ | `--compression` | `full`, `balanced`, `focused`, `signatures`, `lean` |
196
+ | `--ai` | Target AI profile: `generic`, `chatgpt`, `claude`, `gemini`, `copilot` |
197
+ | `--task` | Task profile: `general`, `ai_handoff`, `bug_report`, `code_review`, `explain_project`, `risk_analysis`, `refactor_request`, `pr_summary`, `write_tests`, `find_dead_code` |
198
+ | `--focus` | Bias scoring around a topic (e.g. `"auth flow"`, `"database"`) |
199
+ | `--diff` / `--staged` | Use git changes as context |
200
+ | `--ask` | Send context pack + prompt to an AI API |
201
+ | `--provider` | AI provider: `claude`, `gemini`, `openai` |
202
+ | `--api-key` | API key (also reads `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `OPENAI_API_KEY`) |
203
+ | `--configure-ai` | Interactive AI API setup |
204
+ | `-c` / `--copy` | Copy output to clipboard |
205
+ | `-o` / `--output` | Custom output path |
206
+
207
+ ---
208
+
209
+ ## Context packs
210
+
211
+ | Pack | Best for |
212
+ |---|---|
213
+ | `onboarding` | Understanding a new codebase fast |
214
+ | `pr_review` | Code review with change context |
215
+ | `risk_review` | Regression hotspots, missing coverage, broad-impact modules |
216
+ | `debug` | Bug hunting with changed/suspicious files prioritized |
217
+ | `backend` / `frontend` | Bias toward one side of the app |
218
+ | `changes_related` | Git changes + nearby relevant files |
219
+ | `raw_files` | Clean dump of important files — path + full content, no analysis |
220
+ | `chatgpt` | General-purpose ChatGPT preset |
221
+ | `custom` | Full manual control |
222
+
223
+ ---
224
+
225
+ ## What Contexta exports
226
+
227
+ Depending on pack, mode, and task, the output can include:
228
+
229
+ - Project summary with detected technologies, entry points, purpose, and central modules
230
+ - Read-this-first path through the repository
231
+ - Main execution flow narrative
232
+ - Core files, supporting files, related tests, and changed-file context
233
+ - Relationship maps and risk notes
234
+ - Score breakdown explaining why each file was selected
235
+ - Curated Markdown payload ready for any AI tool
236
+
237
+ ---
238
+
239
+ ## Main features
240
+
241
+ | Feature | Detail |
242
+ |---|---|
243
+ | **MCP Server** | `contexta serve` — AI tools call it as a tool server with smart caching |
244
+ | **AI API integration** | `--ask` sends context directly to Claude, Gemini, or OpenAI |
245
+ | **Smart cache** | ~2ms cached responses with automatic mtime-based invalidation |
246
+ | **Raw files pack** | Clean path + content dump for simple AI workflows |
247
+ | **GUI + CLI** | Desktop app for visual use, CLI for scripting and automation |
248
+ | **Project fingerprinting** | Detects stack, frameworks, domain, and project type automatically |
249
+ | **Syntax-aware analysis** | tree-sitter + heuristic fallback for symbol extraction |
250
+ | **Multi-language** | Python, JS/TS, Go, Rust, PHP, Java, C#, Kotlin, Swift, C++ and more |
251
+ | **Token guidance** | tiktoken-backed estimates for pack sizing |
252
+ | **Inline blob protection** | Collapses base64/binary literals automatically |
253
+ | **PyPI package** | `pip install contexta-ai` with optional AI extras |
254
+
255
+ ---
256
+
257
+ ## Build from source
258
+
259
+ ```bash
260
+ # Windows (requires Visual Studio C++ Build Tools)
261
+ .\build.bat
262
+
263
+ # Linux / macOS
264
+ chmod +x build.sh && ./build.sh
265
+ ```
266
+
267
+ Build outputs: `dist/contexta.exe` (Windows), `dist/contexta` (Linux/macOS), `dist/contexta-linux.tar.gz` (Linux bundle).
268
+
269
+ ---
270
+
271
+ ## Run tests
272
+
273
+ ```bash
274
+ python -m pytest tests/ -k "not test_relation_score"
275
+ ```
276
+
277
+ ---
278
+
279
+ ## Security and behavior
280
+
281
+ - Read-only: Contexta does not modify the scanned project
282
+ - No runtime telemetry or network requirement (except optional `--ask` AI calls)
283
+ - Scan limits prevent runaway exports
284
+ - Embedded binary/blob payloads are suppressed automatically
285
+ - AI API keys are stored locally in `~/.contexta/ai_config.json` when using `--configure-ai`
286
+
287
+ ---
288
+
289
+ ## Contributing
290
+
291
+ See [CONTRIBUTING.md](CONTRIBUTING.md)
292
+
293
+ ## Changelog
294
+
295
+ See [CHANGELOG.md](CHANGELOG.md)
296
+
297
+ ## License
298
+
299
+ [MIT](LICENSE) © [pablokaua03](https://github.com/pablokaua03)
@@ -0,0 +1,18 @@
1
+ contexta_app/__init__.py,sha256=1sFrjiDsOqTfdTMOhOPtx5qNwJ9p_MHr9CEIEQGfb98,38
2
+ contexta_app/ai_client.py,sha256=1WwOY61KtMsb4Wge8RkZXu2IAf7oSnhGxzoCJzTlSSk,5366
3
+ contexta_app/cache.py,sha256=hGqM9vAiZW9TO1FxEuS68iMJ075U6Z6zlu992AxL1KU,4169
4
+ contexta_app/cli.py,sha256=WJv2WJpse0vyxE9gMHTAQK-Scaqp_IH1MjJn_eNT0JA,7725
5
+ contexta_app/context_engine.py,sha256=4Bs0W7Ac_2KNjAAP0No8hnu2Vy5dk7mp4-lPvp-Iehs,231598
6
+ contexta_app/embedded_icon_data.py,sha256=_CImjDgPdmIrTCTe0U7aeUCMlm3wx8hS_6mugi5csoE,33637
7
+ contexta_app/mcp_server.py,sha256=SubradWolkrtAxT_qB-wI94wHFuSBWVotay8bx9KqDg,11317
8
+ contexta_app/renderer.py,sha256=L2CGlgAeZXk-XF8dIFaZgjOnHVZ0LEpMmZCuOWSX0H0,79649
9
+ contexta_app/scanner.py,sha256=mWaRYinpBUpwHx7aie2qpEZbFzX-j9R5wrQpLsfU_vY,13614
10
+ contexta_app/syntax.py,sha256=F41VfxPJDJdgS6DbCiUufqYH6-z3_be53-WGIE9m0Ss,6216
11
+ contexta_app/theme.py,sha256=OgaIwSYCq3MSOg3CX8tC7NbU-n5zsnPTcGTMD561WOM,4700
12
+ contexta_app/ui.py,sha256=gdJiCIbagWciiDIaR4kH3VbQWAVhfvDVMBnldpFtxVs,84704
13
+ contexta_app/utils.py,sha256=DE0QGhNT8-VVdNa2XEZoBclzkRUqlbuEgIm5hJsBu3I,771
14
+ contexta_ai-2.0.0.dist-info/METADATA,sha256=w-eNqTJalqx0e8Ty7usALZtPZv74aS2oihsVbwLrIxI,10799
15
+ contexta_ai-2.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ contexta_ai-2.0.0.dist-info/entry_points.txt,sha256=uiU3v16omDQFbRjybKBk7RIlxIfCy4c3G3KqJ_TDZog,43
17
+ contexta_ai-2.0.0.dist-info/licenses/LICENSE,sha256=Hj-WZf4KcurgEbiLdQnqWwIewx_bJmcrgawyQDraCmE,1068
18
+ contexta_ai-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ contexta = contexta:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 pablokaua03
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,3 @@
1
+ """
2
+ Contexta application package.
3
+ """
@@ -0,0 +1,182 @@
1
+ """AI API client for sending context packs directly to Claude, Gemini, or OpenAI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ CONFIG_PATH = Path.home() / ".contexta" / "ai_config.json"
11
+
12
+ ENV_VARS = {
13
+ "claude": "ANTHROPIC_API_KEY",
14
+ "gemini": "GOOGLE_API_KEY",
15
+ "openai": "OPENAI_API_KEY",
16
+ }
17
+
18
+ DEFAULT_MODELS = {
19
+ "claude": "claude-sonnet-4-20250514",
20
+ "gemini": "gemini-2.5-flash",
21
+ "openai": "gpt-4o",
22
+ }
23
+
24
+ PROVIDER_LABELS = {
25
+ "claude": "Claude (Anthropic)",
26
+ "gemini": "Gemini (Google)",
27
+ "openai": "ChatGPT / GPT-4 (OpenAI)",
28
+ }
29
+
30
+
31
+ @dataclass
32
+ class AIConfig:
33
+ provider: str = "" # "claude", "gemini", "openai"
34
+ api_key: str = ""
35
+ model: str = ""
36
+
37
+ @staticmethod
38
+ def load() -> "AIConfig":
39
+ if CONFIG_PATH.exists():
40
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
41
+ return AIConfig(**{k: v for k, v in data.items() if k in AIConfig.__dataclass_fields__})
42
+ return AIConfig()
43
+
44
+ def save(self) -> None:
45
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
46
+ CONFIG_PATH.write_text(
47
+ json.dumps(
48
+ {"provider": self.provider, "api_key": self.api_key, "model": self.model},
49
+ indent=2,
50
+ ),
51
+ encoding="utf-8",
52
+ )
53
+
54
+
55
+ def get_available_providers() -> list[str]:
56
+ """Return provider keys whose SDK is installed."""
57
+ available = []
58
+ try:
59
+ import anthropic # noqa: F401
60
+ available.append("claude")
61
+ except ImportError:
62
+ pass
63
+ try:
64
+ import google.genai # noqa: F401
65
+ available.append("gemini")
66
+ except ImportError:
67
+ pass
68
+ try:
69
+ import openai # noqa: F401
70
+ available.append("openai")
71
+ except ImportError:
72
+ pass
73
+ return available
74
+
75
+
76
+ def resolve_api_key(provider: str, explicit_key: str = "") -> str:
77
+ """Resolve API key: explicit flag > env var > saved config."""
78
+ if explicit_key:
79
+ return explicit_key
80
+ env_var = ENV_VARS.get(provider, "")
81
+ if env_var:
82
+ env_val = os.environ.get(env_var, "")
83
+ if env_val:
84
+ return env_val
85
+ cfg = AIConfig.load()
86
+ if cfg.provider == provider and cfg.api_key:
87
+ return cfg.api_key
88
+ return ""
89
+
90
+
91
+ def send_to_ai(
92
+ context_markdown: str,
93
+ prompt: str,
94
+ provider: str,
95
+ api_key: str,
96
+ model: str = "",
97
+ ) -> str:
98
+ """Send context + prompt to the chosen AI and return the response text."""
99
+ if not model:
100
+ model = DEFAULT_MODELS.get(provider, "")
101
+
102
+ system_msg = (
103
+ "You are a senior software engineer. The user will provide a project context "
104
+ "pack followed by a question or task. Analyze the context and respond helpfully."
105
+ )
106
+
107
+ if provider == "claude":
108
+ import anthropic
109
+
110
+ client = anthropic.Anthropic(api_key=api_key)
111
+ response = client.messages.create(
112
+ model=model,
113
+ max_tokens=8192,
114
+ system=system_msg,
115
+ messages=[{"role": "user", "content": f"{context_markdown}\n\n---\n\n{prompt}"}],
116
+ )
117
+ return response.content[0].text
118
+
119
+ elif provider == "gemini":
120
+ from google import genai
121
+
122
+ client = genai.Client(api_key=api_key)
123
+ response = client.models.generate_content(
124
+ model=model,
125
+ contents=f"{system_msg}\n\n{context_markdown}\n\n---\n\n{prompt}",
126
+ )
127
+ return response.text
128
+
129
+ elif provider == "openai":
130
+ import openai as oai
131
+
132
+ client = oai.OpenAI(api_key=api_key)
133
+ response = client.chat.completions.create(
134
+ model=model,
135
+ messages=[
136
+ {"role": "system", "content": system_msg},
137
+ {"role": "user", "content": f"{context_markdown}\n\n---\n\n{prompt}"},
138
+ ],
139
+ )
140
+ return response.choices[0].message.content
141
+
142
+ else:
143
+ raise ValueError(f"Unknown provider: {provider}")
144
+
145
+
146
+ def configure_interactive() -> None:
147
+ """Interactive setup: choose provider, enter API key and model, save to config."""
148
+ available = get_available_providers()
149
+ all_providers = list(PROVIDER_LABELS.keys())
150
+
151
+ print("AI Configuration for Contexta")
152
+ print("=" * 40)
153
+ print("\nAvailable providers (SDK installed):")
154
+ for p in all_providers:
155
+ installed = p in available
156
+ label = PROVIDER_LABELS[p]
157
+ status = "installed" if installed else "not installed"
158
+ print(f" {p:10s} - {label} [{status}]")
159
+
160
+ print()
161
+ provider = input(f"Choose provider ({', '.join(all_providers)}): ").strip().lower()
162
+ if provider not in all_providers:
163
+ print(f"Unknown provider '{provider}'. Aborting.")
164
+ return
165
+
166
+ if provider not in available:
167
+ pkg = {"claude": "anthropic", "gemini": "google-genai", "openai": "openai"}[provider]
168
+ print(f"\nWarning: '{pkg}' is not installed. Install it with: pip install {pkg}")
169
+
170
+ api_key = input("API key: ").strip()
171
+ if not api_key:
172
+ print("No API key provided. Aborting.")
173
+ return
174
+
175
+ default_model = DEFAULT_MODELS.get(provider, "")
176
+ model = input(f"Model (default: {default_model}): ").strip()
177
+ if not model:
178
+ model = default_model
179
+
180
+ cfg = AIConfig(provider=provider, api_key=api_key, model=model)
181
+ cfg.save()
182
+ print(f"\nSaved to {CONFIG_PATH}")
contexta_app/cache.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ cache.py - Smart project cache with file-change detection.
3
+
4
+ Keeps ProjectAnalysis in memory and invalidates when source files change.
5
+ No background threads — checks mtimes on access, so it's safe for MCP's
6
+ async model.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from contexta_app.context_engine import ExportConfig, ProjectAnalysis, build_analysis
16
+ from contexta_app.scanner import (
17
+ build_tree,
18
+ count_files,
19
+ get_git_changed_files,
20
+ load_gitignore_patterns,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class _CacheEntry:
26
+ analysis: ProjectAnalysis
27
+ tree: dict
28
+ file_count: int
29
+ mtimes: dict[str, float]
30
+ created_at: float
31
+ hit_count: int = 0
32
+
33
+
34
+ _NOOP_LOG = lambda msg, tag="": None
35
+ _MAX_CACHE_AGE = 300.0
36
+ _MAX_ENTRIES = 10
37
+
38
+ _cache: dict[str, _CacheEntry] = {}
39
+ _stats = {"hits": 0, "misses": 0, "invalidations": 0}
40
+
41
+
42
+ def _snapshot_mtimes(path: Path, file_paths: list[Path]) -> dict[str, float]:
43
+ result = {}
44
+ for fp in file_paths:
45
+ try:
46
+ result[str(fp)] = fp.stat().st_mtime
47
+ except OSError:
48
+ result[str(fp)] = 0.0
49
+
50
+ for marker in ("package.json", "pyproject.toml", "Cargo.toml", "go.mod",
51
+ "requirements.txt", "composer.json", "Gemfile", ".gitignore"):
52
+ candidate = path / marker
53
+ if candidate.exists():
54
+ try:
55
+ result[str(candidate)] = candidate.stat().st_mtime
56
+ except OSError:
57
+ pass
58
+ return result
59
+
60
+
61
+ def _has_changes(entry: _CacheEntry) -> bool:
62
+ if time.time() - entry.created_at > _MAX_CACHE_AGE:
63
+ return True
64
+
65
+ for fpath, old_mtime in entry.mtimes.items():
66
+ try:
67
+ current = Path(fpath).stat().st_mtime
68
+ if current != old_mtime:
69
+ return True
70
+ except OSError:
71
+ return True
72
+ return False
73
+
74
+
75
+ def _evict_oldest():
76
+ if len(_cache) < _MAX_ENTRIES:
77
+ return
78
+ oldest_key = min(_cache, key=lambda k: _cache[k].created_at)
79
+ del _cache[oldest_key]
80
+
81
+
82
+ def get_analysis(
83
+ project_path: Path,
84
+ config: ExportConfig | None = None,
85
+ force_refresh: bool = False,
86
+ log_cb=None,
87
+ ) -> tuple[ProjectAnalysis, dict, int, bool]:
88
+ """Return (analysis, tree, file_count, was_cached).
89
+
90
+ Uses cached analysis if files haven't changed, otherwise recomputes.
91
+ """
92
+ if log_cb is None:
93
+ log_cb = _NOOP_LOG
94
+ if config is None:
95
+ config = ExportConfig()
96
+
97
+ key = str(project_path.resolve())
98
+ entry = _cache.get(key)
99
+
100
+ if entry is not None and not force_refresh and not _has_changes(entry):
101
+ entry.hit_count += 1
102
+ _stats["hits"] += 1
103
+ return entry.analysis, entry.tree, entry.file_count, True
104
+
105
+ if entry is not None:
106
+ _stats["invalidations"] += 1
107
+
108
+ _stats["misses"] += 1
109
+ _evict_oldest()
110
+
111
+ gitignore = load_gitignore_patterns(project_path)
112
+ counter = [0]
113
+ tree = build_tree(
114
+ root=project_path,
115
+ include_hidden=False,
116
+ include_unknown=False,
117
+ log_cb=log_cb,
118
+ counter=counter,
119
+ gitignore_patterns=gitignore,
120
+ project_root=project_path,
121
+ )
122
+ file_count = count_files(tree)
123
+ changed = get_git_changed_files(project_path) or []
124
+ analysis = build_analysis(
125
+ project_path=project_path,
126
+ full_tree=tree,
127
+ changed_files=changed,
128
+ config=config,
129
+ log_cb=log_cb,
130
+ )
131
+
132
+ all_paths = [f.path for f in analysis.all_files]
133
+ mtimes = _snapshot_mtimes(project_path, all_paths)
134
+
135
+ _cache[key] = _CacheEntry(
136
+ analysis=analysis,
137
+ tree=tree,
138
+ file_count=file_count,
139
+ mtimes=mtimes,
140
+ created_at=time.time(),
141
+ )
142
+
143
+ return analysis, tree, file_count, False
144
+
145
+
146
+ def get_cache_stats() -> dict:
147
+ return {
148
+ "entries": len(_cache),
149
+ "projects": list(_cache.keys()),
150
+ **_stats,
151
+ }
152
+
153
+
154
+ def invalidate(project_path: Path | None = None) -> int:
155
+ if project_path is None:
156
+ count = len(_cache)
157
+ _cache.clear()
158
+ return count
159
+ key = str(project_path.resolve())
160
+ if key in _cache:
161
+ del _cache[key]
162
+ return 1
163
+ return 0