code-context-control 2.28.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.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
@@ -0,0 +1,579 @@
1
+ """Global project registry and session manager for C3."""
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from services.activity_log import ActivityLog
15
+
16
+ _GLOBAL_C3_DIR = Path.home() / ".c3"
17
+ _PROJECTS_FILE = _GLOBAL_C3_DIR / "projects.json"
18
+ _REGISTRY_FILE = _GLOBAL_C3_DIR / "registry.json"
19
+ _SESSION_ACTIVITY_GRACE_SECONDS = 20 * 60
20
+ _REGISTRY_STARTUP_GRACE = 30 # seconds: keep registry entry while server is still starting
21
+
22
+
23
+ def _pythonw() -> str:
24
+ """Return pythonw.exe when available for hidden-background launches on Windows."""
25
+ candidate = Path(sys.executable).parent / "pythonw.exe"
26
+ return str(candidate) if candidate.exists() else sys.executable
27
+
28
+
29
+ def _coerce_epoch(value) -> float | None:
30
+ """Convert a value to epoch-seconds (float).
31
+
32
+ Accepts epoch numbers, ISO-8601 strings, or None. Registry writes from
33
+ _register_session() store a float; older writers and test fixtures may
34
+ have stored an ISO string. Return None if we can't interpret it.
35
+ """
36
+ if value is None:
37
+ return None
38
+ if isinstance(value, (int, float)):
39
+ return float(value)
40
+ if isinstance(value, str):
41
+ s = value.strip()
42
+ if not s:
43
+ return None
44
+ try:
45
+ return float(s) # numeric string
46
+ except ValueError:
47
+ pass
48
+ try:
49
+ # Handle trailing Z and offset-aware ISO
50
+ if s.endswith("Z"):
51
+ s = s[:-1] + "+00:00"
52
+ dt = datetime.fromisoformat(s)
53
+ if dt.tzinfo is None:
54
+ dt = dt.replace(tzinfo=timezone.utc)
55
+ return dt.timestamp()
56
+ except ValueError:
57
+ return None
58
+ return None
59
+
60
+
61
+ class ProjectManager:
62
+ """Manages the global C3 project registry stored in ~/.c3/projects.json."""
63
+
64
+ def __init__(self):
65
+ _GLOBAL_C3_DIR.mkdir(parents=True, exist_ok=True)
66
+
67
+ def _read_projects(self) -> list:
68
+ try:
69
+ if _PROJECTS_FILE.exists():
70
+ with open(_PROJECTS_FILE, encoding="utf-8") as f:
71
+ return json.load(f).get("projects", [])
72
+ except Exception:
73
+ pass
74
+ return []
75
+
76
+ def _write_projects(self, projects: list):
77
+ _GLOBAL_C3_DIR.mkdir(parents=True, exist_ok=True)
78
+ with open(_PROJECTS_FILE, "w", encoding="utf-8") as f:
79
+ json.dump({"projects": projects}, f, indent=2)
80
+
81
+ def _read_registry(self) -> list:
82
+ try:
83
+ if _REGISTRY_FILE.exists():
84
+ with open(_REGISTRY_FILE, encoding="utf-8") as f:
85
+ return json.load(f)
86
+ except Exception:
87
+ pass
88
+ return []
89
+
90
+ def _port_alive(self, port: int) -> bool:
91
+ try:
92
+ with socket.create_connection(("127.0.0.1", port), timeout=0.3):
93
+ return True
94
+ except Exception:
95
+ return False
96
+
97
+ def _verify_c3_session(self, port: int) -> bool:
98
+ """Verify that a given port is actually a C3 session UI."""
99
+ try:
100
+ url = f"http://127.0.0.1:{port}/api/health"
101
+ with urllib.request.urlopen(url, timeout=0.8) as r:
102
+ data = json.loads(r.read().decode("utf-8"))
103
+ is_c3 = data.get("service") in {"c3-ui", "c3-hub"}
104
+ # Also accept if 'sources' key is present (for older versions or custom UIs)
105
+ return is_c3 or "sources" in data
106
+ except Exception:
107
+ return False
108
+
109
+ def _parse_timestamp(self, value: str | None) -> datetime | None:
110
+ if not value:
111
+ return None
112
+ try:
113
+ normalized = str(value).strip().replace("Z", "+00:00")
114
+ dt = datetime.fromisoformat(normalized)
115
+ if dt.tzinfo is None:
116
+ dt = dt.replace(tzinfo=timezone.utc)
117
+ return dt
118
+ except Exception:
119
+ return None
120
+
121
+ def _read_project_config(self, path: str) -> dict:
122
+ config_path = Path(path) / ".c3" / "config.json"
123
+ if config_path.exists():
124
+ try:
125
+ with open(config_path, encoding="utf-8") as f:
126
+ return json.load(f)
127
+ except Exception:
128
+ pass
129
+ return {}
130
+
131
+ def _get_budget_info(self, path: str) -> dict | None:
132
+ """Read the context budget from the project's .c3 directory."""
133
+ budget_path = Path(path) / ".c3" / "context_budget.json"
134
+ if budget_path.exists():
135
+ try:
136
+ with open(budget_path, encoding="utf-8") as f:
137
+ return json.load(f)
138
+ except Exception:
139
+ pass
140
+ return None
141
+
142
+ def _get_live_session_info(self, path: str) -> dict | None:
143
+ """Return live session info inferred from the project's activity log."""
144
+ try:
145
+ activity = ActivityLog(path)
146
+
147
+ # Use the public API — get_recent's scan_factor=100 for typed queries
148
+ # handles sparse rare events (session_start can sit far behind many
149
+ # tool_call entries). Going through the API also lets tests stub it.
150
+ starts = activity.get_recent(limit=1, event_type="session_start")
151
+ saves = activity.get_recent(limit=1, event_type="session_save")
152
+ last_start = starts[0] if starts else None
153
+ last_save = saves[0] if saves else None
154
+
155
+ if last_start is None:
156
+ return None
157
+ session_id = last_start.get("session_id")
158
+ started = last_start.get("timestamp")
159
+ if not session_id or not started:
160
+ return None
161
+
162
+ # If the most recent save belongs to this session, it has ended.
163
+ if last_save and last_save.get("session_id") == session_id:
164
+ return None
165
+
166
+ recent = activity.get_recent(limit=1, since=started)
167
+ last_activity = recent[0].get("timestamp") if recent else started
168
+ started_dt = self._parse_timestamp(started)
169
+ last_activity_dt = self._parse_timestamp(last_activity)
170
+ if not started_dt or not last_activity_dt:
171
+ return None
172
+ now = datetime.now(timezone.utc)
173
+ idle_seconds = max(0, int((now - last_activity_dt).total_seconds()))
174
+ if idle_seconds > _SESSION_ACTIVITY_GRACE_SECONDS:
175
+ return None
176
+ duration_seconds = max(0, int((now - started_dt).total_seconds()))
177
+ return {
178
+ "session_id": session_id,
179
+ "started_at": started,
180
+ "last_activity": last_activity,
181
+ "description": last_start.get("description", ""),
182
+ "duration_seconds": duration_seconds,
183
+ "idle_seconds": idle_seconds,
184
+ }
185
+ except Exception:
186
+ return None
187
+
188
+ def _get_last_session_timestamp(
189
+ self, path: str, stored_value: str | None = None, live_session: dict | None = None
190
+ ) -> str | None:
191
+ """Return the most recent known session timestamp for a project."""
192
+ latest_dt = None
193
+ latest_raw = None
194
+
195
+ def remember(value: str | None):
196
+ nonlocal latest_dt, latest_raw
197
+ dt = self._parse_timestamp(value)
198
+ if dt and (latest_dt is None or dt > latest_dt):
199
+ latest_dt = dt
200
+ latest_raw = value
201
+
202
+ remember(stored_value)
203
+ if live_session:
204
+ remember(live_session.get("last_activity"))
205
+ remember(live_session.get("started_at"))
206
+ try:
207
+ activity = ActivityLog(path)
208
+ remember((activity.get_recent(limit=1, event_type="session_save") or [{}])[0].get("timestamp"))
209
+ remember((activity.get_recent(limit=1, event_type="session_start") or [{}])[0].get("timestamp"))
210
+ except Exception:
211
+ pass
212
+ return latest_raw
213
+
214
+ def add_project(self, path: str, name: str = None) -> dict:
215
+ path = str(Path(path).resolve())
216
+ projects = self._read_projects()
217
+ for p in projects:
218
+ if p["path"] == path:
219
+ return p
220
+ cfg = self._read_project_config(path)
221
+ entry = {
222
+ "name": name or Path(path).name,
223
+ "path": path,
224
+ "ide": cfg.get("ide", "unknown"),
225
+ "added_at": datetime.utcnow().isoformat() + "Z",
226
+ "last_session": None,
227
+ "tags": [],
228
+ "notes": "",
229
+ }
230
+ projects.append(entry)
231
+ self._write_projects(projects)
232
+ return entry
233
+
234
+ def remove_project(self, path: str) -> bool:
235
+ path = str(Path(path).resolve())
236
+ projects = self._read_projects()
237
+ filtered = [p for p in projects if p["path"] != path]
238
+ if len(filtered) < len(projects):
239
+ self._write_projects(filtered)
240
+ return True
241
+ return False
242
+
243
+ def sweep_registry(self):
244
+ """Remove stale registry entries (dead ports). Call on hub startup."""
245
+ registry = self._read_registry()
246
+ if not registry:
247
+ return
248
+ valid = [e for e in registry if e.get("port") and self._port_alive(e["port"])]
249
+ if len(valid) != len(registry):
250
+ try:
251
+ with open(_REGISTRY_FILE, "w", encoding="utf-8") as f:
252
+ json.dump(valid, f, indent=2)
253
+ except Exception:
254
+ pass
255
+
256
+ def list_projects(self) -> list:
257
+ projects = self._read_projects()
258
+ registry = self._read_registry()
259
+ ui_active_by_path: dict = {}
260
+ valid_registry = []
261
+ registry_changed = False
262
+
263
+ for entry in registry:
264
+ port = entry.get("port")
265
+ proj_path = entry.get("project_path", "")
266
+ if port and self._port_alive(port):
267
+ if self._verify_c3_session(port):
268
+ ui_active_by_path[proj_path] = entry
269
+ valid_registry.append(entry)
270
+ else:
271
+ registry_changed = True
272
+ else:
273
+ # Grace period: keep entries registered recently — the server may still be
274
+ # starting up between _register_session() and app.run() binding the port.
275
+ # started_at is written as epoch float by _register_session() but older
276
+ # entries (or test fixtures) may store an ISO string; coerce defensively.
277
+ started_epoch = _coerce_epoch(entry.get("started_at"))
278
+ if started_epoch is not None and time.time() - started_epoch < _REGISTRY_STARTUP_GRACE:
279
+ valid_registry.append(entry) # keep it; don't mark ui_active yet
280
+ else:
281
+ registry_changed = True
282
+
283
+ if registry_changed:
284
+ try:
285
+ with open(_REGISTRY_FILE, "w", encoding="utf-8") as f:
286
+ json.dump(valid_registry, f, indent=2)
287
+ except Exception:
288
+ pass
289
+
290
+ result = []
291
+ for p in projects:
292
+ enriched = dict(p)
293
+ path_accessible = Path(p["path"]).is_dir()
294
+ enriched["accessible"] = path_accessible
295
+ ui_active = ui_active_by_path.get(p["path"])
296
+ live_session = self._get_live_session_info(p["path"]) if path_accessible else None
297
+ enriched["ui_active"] = ui_active is not None
298
+ enriched["session_active"] = live_session is not None
299
+ # A project is active if either the web UI is live or the activity log shows a
300
+ # currently running C3 session. The hub card should not fall back to "idle"
301
+ # just because the session has no live UI port.
302
+ enriched["active"] = enriched["ui_active"] or enriched["session_active"]
303
+ enriched["port"] = ui_active["port"] if ui_active else None
304
+ enriched["budget"] = self._get_budget_info(p["path"]) if enriched["active"] else None
305
+ enriched["started_at"] = (
306
+ ui_active["started_at"]
307
+ if ui_active
308
+ else (live_session["started_at"] if live_session else None)
309
+ )
310
+ enriched["live_session_id"] = (
311
+ live_session["session_id"] if live_session else None
312
+ )
313
+ enriched["last_activity"] = (
314
+ live_session["last_activity"] if live_session else None
315
+ )
316
+ enriched["session_description"] = (
317
+ live_session["description"] if live_session else ""
318
+ )
319
+ enriched["last_session"] = (
320
+ self._get_last_session_timestamp(p["path"], p.get("last_session"), live_session)
321
+ if path_accessible else p.get("last_session")
322
+ )
323
+ cfg = self._read_project_config(p["path"]) if path_accessible else {}
324
+ if cfg:
325
+ enriched["ide"] = cfg.get("ide", p.get("ide", "unknown"))
326
+ enriched["c3_version"] = cfg.get("version")
327
+ try:
328
+ from core.ide import PROFILES
329
+ prof = PROFILES.get(enriched["ide"])
330
+ if prof:
331
+ if prof.config_path_global:
332
+ mf = Path.home() / prof.config_path
333
+ else:
334
+ mf = Path(p["path"]) / prof.config_path
335
+ enriched["mcp_installed"] = mf.exists()
336
+ mcp_cfg = cfg.get("mcp", {})
337
+ enriched["mcp_mode"] = mcp_cfg.get("mode") if isinstance(mcp_cfg, dict) else None
338
+ except Exception:
339
+ pass
340
+ result.append(enriched)
341
+ return result
342
+
343
+ def get_active_sessions(self) -> list:
344
+ active = []
345
+ for e in self._read_registry():
346
+ port = e.get("port", 0)
347
+ if port and self._port_alive(port) and self._verify_c3_session(port):
348
+ active.append(e)
349
+
350
+ for project in self.list_projects():
351
+ if project.get("session_active") and not project.get("ui_active"):
352
+ active.append(
353
+ {
354
+ "project_path": project["path"],
355
+ "project_name": project.get("name", Path(project["path"]).name),
356
+ "port": None,
357
+ "started_at": project.get("started_at"),
358
+ "live_session_id": project.get("live_session_id"),
359
+ }
360
+ )
361
+ return active
362
+
363
+ def launch_session(self, path: str) -> dict:
364
+ """Launch a C3 UI session for a project.
365
+
366
+ Returns dict with 'launched' bool and optional 'error' string.
367
+ """
368
+ if not Path(path).is_dir():
369
+ return {"launched": False, "error": f"Project path not accessible: {path}"}
370
+ c3_py = Path(__file__).parent.parent / "cli" / "c3.py"
371
+ env = os.environ.copy()
372
+ env["PYTHONPATH"] = str(Path(__file__).parent.parent)
373
+ kwargs = {}
374
+ cmd = [sys.executable, str(c3_py), "ui", path, "--no-browser", "--silent"]
375
+ log_dir = Path(path) / ".c3"
376
+ log_dir.mkdir(parents=True, exist_ok=True)
377
+ log_file = log_dir / "ui.log"
378
+ if sys.platform == "win32":
379
+ cmd[0] = _pythonw()
380
+ kwargs["creationflags"] = (
381
+ subprocess.DETACHED_PROCESS
382
+ | subprocess.CREATE_NEW_PROCESS_GROUP
383
+ | subprocess.CREATE_NO_WINDOW
384
+ )
385
+ kwargs["close_fds"] = True
386
+ kwargs["stdout"] = open(log_file, "a", encoding="utf-8")
387
+ kwargs["stderr"] = subprocess.STDOUT
388
+ kwargs["stdin"] = subprocess.DEVNULL
389
+ else:
390
+ kwargs["start_new_session"] = True
391
+ kwargs["stdout"] = open(log_file, "a", encoding="utf-8")
392
+ kwargs["stderr"] = subprocess.STDOUT
393
+ try:
394
+ subprocess.Popen(cmd, cwd=path, env=env, **kwargs)
395
+ return {"launched": True}
396
+ except Exception as e:
397
+ return {"launched": False, "error": str(e)}
398
+
399
+ def stop_session(self, port: int) -> bool:
400
+ try:
401
+ kwargs = {}
402
+ if sys.platform == "win32":
403
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
404
+ if sys.platform == "win32":
405
+ result = subprocess.run(f"netstat -ano | findstr :{port}", shell=True, capture_output=True, text=True, **kwargs)
406
+ pids = set()
407
+ for line in result.stdout.strip().splitlines():
408
+ if f":{port}" in line and "LISTENING" in line:
409
+ parts = line.strip().split()
410
+ if len(parts) >= 5:
411
+ pids.add(parts[-1])
412
+ for pid in pids:
413
+ subprocess.run(f"taskkill /PID {pid} /F", shell=True, capture_output=True, **kwargs)
414
+ else:
415
+ subprocess.run(f"lsof -ti:{port} | xargs kill -9", shell=True, capture_output=True)
416
+ return True
417
+ except Exception:
418
+ return False
419
+
420
+ def end_mcp_session(self, path: str) -> bool:
421
+ """End an MCP-only session by writing a session_save event to the activity log."""
422
+ live = self._get_live_session_info(path)
423
+ if not live:
424
+ return False
425
+ activity = ActivityLog(path)
426
+ activity.log("session_save", {"session_id": live["session_id"], "source": "hub"})
427
+ return True
428
+
429
+ def update_last_session(self, path: str):
430
+ path = str(Path(path).resolve())
431
+ projects = self._read_projects()
432
+ for p in projects:
433
+ if p["path"] == path:
434
+ p["last_session"] = datetime.utcnow().isoformat() + "Z"
435
+ break
436
+ self._write_projects(projects)
437
+
438
+ def get_project_details(self, path: str) -> dict:
439
+ path = str(Path(path).resolve())
440
+ cfg = self._read_project_config(path)
441
+ ide = cfg.get("ide", "unknown")
442
+ mcp_cfg = cfg.get("mcp", {})
443
+ mcp_mode = mcp_cfg.get("mode", "unknown") if isinstance(mcp_cfg, dict) else "unknown"
444
+ mcp_installed = False
445
+ mcp_config_path = None
446
+ mcp_servers: list = []
447
+ try:
448
+ from core.ide import PROFILES
449
+ profile = PROFILES.get(ide)
450
+ if profile:
451
+ if profile.config_path_global:
452
+ mcp_file = Path.home() / profile.config_path
453
+ else:
454
+ mcp_file = Path(path) / profile.config_path
455
+ if mcp_file.exists():
456
+ mcp_installed = True
457
+ mcp_config_path = str(mcp_file)
458
+ if profile.config_format == "json":
459
+ with open(mcp_file, encoding="utf-8") as f:
460
+ mcp_data = json.load(f)
461
+ servers = mcp_data.get(profile.config_key, {})
462
+ if isinstance(servers, dict):
463
+ for name, conf in servers.items():
464
+ mcp_servers.append({
465
+ "name": name,
466
+ "command": conf.get("command", ""),
467
+ "args": conf.get("args", []),
468
+ "type": conf.get("type", ""),
469
+ "env_keys": list((conf.get("env") or {}).keys()),
470
+ })
471
+ elif profile.config_format == "toml":
472
+ try:
473
+ import tomllib
474
+ except ImportError:
475
+ try:
476
+ import tomli as tomllib
477
+ except ImportError:
478
+ tomllib = None
479
+ if tomllib:
480
+ with open(mcp_file, "rb") as f:
481
+ toml_data = tomllib.load(f)
482
+ for name, conf in toml_data.get("mcp_servers", {}).items():
483
+ mcp_servers.append({
484
+ "name": name,
485
+ "command": conf.get("command", ""),
486
+ "args": conf.get("args", []),
487
+ "type": "",
488
+ "env_keys": [],
489
+ })
490
+ except Exception:
491
+ pass
492
+ return {
493
+ "path": path,
494
+ "c3_version": cfg.get("version"),
495
+ "ide": ide,
496
+ "mcp_mode": mcp_mode,
497
+ "mcp_installed": mcp_installed,
498
+ "mcp_config_path": mcp_config_path,
499
+ "mcp_servers": mcp_servers,
500
+ "initialized": bool(cfg),
501
+ }
502
+
503
+ def update_project(self, path: str, **fields) -> bool:
504
+ """Update editable project fields: name, tags, notes."""
505
+ path = str(Path(path).resolve())
506
+ projects = self._read_projects()
507
+ allowed = {"name", "tags", "notes", "autostart_ui"}
508
+ for p in projects:
509
+ if p["path"] == path:
510
+ for k, v in fields.items():
511
+ if k in allowed:
512
+ p[k] = v
513
+ self._write_projects(projects)
514
+ return True
515
+ return False
516
+
517
+ def get_autostart_projects(self) -> list[str]:
518
+ """Return paths of projects with autostart_ui=True that are accessible."""
519
+ return [
520
+ p["path"] for p in self._read_projects()
521
+ if p.get("autostart_ui") and Path(p["path"]).is_dir()
522
+ ]
523
+
524
+ def rename_project(self, path: str, new_name: str) -> bool:
525
+ path = str(Path(path).resolve())
526
+ projects = self._read_projects()
527
+ for p in projects:
528
+ if p["path"] == path:
529
+ p["name"] = new_name
530
+ self._write_projects(projects)
531
+ return True
532
+ return False
533
+
534
+ def transfer_project(self, old_path: str, new_path: str) -> dict:
535
+ """Transfer a project registration from old_path to new_path.
536
+
537
+ Validates the new location exists with a .c3/ directory and updates
538
+ the registry to point there. Does not move files — the user handles
539
+ the actual copy/move.
540
+ """
541
+ old_path = str(Path(old_path).resolve())
542
+ new_path = str(Path(new_path).resolve())
543
+
544
+ if old_path == new_path:
545
+ return {"transferred": False, "error": "Paths are identical"}
546
+
547
+ if not Path(new_path).is_dir():
548
+ return {"transferred": False, "error": "New path does not exist"}
549
+
550
+ if not (Path(new_path) / ".c3").is_dir():
551
+ return {"transferred": False, "error": "New path has no .c3 directory"}
552
+
553
+ projects = self._read_projects()
554
+
555
+ entry = None
556
+ for p in projects:
557
+ if p["path"] == old_path:
558
+ entry = p
559
+ break
560
+ if entry is None:
561
+ return {"transferred": False, "error": "Project not registered"}
562
+
563
+ for p in projects:
564
+ if p["path"] == new_path:
565
+ return {"transferred": False, "error": "New path already registered"}
566
+
567
+ # Update entry
568
+ old_dir_name = Path(old_path).name
569
+ entry["path"] = new_path
570
+ if entry.get("name") == old_dir_name:
571
+ entry["name"] = Path(new_path).name
572
+
573
+ # Re-read config from new location
574
+ cfg = self._read_project_config(new_path)
575
+ if cfg.get("ide"):
576
+ entry["ide"] = cfg["ide"]
577
+
578
+ self._write_projects(projects)
579
+ return {"transferred": True, "old_path": old_path, "new_path": new_path}