codedthemes-cli 0.1.18__tar.gz → 0.1.20__tar.gz
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.
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/PKG-INFO +4 -10
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/README.md +3 -9
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes/__init__.py +1 -1
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes/cli.py +41 -74
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes/config.py +17 -4
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes/mcp_client.py +11 -7
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes/patch_utils.py +1 -1
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes/repo_utils.py +45 -7
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes/sync_manager.py +18 -9
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes_cli.egg-info/PKG-INFO +4 -10
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/pyproject.toml +1 -1
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes_cli.egg-info/SOURCES.txt +0 -0
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes_cli.egg-info/dependency_links.txt +0 -0
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes_cli.egg-info/entry_points.txt +0 -0
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes_cli.egg-info/requires.txt +0 -0
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes_cli.egg-info/top_level.txt +0 -0
- {codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codedthemes-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.20
|
|
4
4
|
Summary: CLI tool for Code Theme and Integration
|
|
5
5
|
Author: codedthemes
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -46,21 +46,15 @@ Log out from the current device to free up a license slot on the server.
|
|
|
46
46
|
codedthemes logout
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
### 3. Initialization
|
|
49
|
+
### 3. Initialization (Optional)
|
|
50
|
+
|
|
50
51
|
Initialize your repository to establish a baseline for synchronization. This is recommended for first-time use in a project.
|
|
51
52
|
|
|
52
53
|
```bash
|
|
53
54
|
codedthemes init
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
###
|
|
57
|
-
If your project workspace has been evicted due to inactivity (30+ minutes), use `reinit` to quickly restore it.
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
codedthemes reinit
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### 5. Applying Changes
|
|
57
|
+
### 3. Applying Changes
|
|
64
58
|
|
|
65
59
|
Describe the changes you want to make in natural language. The CLI will analyze your repository, plan the changes, and ask for your approval before patching.
|
|
66
60
|
|
|
@@ -36,21 +36,15 @@ Log out from the current device to free up a license slot on the server.
|
|
|
36
36
|
codedthemes logout
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
### 3. Initialization
|
|
39
|
+
### 3. Initialization (Optional)
|
|
40
|
+
|
|
40
41
|
Initialize your repository to establish a baseline for synchronization. This is recommended for first-time use in a project.
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
44
|
codedthemes init
|
|
44
45
|
```
|
|
45
46
|
|
|
46
|
-
###
|
|
47
|
-
If your project workspace has been evicted due to inactivity (30+ minutes), use `reinit` to quickly restore it.
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
codedthemes reinit
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
### 5. Applying Changes
|
|
47
|
+
### 3. Applying Changes
|
|
54
48
|
|
|
55
49
|
Describe the changes you want to make in natural language. The CLI will analyze your repository, plan the changes, and ask for your approval before patching.
|
|
56
50
|
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import argparse
|
|
2
|
-
import
|
|
2
|
+
import sys
|
|
3
3
|
import os
|
|
4
|
+
import json
|
|
4
5
|
import shutil
|
|
5
|
-
import
|
|
6
|
-
import sys
|
|
6
|
+
from getpass import getpass
|
|
7
7
|
|
|
8
8
|
import jwt
|
|
9
9
|
import requests
|
|
10
10
|
|
|
11
|
-
from .config import save_config, load_config, get_device_id
|
|
11
|
+
from .config import save_config, load_config, get_device_id
|
|
12
12
|
from .mcp_client import MCPClient
|
|
13
|
-
from .repo_utils import detect_repo_root
|
|
13
|
+
from .repo_utils import detect_repo_root, zip_repo
|
|
14
14
|
from .sync_manager import SyncManager
|
|
15
|
+
import socket
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def handle_login(server_url: str = None):
|
|
@@ -36,7 +37,7 @@ def handle_login(server_url: str = None):
|
|
|
36
37
|
email = input("Email: ").strip()
|
|
37
38
|
license_key = input("License key: ").strip()
|
|
38
39
|
|
|
39
|
-
# Idempotent check: already logged in as this user?
|
|
40
|
+
# Idempotent check: Are we already logged in as this user?
|
|
40
41
|
if existing_token:
|
|
41
42
|
try:
|
|
42
43
|
decoded = jwt.decode(existing_token, options={"verify_signature": False})
|
|
@@ -68,7 +69,7 @@ def handle_login(server_url: str = None):
|
|
|
68
69
|
print("✖ Login failed: No access token returned. Please check your credentials.")
|
|
69
70
|
sys.exit(1)
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
# LAZY CONFIG: Only save if login succeeded
|
|
72
73
|
save_config({
|
|
73
74
|
"access_token": token,
|
|
74
75
|
"server_url": client.server_url,
|
|
@@ -113,9 +114,12 @@ def handle_logout():
|
|
|
113
114
|
"license_key": license_key,
|
|
114
115
|
"device_id": device_id
|
|
115
116
|
})
|
|
116
|
-
except Exception:
|
|
117
|
+
except Exception as e:
|
|
118
|
+
# Silently fail if server logout fails, we still want to clear local state
|
|
117
119
|
pass
|
|
118
120
|
|
|
121
|
+
# Local cleanup: Remove config.json but KEEP device.json
|
|
122
|
+
from .config import CONFIG_FILE
|
|
119
123
|
if CONFIG_FILE.exists():
|
|
120
124
|
CONFIG_FILE.unlink()
|
|
121
125
|
|
|
@@ -148,7 +152,12 @@ def handle_init():
|
|
|
148
152
|
repo_abs_path = os.path.abspath(repo_root)
|
|
149
153
|
repo_key = repo_abs_path.lower().replace(os.sep, "/")
|
|
150
154
|
|
|
155
|
+
# Check if already initialized to avoid redundant uploads
|
|
151
156
|
config = load_config()
|
|
157
|
+
existing_ws_id = config.get("workspaces", {}).get(repo_key)
|
|
158
|
+
if existing_ws_id:
|
|
159
|
+
# Fast check: exists?
|
|
160
|
+
pass # We could call a /verify endpoint here, but for now let's just proceed with upload as "sync"
|
|
152
161
|
|
|
153
162
|
print("📦 Zipping and uploading repository (this may take a moment)...")
|
|
154
163
|
upload_result = client.upload_workspace(repo_abs_path, user_email)
|
|
@@ -177,8 +186,10 @@ def handle_init():
|
|
|
177
186
|
|
|
178
187
|
print("Choose your editor and add the MCP server:\n")
|
|
179
188
|
|
|
189
|
+
# ---------------- ANTIGRAVITY ----------------
|
|
180
190
|
print("🔹 Antigravity")
|
|
181
191
|
print(" MCP Servers → Manage → Config\n")
|
|
192
|
+
|
|
182
193
|
print(' {')
|
|
183
194
|
print(' "mcpServers": {')
|
|
184
195
|
print(' "code-theme-mcp": {')
|
|
@@ -189,8 +200,10 @@ def handle_init():
|
|
|
189
200
|
print(' }')
|
|
190
201
|
print(' }\n')
|
|
191
202
|
|
|
203
|
+
# ---------------- VS CODE ----------------
|
|
192
204
|
print("🔹 VS Code")
|
|
193
205
|
print(" Ctrl+Shift+P → MCP: Add MCP Server → http\n")
|
|
206
|
+
|
|
194
207
|
print(' {')
|
|
195
208
|
print(' "servers": {')
|
|
196
209
|
print(' "code-theme-mcp": {')
|
|
@@ -202,8 +215,10 @@ def handle_init():
|
|
|
202
215
|
print(' "inputs": []')
|
|
203
216
|
print(' }\n')
|
|
204
217
|
|
|
218
|
+
# ---------------- CURSOR ----------------
|
|
205
219
|
print("🔹 Cursor")
|
|
206
220
|
print(" Settings → Features → MCP Servers\n")
|
|
221
|
+
|
|
207
222
|
print(' {')
|
|
208
223
|
print(' "mcpServers": {')
|
|
209
224
|
print(' "code-theme-mcp": {')
|
|
@@ -215,63 +230,13 @@ def handle_init():
|
|
|
215
230
|
print(' }\n')
|
|
216
231
|
|
|
217
232
|
print("✔ Once saved, your AI assistant will detect available tools automatically.\n")
|
|
218
|
-
print('💡 Example: "Update the primary color to deep purple"')
|
|
219
|
-
print("="*60)
|
|
220
|
-
|
|
221
|
-
except Exception as e:
|
|
222
|
-
print(f"✖ Error during initialization: {e}")
|
|
223
|
-
sys.exit(1)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def handle_reinit():
|
|
227
|
-
"""
|
|
228
|
-
Reactivates an evicted workspace by re-uploading the repository to the cloud.
|
|
229
|
-
"""
|
|
230
|
-
try:
|
|
231
|
-
repo_root = detect_repo_root()
|
|
232
|
-
print(f"🔍 Found repository at {repo_root}")
|
|
233
|
-
|
|
234
|
-
client = MCPClient()
|
|
235
|
-
if not client.token:
|
|
236
|
-
print("✖ Not logged in. Please run 'codedthemes login' first.")
|
|
237
|
-
sys.exit(1)
|
|
238
|
-
|
|
239
|
-
user_email = "unknown_user"
|
|
240
|
-
try:
|
|
241
|
-
decoded = jwt.decode(client.token, options={"verify_signature": False})
|
|
242
|
-
user_email = decoded.get("email", "unknown_user")
|
|
243
|
-
except:
|
|
244
|
-
pass
|
|
245
233
|
|
|
246
|
-
|
|
247
|
-
repo_key = repo_abs_path.lower().replace(os.sep, "/")
|
|
248
|
-
|
|
249
|
-
print("🚀 Reactivating repository (this may take a moment)...")
|
|
250
|
-
upload_result = client.upload_workspace(repo_abs_path, user_email)
|
|
251
|
-
workspace_id = upload_result.get("workspace_id")
|
|
252
|
-
|
|
253
|
-
if not workspace_id:
|
|
254
|
-
print("✖ Reactivation failed: No workspace ID returned.")
|
|
255
|
-
sys.exit(1)
|
|
256
|
-
|
|
257
|
-
config = load_config()
|
|
258
|
-
last_workspaces = config.get("workspaces", {})
|
|
259
|
-
last_workspaces[repo_key] = workspace_id
|
|
260
|
-
config["workspaces"] = last_workspaces
|
|
261
|
-
save_config(config)
|
|
262
|
-
|
|
263
|
-
sync_manager = SyncManager()
|
|
264
|
-
sync_manager.update_sync_state(repo_abs_path, workspace_id, user_email)
|
|
234
|
+
print('💡 Example: "Update the primary color to deep purple"')
|
|
265
235
|
|
|
266
|
-
print(f"✔ Workspace reactivated successfully.")
|
|
267
|
-
print(f"✔ Workspace ID: {workspace_id}")
|
|
268
|
-
print("\n" + "="*60)
|
|
269
|
-
print("Your repository is now synced and active in the cloud!")
|
|
270
|
-
print("You can now return to your IDE and continue using the AI assistant.")
|
|
271
236
|
print("="*60)
|
|
272
237
|
|
|
273
238
|
except Exception as e:
|
|
274
|
-
print(f"✖ Error during
|
|
239
|
+
print(f"✖ Error during initialization: {e}")
|
|
275
240
|
sys.exit(1)
|
|
276
241
|
|
|
277
242
|
|
|
@@ -321,7 +286,7 @@ def handle_apply(query: str):
|
|
|
321
286
|
last_workspaces = config.get("workspaces", {})
|
|
322
287
|
workspace_id = last_workspaces.get(repo_key)
|
|
323
288
|
|
|
324
|
-
|
|
289
|
+
# Verify or re-initialize workspace
|
|
325
290
|
if workspace_id:
|
|
326
291
|
try:
|
|
327
292
|
check = client.call("check_workspace", {"workspace_id": workspace_id})
|
|
@@ -332,6 +297,7 @@ def handle_apply(query: str):
|
|
|
332
297
|
sys.exit(0)
|
|
333
298
|
|
|
334
299
|
if isinstance(check, dict) and check.get("status") == "ok":
|
|
300
|
+
# print(f"✔ Using active workspace: {workspace_id}")
|
|
335
301
|
pass
|
|
336
302
|
else:
|
|
337
303
|
workspace_id = None
|
|
@@ -343,6 +309,7 @@ def handle_apply(query: str):
|
|
|
343
309
|
sys.exit(0)
|
|
344
310
|
workspace_id = None
|
|
345
311
|
|
|
312
|
+
|
|
346
313
|
if not workspace_id:
|
|
347
314
|
print("📦 Zipping and uploading repository (this may take a moment)...")
|
|
348
315
|
upload_result = client.upload_workspace(repo_abs_path, user_email)
|
|
@@ -352,7 +319,7 @@ def handle_apply(query: str):
|
|
|
352
319
|
save_config(config)
|
|
353
320
|
sync_manager.update_sync_state(repo_abs_path, workspace_id, user_email)
|
|
354
321
|
|
|
355
|
-
|
|
322
|
+
# 1. Repository Analysis
|
|
356
323
|
print("🚀 Analyzing repository...")
|
|
357
324
|
analysis = client.call("repo_analyzer", {
|
|
358
325
|
"repo_path": repo_abs_path,
|
|
@@ -369,7 +336,7 @@ def handle_apply(query: str):
|
|
|
369
336
|
else:
|
|
370
337
|
print(f"✔ {analysis}")
|
|
371
338
|
|
|
372
|
-
|
|
339
|
+
# 2. Planning
|
|
373
340
|
print("🔍 Detecting local changes...")
|
|
374
341
|
local_changes = sync_manager.get_changed_files(repo_abs_path, user_email)
|
|
375
342
|
if local_changes:
|
|
@@ -422,7 +389,7 @@ def handle_apply(query: str):
|
|
|
422
389
|
return
|
|
423
390
|
print("Please enter 'y' or 'n'.")
|
|
424
391
|
|
|
425
|
-
|
|
392
|
+
# 3. Execution
|
|
426
393
|
print("⚙ Executing plan...")
|
|
427
394
|
result = client.call("execute_plan", {
|
|
428
395
|
"query": query,
|
|
@@ -439,7 +406,7 @@ def handle_apply(query: str):
|
|
|
439
406
|
print(f"✖ Execution failed: {error_msg}")
|
|
440
407
|
return
|
|
441
408
|
|
|
442
|
-
|
|
409
|
+
# 4. Local Patching
|
|
443
410
|
res_data = result
|
|
444
411
|
if isinstance(result, str):
|
|
445
412
|
try: res_data = json.loads(result)
|
|
@@ -448,7 +415,7 @@ def handle_apply(query: str):
|
|
|
448
415
|
updates = res_data.get("updates_for_local", [])
|
|
449
416
|
deletes = res_data.get("deleted_files", [])
|
|
450
417
|
|
|
451
|
-
|
|
418
|
+
# 1. Apply local updates
|
|
452
419
|
for item in updates:
|
|
453
420
|
rel_path = clean_remote_path(item.get("path"))
|
|
454
421
|
code = item.get("code")
|
|
@@ -463,7 +430,7 @@ def handle_apply(query: str):
|
|
|
463
430
|
except Exception as e:
|
|
464
431
|
print(f"✖ Error writing {rel_path}: {e}")
|
|
465
432
|
|
|
466
|
-
|
|
433
|
+
# 2. Apply local deletions
|
|
467
434
|
for rel_path_raw in (deletes or []):
|
|
468
435
|
rel_path = clean_remote_path(rel_path_raw)
|
|
469
436
|
abs_path = os.path.normpath(os.path.join(repo_abs_path, rel_path.replace('/', os.sep)))
|
|
@@ -474,12 +441,12 @@ def handle_apply(query: str):
|
|
|
474
441
|
print(f"✔ Deleted: {rel_path}")
|
|
475
442
|
except: pass
|
|
476
443
|
|
|
477
|
-
|
|
444
|
+
# 3. Synchronize state
|
|
478
445
|
if updates or deletes:
|
|
479
446
|
sync_manager.update_sync_state(repo_abs_path, workspace_id, user_email)
|
|
480
447
|
print("✔ Local sync state updated.")
|
|
481
448
|
|
|
482
|
-
|
|
449
|
+
# 4. Show Integration Report (Always show errors or details)
|
|
483
450
|
_details_raw = res_data.get("details", [])
|
|
484
451
|
details_list = _details_raw.get("report", []) if isinstance(_details_raw, dict) else (_details_raw if isinstance(_details_raw, list) else [])
|
|
485
452
|
if details_list:
|
|
@@ -488,7 +455,7 @@ def handle_apply(query: str):
|
|
|
488
455
|
status_icon = "✔" if item.get("success") else "✖"
|
|
489
456
|
print(f" [{status_icon}] {item.get('file_path')}: {item.get('message', '')}")
|
|
490
457
|
|
|
491
|
-
|
|
458
|
+
# 5. Output Final Message if no updates were applied
|
|
492
459
|
if not updates and not deletes:
|
|
493
460
|
msg = res_data.get('message', 'Changes applied successfully.')
|
|
494
461
|
if isinstance(msg, str) and "AI MUST now sync" in msg:
|
|
@@ -510,7 +477,7 @@ def main():
|
|
|
510
477
|
parser = argparse.ArgumentParser(prog="codedthemes", description="CodedThemes CLI client")
|
|
511
478
|
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
512
479
|
|
|
513
|
-
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
480
|
+
subparsers = parser.add_subparsers(dest="command", required=False) # Changed to False to allow --version to work alone
|
|
514
481
|
|
|
515
482
|
|
|
516
483
|
login_parser = subparsers.add_parser("login", help="Login to MCP server")
|
|
@@ -520,9 +487,9 @@ def main():
|
|
|
520
487
|
apply_parser.add_argument("query", help="Description of changes to make")
|
|
521
488
|
|
|
522
489
|
subparsers.add_parser("init", help="Initialize repository and sync to cloud")
|
|
523
|
-
subparsers.add_parser("reinit", help="Reactivate an evicted workspace and sync to cloud")
|
|
524
490
|
subparsers.add_parser("logout", help="Log out from current device")
|
|
525
491
|
|
|
492
|
+
|
|
526
493
|
args = parser.parse_args()
|
|
527
494
|
|
|
528
495
|
if not args.command:
|
|
@@ -533,12 +500,12 @@ def main():
|
|
|
533
500
|
handle_login(args.server)
|
|
534
501
|
elif args.command == "init":
|
|
535
502
|
handle_init()
|
|
536
|
-
elif args.command == "reinit":
|
|
537
|
-
handle_reinit()
|
|
538
503
|
elif args.command == "apply":
|
|
539
504
|
handle_apply(args.query)
|
|
540
505
|
elif args.command == "logout":
|
|
541
506
|
handle_logout()
|
|
542
507
|
|
|
508
|
+
|
|
509
|
+
|
|
543
510
|
if __name__ == "__main__":
|
|
544
511
|
main()
|
|
@@ -4,17 +4,22 @@ import hashlib
|
|
|
4
4
|
import uuid
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
# Stores token at ~/.codedthemes/config.json
|
|
7
8
|
CONFIG_DIR = Path.home() / ".codedthemes"
|
|
8
9
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def ensure_config_dir():
|
|
12
|
-
"""
|
|
13
|
+
"""
|
|
14
|
+
Ensures the configuration directory exists.
|
|
15
|
+
"""
|
|
13
16
|
CONFIG_DIR.mkdir(exist_ok=True)
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
def save_config(data: dict):
|
|
17
|
-
"""
|
|
20
|
+
"""
|
|
21
|
+
Saves the provided configuration data to the config file (merging with existing).
|
|
22
|
+
"""
|
|
18
23
|
ensure_config_dir()
|
|
19
24
|
existing = load_config()
|
|
20
25
|
existing.update(data)
|
|
@@ -23,7 +28,9 @@ def save_config(data: dict):
|
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
def get_device_id():
|
|
26
|
-
"""
|
|
31
|
+
"""
|
|
32
|
+
Returns a unique device ID, stored globally in ~/.codedthemes/device.json.
|
|
33
|
+
"""
|
|
27
34
|
device_file = CONFIG_DIR / "device.json"
|
|
28
35
|
|
|
29
36
|
if device_file.exists():
|
|
@@ -35,6 +42,7 @@ def get_device_id():
|
|
|
35
42
|
except:
|
|
36
43
|
pass
|
|
37
44
|
|
|
45
|
+
# Generate new device ID
|
|
38
46
|
try:
|
|
39
47
|
hostname = socket.gethostname()
|
|
40
48
|
device_uuid = str(uuid.getnode())
|
|
@@ -43,6 +51,7 @@ def get_device_id():
|
|
|
43
51
|
except:
|
|
44
52
|
device_id = str(uuid.uuid4())[:12]
|
|
45
53
|
|
|
54
|
+
# Save globally (create dir if not exists)
|
|
46
55
|
CONFIG_DIR.mkdir(exist_ok=True)
|
|
47
56
|
try:
|
|
48
57
|
with open(device_file, "w") as f:
|
|
@@ -53,8 +62,12 @@ def get_device_id():
|
|
|
53
62
|
return device_id
|
|
54
63
|
|
|
55
64
|
|
|
65
|
+
|
|
66
|
+
|
|
56
67
|
def load_config():
|
|
57
|
-
"""
|
|
68
|
+
"""
|
|
69
|
+
Loads the configuration data from the config file.
|
|
70
|
+
"""
|
|
58
71
|
if not CONFIG_FILE.exists():
|
|
59
72
|
return {}
|
|
60
73
|
with open(CONFIG_FILE, "r") as f:
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import os
|
|
2
|
-
|
|
3
2
|
import requests
|
|
4
|
-
|
|
5
3
|
from .config import load_config
|
|
6
4
|
from .repo_utils import zip_repo
|
|
7
5
|
|
|
8
|
-
|
|
9
6
|
class MCPClient:
|
|
10
|
-
"""
|
|
11
|
-
|
|
7
|
+
"""
|
|
8
|
+
Client for interacting with the CodedThemes MCP Server.
|
|
9
|
+
"""
|
|
12
10
|
def __init__(self):
|
|
13
11
|
config = load_config()
|
|
14
12
|
self.server_url = (
|
|
@@ -19,7 +17,9 @@ class MCPClient:
|
|
|
19
17
|
self.token = config.get("access_token")
|
|
20
18
|
|
|
21
19
|
def upload_workspace(self, repo_path: str, user_id: str):
|
|
22
|
-
"""
|
|
20
|
+
"""
|
|
21
|
+
Zips and uploads the repository to create a new workspace.
|
|
22
|
+
"""
|
|
23
23
|
zip_path = zip_repo(repo_path)
|
|
24
24
|
repo_name = os.path.basename(repo_path)
|
|
25
25
|
|
|
@@ -59,11 +59,14 @@ class MCPClient:
|
|
|
59
59
|
os.remove(zip_path)
|
|
60
60
|
|
|
61
61
|
def call(self, tool_name: str, payload: dict):
|
|
62
|
-
"""
|
|
62
|
+
"""
|
|
63
|
+
Calls a remote MCP tool or the login endpoint.
|
|
64
|
+
"""
|
|
63
65
|
headers = {}
|
|
64
66
|
if self.token:
|
|
65
67
|
headers["Authorization"] = f"Bearer {self.token}"
|
|
66
68
|
|
|
69
|
+
# Login and Logout use dedicated endpoints
|
|
67
70
|
if tool_name == "login":
|
|
68
71
|
url = f"{self.server_url}/auth/login"
|
|
69
72
|
elif tool_name == "logout":
|
|
@@ -71,6 +74,7 @@ class MCPClient:
|
|
|
71
74
|
else:
|
|
72
75
|
url = f"{self.server_url}/tools/{tool_name}"
|
|
73
76
|
|
|
77
|
+
|
|
74
78
|
response = requests.post(
|
|
75
79
|
url,
|
|
76
80
|
json=payload,
|
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import zipfile
|
|
3
|
+
import tempfile
|
|
2
4
|
import logging
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from fnmatch import fnmatch
|
|
5
|
-
import zipfile
|
|
6
|
-
import tempfile
|
|
7
7
|
|
|
8
8
|
logger = logging.getLogger("codedthemes")
|
|
9
9
|
|
|
10
|
+
# ── Shared exclusion constants (used by both zip_repo and SyncManager) ────────
|
|
11
|
+
|
|
10
12
|
EXCLUDE_DIRS = {
|
|
13
|
+
# Version control
|
|
11
14
|
".git", ".svn", ".hg",
|
|
15
|
+
# Dependencies
|
|
12
16
|
"node_modules", "bower_components", "vendor", "venv", ".venv", "env",
|
|
17
|
+
# Build outputs
|
|
13
18
|
"build", "dist", "out", "target", ".output", "_build",
|
|
19
|
+
# Framework caches
|
|
14
20
|
".next", ".nuxt", ".svelte-kit", ".angular", ".turbo",
|
|
15
21
|
".parcel-cache", ".cache", ".temp", ".tmp",
|
|
22
|
+
# IDE / editor
|
|
16
23
|
".idea", ".vscode", ".vs",
|
|
24
|
+
# Python
|
|
17
25
|
"__pycache__", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
26
|
+
# Testing
|
|
18
27
|
"coverage", ".nyc_output", "htmlcov",
|
|
28
|
+
# Misc
|
|
19
29
|
".terraform", ".serverless",
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
EXCLUDE_EXTENSIONS = {
|
|
33
|
+
# Images
|
|
23
34
|
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".ico", ".svg",
|
|
35
|
+
# Fonts
|
|
24
36
|
".woff", ".woff2", ".ttf", ".eot", ".otf",
|
|
37
|
+
# Video / audio
|
|
25
38
|
".mp4", ".webm", ".avi", ".mov", ".mp3", ".wav", ".ogg",
|
|
39
|
+
# Archives
|
|
26
40
|
".zip", ".tar", ".gz", ".rar", ".7z",
|
|
41
|
+
# Documents
|
|
27
42
|
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
43
|
+
# Source maps
|
|
28
44
|
".map",
|
|
45
|
+
# Compiled / binary
|
|
29
46
|
".exe", ".dll", ".so", ".dylib", ".o", ".pyc", ".pyo", ".class",
|
|
30
47
|
}
|
|
31
48
|
|
|
@@ -38,8 +55,14 @@ EXCLUDE_FILES = {
|
|
|
38
55
|
MAX_SINGLE_FILE_BYTES = 2 * 1024 * 1024 # 2 MB
|
|
39
56
|
|
|
40
57
|
|
|
58
|
+
# ── .gitignore parsing ────────────────────────────────────────────────────────
|
|
59
|
+
|
|
41
60
|
def _load_gitignore_patterns(repo_root):
|
|
42
|
-
"""
|
|
61
|
+
"""
|
|
62
|
+
Reads .gitignore at the repo root and returns a list of patterns.
|
|
63
|
+
Supports basic glob patterns; does NOT implement full git-ignore spec
|
|
64
|
+
(negation, nested .gitignore, etc.) but covers the common cases.
|
|
65
|
+
"""
|
|
43
66
|
gitignore_path = os.path.join(repo_root, ".gitignore")
|
|
44
67
|
patterns = []
|
|
45
68
|
if not os.path.isfile(gitignore_path):
|
|
@@ -50,6 +73,7 @@ def _load_gitignore_patterns(repo_root):
|
|
|
50
73
|
line = line.strip()
|
|
51
74
|
if not line or line.startswith("#"):
|
|
52
75
|
continue
|
|
76
|
+
# Ignore negation patterns (!) for simplicity
|
|
53
77
|
if line.startswith("!"):
|
|
54
78
|
continue
|
|
55
79
|
patterns.append(line.rstrip("/"))
|
|
@@ -59,12 +83,16 @@ def _load_gitignore_patterns(repo_root):
|
|
|
59
83
|
|
|
60
84
|
|
|
61
85
|
def _is_gitignored(rel_path, patterns):
|
|
62
|
-
"""
|
|
86
|
+
"""
|
|
87
|
+
Checks if a relative path matches any .gitignore pattern.
|
|
88
|
+
"""
|
|
63
89
|
parts = rel_path.replace("\\", "/").split("/")
|
|
64
90
|
for pattern in patterns:
|
|
91
|
+
# Directory-level match: check each path component
|
|
65
92
|
for part in parts:
|
|
66
93
|
if fnmatch(part, pattern):
|
|
67
94
|
return True
|
|
95
|
+
# Full-path match
|
|
68
96
|
if fnmatch(rel_path.replace("\\", "/"), pattern):
|
|
69
97
|
return True
|
|
70
98
|
if fnmatch(rel_path.replace("\\", "/"), f"**/{pattern}"):
|
|
@@ -72,10 +100,13 @@ def _is_gitignored(rel_path, patterns):
|
|
|
72
100
|
return False
|
|
73
101
|
|
|
74
102
|
|
|
103
|
+
# ── Core functions ────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
75
105
|
def detect_repo_root(start_path=None):
|
|
76
106
|
"""
|
|
77
107
|
Checks if the current directory contains ai.json or .git.
|
|
78
|
-
Strictly restricted to the current path (no upward traversal)
|
|
108
|
+
Strictly restricted to the current path (no upward traversal) to avoid
|
|
109
|
+
incorrectly detecting parent repos.
|
|
79
110
|
"""
|
|
80
111
|
if not start_path:
|
|
81
112
|
start_path = os.getcwd()
|
|
@@ -88,6 +119,8 @@ def detect_repo_root(start_path=None):
|
|
|
88
119
|
raise Exception("No repository root found. Please run this command inside a Git repository or one initialized with 'codedthemes init'.")
|
|
89
120
|
|
|
90
121
|
|
|
122
|
+
|
|
123
|
+
|
|
91
124
|
def zip_repo(repo_root):
|
|
92
125
|
"""
|
|
93
126
|
Creates a temporary ZIP archive of the repository with aggressive filtering:
|
|
@@ -106,26 +139,30 @@ def zip_repo(repo_root):
|
|
|
106
139
|
|
|
107
140
|
with zipfile.ZipFile(temp_zip.name, "w", zipfile.ZIP_DEFLATED) as z:
|
|
108
141
|
for root, dirs, files in os.walk(repo_root):
|
|
142
|
+
# Prune excluded directories (in-place)
|
|
109
143
|
dirs[:] = [
|
|
110
144
|
d for d in dirs
|
|
111
145
|
if d not in EXCLUDE_DIRS
|
|
112
|
-
and not d.startswith(".")
|
|
113
|
-
or d in {".github"}
|
|
146
|
+
and not d.startswith(".") # skip all hidden dirs (e.g. .env, .husky)
|
|
147
|
+
or d in {".github"} # but keep .github
|
|
114
148
|
]
|
|
115
149
|
|
|
116
150
|
for file in files:
|
|
117
151
|
full_path = os.path.join(root, file)
|
|
118
152
|
rel_path = os.path.relpath(full_path, repo_root).replace(os.sep, "/")
|
|
119
153
|
|
|
154
|
+
# Skip by exact filename
|
|
120
155
|
if file in EXCLUDE_FILES:
|
|
121
156
|
skipped_count += 1
|
|
122
157
|
continue
|
|
123
158
|
|
|
159
|
+
# Skip by extension
|
|
124
160
|
_, ext = os.path.splitext(file)
|
|
125
161
|
if ext.lower() in EXCLUDE_EXTENSIONS:
|
|
126
162
|
skipped_count += 1
|
|
127
163
|
continue
|
|
128
164
|
|
|
165
|
+
# Skip large files
|
|
129
166
|
try:
|
|
130
167
|
if os.path.getsize(full_path) > MAX_SINGLE_FILE_BYTES:
|
|
131
168
|
logger.debug(f"Skipping large file: {rel_path}")
|
|
@@ -134,6 +171,7 @@ def zip_repo(repo_root):
|
|
|
134
171
|
except OSError:
|
|
135
172
|
continue
|
|
136
173
|
|
|
174
|
+
# Skip .gitignore-matched paths
|
|
137
175
|
if gitignore_patterns and _is_gitignored(rel_path, gitignore_patterns):
|
|
138
176
|
skipped_count += 1
|
|
139
177
|
continue
|
|
@@ -2,13 +2,12 @@ import os
|
|
|
2
2
|
import json
|
|
3
3
|
import hashlib
|
|
4
4
|
from datetime import datetime
|
|
5
|
-
|
|
6
5
|
from .repo_utils import EXCLUDE_DIRS
|
|
7
6
|
|
|
8
|
-
|
|
9
7
|
class SyncManager:
|
|
10
|
-
"""
|
|
11
|
-
|
|
8
|
+
"""
|
|
9
|
+
Manages local file hashes to track manual changes and maintain synchronization state.
|
|
10
|
+
"""
|
|
12
11
|
def __init__(self):
|
|
13
12
|
self.config_dir = os.path.expanduser("~/.codedthemes")
|
|
14
13
|
self.config_file = os.path.join(self.config_dir, "workspace.json")
|
|
@@ -16,7 +15,9 @@ class SyncManager:
|
|
|
16
15
|
self.workspaces = self.load()
|
|
17
16
|
|
|
18
17
|
def load(self):
|
|
19
|
-
"""
|
|
18
|
+
"""
|
|
19
|
+
Loads the workspace synchronization state from the cloud config.
|
|
20
|
+
"""
|
|
20
21
|
if os.path.exists(self.config_file):
|
|
21
22
|
try:
|
|
22
23
|
with open(self.config_file, 'r') as f: return json.load(f)
|
|
@@ -24,12 +25,16 @@ class SyncManager:
|
|
|
24
25
|
return {}
|
|
25
26
|
|
|
26
27
|
def save(self):
|
|
27
|
-
"""
|
|
28
|
+
"""
|
|
29
|
+
Saves the workspace synchronization state locally.
|
|
30
|
+
"""
|
|
28
31
|
with open(self.config_file, 'w') as f:
|
|
29
32
|
json.dump(self.workspaces, f, indent=2)
|
|
30
33
|
|
|
31
34
|
def compute_hashes(self, repo_path):
|
|
32
|
-
"""
|
|
35
|
+
"""
|
|
36
|
+
Computes MD5 hashes for all relevant files in the repository.
|
|
37
|
+
"""
|
|
33
38
|
hashes = {}
|
|
34
39
|
for root, dirs, files in os.walk(repo_path):
|
|
35
40
|
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
|
|
@@ -45,7 +50,9 @@ class SyncManager:
|
|
|
45
50
|
return hashes
|
|
46
51
|
|
|
47
52
|
def update_sync_state(self, repo_path, workspace_id, user_id):
|
|
48
|
-
"""
|
|
53
|
+
"""
|
|
54
|
+
Updates the synchronization state with the latest file hashes.
|
|
55
|
+
"""
|
|
49
56
|
repo_key = f"{user_id}:{os.path.abspath(repo_path)}"
|
|
50
57
|
self.workspaces[repo_key] = {
|
|
51
58
|
"workspace_id": workspace_id,
|
|
@@ -56,7 +63,9 @@ class SyncManager:
|
|
|
56
63
|
self.save()
|
|
57
64
|
|
|
58
65
|
def get_changed_files(self, repo_path, user_id):
|
|
59
|
-
"""
|
|
66
|
+
"""
|
|
67
|
+
Identifies files that have been modified or deleted locally since the last sync.
|
|
68
|
+
"""
|
|
60
69
|
repo_key = f"{user_id}:{os.path.abspath(repo_path)}"
|
|
61
70
|
if repo_key not in self.workspaces: return []
|
|
62
71
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codedthemes-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.20
|
|
4
4
|
Summary: CLI tool for Code Theme and Integration
|
|
5
5
|
Author: codedthemes
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -46,21 +46,15 @@ Log out from the current device to free up a license slot on the server.
|
|
|
46
46
|
codedthemes logout
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
### 3. Initialization
|
|
49
|
+
### 3. Initialization (Optional)
|
|
50
|
+
|
|
50
51
|
Initialize your repository to establish a baseline for synchronization. This is recommended for first-time use in a project.
|
|
51
52
|
|
|
52
53
|
```bash
|
|
53
54
|
codedthemes init
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
###
|
|
57
|
-
If your project workspace has been evicted due to inactivity (30+ minutes), use `reinit` to quickly restore it.
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
codedthemes reinit
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### 5. Applying Changes
|
|
57
|
+
### 3. Applying Changes
|
|
64
58
|
|
|
65
59
|
Describe the changes you want to make in natural language. The CLI will analyze your repository, plan the changes, and ask for your approval before patching.
|
|
66
60
|
|
|
File without changes
|
{codedthemes_cli-0.1.18 → codedthemes_cli-0.1.20}/codedthemes_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|