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
cli/hub_server.py ADDED
@@ -0,0 +1,1619 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ C3 Project Hub — global project & session manager web server.
4
+
5
+ Features:
6
+ - Dedicated configurable port (stored in ~/.c3/hub_config.json)
7
+ - Single-instance detection: if already running on configured port, opens browser
8
+ - Project CRUD + session management
9
+ - Init and MCP install runners
10
+
11
+ Launch with: c3 hub [--port 3330] [--no-browser]
12
+ """
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import shlex
18
+ import shutil
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import urllib.request
24
+ import webbrowser
25
+ from pathlib import Path
26
+
27
+ from flask import Flask, jsonify, request, send_from_directory
28
+
29
+ sys.path.insert(0, str(Path(__file__).parent.parent))
30
+
31
+ from core.ide import PROFILES, detect_ide, get_profile, load_ide_config, normalize_ide_name
32
+ from services.activity_log import ActivityLog
33
+ from services.project_manager import ProjectManager
34
+ from services.tool_classifier import CATEGORIES
35
+
36
+ app = Flask(__name__, static_folder=str(Path(__file__).parent))
37
+
38
+ # ─── Hub config ───────────────────────────────────────────────────────────────
39
+
40
+ _GLOBAL_C3_DIR = Path.home() / ".c3"
41
+ _HUB_CONFIG_FILE = _GLOBAL_C3_DIR / "hub_config.json"
42
+
43
+ _HUB_CONFIG_DEFAULTS = {
44
+ "port": 3330,
45
+ "host": "127.0.0.1", # loopback only by default; opt-in to 0.0.0.0 for LAN
46
+ "auto_open_browser": True,
47
+ "theme": "dark",
48
+ "projects_view": "list",
49
+ "oracle_url": "",
50
+ }
51
+
52
+
53
+ def _read_hub_config() -> dict:
54
+ cfg = dict(_HUB_CONFIG_DEFAULTS)
55
+ try:
56
+ if _HUB_CONFIG_FILE.exists():
57
+ with open(_HUB_CONFIG_FILE, encoding="utf-8") as f:
58
+ cfg.update(json.load(f))
59
+ except Exception:
60
+ pass
61
+ return cfg
62
+
63
+
64
+ def _write_hub_config(cfg: dict):
65
+ _GLOBAL_C3_DIR.mkdir(parents=True, exist_ok=True)
66
+ merged = dict(_HUB_CONFIG_DEFAULTS)
67
+ merged.update(cfg)
68
+ with open(_HUB_CONFIG_FILE, "w", encoding="utf-8") as f:
69
+ json.dump(merged, f, indent=2)
70
+
71
+
72
+ # ─── C3 version ───────────────────────────────────────────────────────────────
73
+
74
+ _C3_PY = Path(__file__).parent / "c3.py"
75
+ _ver_pat = re.compile(r'__version__\s*=\s*["\']([^"\']+)["\']')
76
+ try:
77
+ C3_VERSION = _ver_pat.search(_C3_PY.read_text(encoding="utf-8-sig")).group(1)
78
+ except Exception:
79
+ C3_VERSION = "unknown"
80
+
81
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
82
+
83
+
84
+ def _pm() -> ProjectManager:
85
+ return ProjectManager()
86
+
87
+
88
+ def _port_free(port: int) -> bool:
89
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
90
+ return s.connect_ex(("127.0.0.1", port)) != 0
91
+
92
+
93
+ def _find_free_port(start: int, tries: int = 20) -> int:
94
+ for port in range(start, start + tries):
95
+ if _port_free(port):
96
+ return port
97
+ raise RuntimeError(f"No free port found near {start}")
98
+
99
+
100
+ def _is_hub_running(port: int) -> bool:
101
+ """Return True if a C3 hub is already listening on this port."""
102
+ try:
103
+ url = f"http://127.0.0.1:{port}/api/health"
104
+ with urllib.request.urlopen(url, timeout=1) as r:
105
+ data = json.loads(r.read())
106
+ return data.get("service") == "c3-hub"
107
+ except Exception:
108
+ return False
109
+
110
+
111
+ def _run_c3(args: list, cwd: str = None, timeout: int = 90) -> dict:
112
+ env = os.environ.copy()
113
+ env["PYTHONPATH"] = str(Path(__file__).parent.parent)
114
+ env["NO_COLOR"] = "1"
115
+ env["TERM"] = "dumb"
116
+ cmd = [sys.executable, str(_C3_PY)] + args
117
+ try:
118
+ kwargs = {}
119
+ if sys.platform == "win32":
120
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
121
+ result = subprocess.run(
122
+ cmd,
123
+ capture_output=True, text=True, env=env,
124
+ encoding="utf-8", errors="replace",
125
+ cwd=cwd or str(Path(__file__).parent.parent),
126
+ timeout=timeout,
127
+ **kwargs
128
+ )
129
+ output = (result.stdout or "") + (result.stderr or "")
130
+ return {"success": result.returncode == 0, "output": output.strip(), "returncode": result.returncode}
131
+ except subprocess.TimeoutExpired:
132
+ return {"success": False, "output": "Command timed out.", "returncode": -1}
133
+ except Exception as e:
134
+ return {"success": False, "output": str(e), "returncode": -1}
135
+
136
+
137
+ def _resolve_project_path(path: str) -> Path:
138
+ resolved = Path(path or ".").resolve()
139
+ if not resolved.exists():
140
+ raise ValueError(f"Project path not found: {resolved}")
141
+ return resolved
142
+
143
+
144
+ def _resolve_project_ide_profile(project_path: str, ide_name: str | None):
145
+ project_root = _resolve_project_path(project_path)
146
+ requested = (ide_name or "").strip().lower()
147
+ if requested and requested != "auto":
148
+ requested = normalize_ide_name(requested)
149
+ if requested not in PROFILES:
150
+ raise ValueError(f"Unknown IDE profile: {requested}")
151
+ return project_root, requested, get_profile(requested)
152
+
153
+ configured_ide = load_ide_config(str(project_root))
154
+ detected_ide = detect_ide(str(project_root))
155
+ active_ide = configured_ide or detected_ide or "claude-code"
156
+ return project_root, active_ide, get_profile(active_ide)
157
+
158
+
159
+ def _project_mcp_config_path(project_root: Path, profile) -> Path:
160
+ return (Path.home() / profile.config_path) if profile.config_path_global else (project_root / profile.config_path)
161
+
162
+
163
+ def _parse_toml_mcp_servers(content: str) -> dict:
164
+ servers = {}
165
+ current_server = None
166
+
167
+ for raw in content.splitlines():
168
+ line = raw.split("#", 1)[0].strip()
169
+ if not line:
170
+ continue
171
+
172
+ if line.startswith("[") and line.endswith("]"):
173
+ section = line[1:-1].strip()
174
+ if section.startswith("mcp_servers."):
175
+ current_server = section.split(".", 1)[1]
176
+ servers.setdefault(current_server, {})
177
+ else:
178
+ current_server = None
179
+ continue
180
+
181
+ if not current_server or "=" not in line:
182
+ continue
183
+
184
+ key, value = line.split("=", 1)
185
+ key = key.strip().strip('"')
186
+ value = value.strip()
187
+
188
+ if key == "args":
189
+ servers[current_server]["args"] = re.findall(r"[\"']([^\"']*)[\"']", value)
190
+ elif key in ("command", "type"):
191
+ match = re.match(r"^[\"'](.*)[\"']$", value)
192
+ servers[current_server][key] = match.group(1) if match else value
193
+ elif key == "enabled":
194
+ low = value.lower()
195
+ if low.startswith("true"):
196
+ servers[current_server]["enabled"] = True
197
+ elif low.startswith("false"):
198
+ servers[current_server]["enabled"] = False
199
+ else:
200
+ servers[current_server][key] = value
201
+
202
+ return servers
203
+
204
+
205
+ def _read_project_mcp_servers_for_profile(profile, mcp_file: Path) -> tuple[dict, dict]:
206
+ if not mcp_file.exists():
207
+ return {}, {}
208
+
209
+ if profile.config_format == "toml":
210
+ content = mcp_file.read_text(encoding="utf-8")
211
+ return _parse_toml_mcp_servers(content), {}
212
+
213
+ with open(mcp_file, encoding="utf-8") as f:
214
+ raw_config = json.load(f)
215
+ servers = raw_config.get(profile.config_key, {})
216
+ if not isinstance(servers, dict):
217
+ servers = {}
218
+ return servers, raw_config
219
+
220
+
221
+ def _toml_escape_str(value: str) -> str:
222
+ return value.replace("\\", "/")
223
+
224
+
225
+ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
226
+ content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
227
+ header = f"[{section}]"
228
+
229
+ lines = content.splitlines()
230
+ new_lines = []
231
+ skip = False
232
+ for line in lines:
233
+ stripped = line.strip()
234
+ if stripped == header:
235
+ skip = True
236
+ continue
237
+ if skip and stripped.startswith("["):
238
+ skip = False
239
+ if not skip:
240
+ new_lines.append(line)
241
+
242
+ content = "\n".join(new_lines).rstrip()
243
+ section_lines = [f"\n\n{header}"]
244
+ for key, value in entries.items():
245
+ if isinstance(value, list):
246
+ items = ", ".join(f'"{_toml_escape_str(str(item))}"' for item in value)
247
+ section_lines.append(f'{key} = [{items}]')
248
+ elif isinstance(value, bool):
249
+ section_lines.append(f'{key} = {"true" if value else "false"}')
250
+ else:
251
+ section_lines.append(f'{key} = "{_toml_escape_str(str(value))}"')
252
+ section_lines.append("")
253
+
254
+ toml_path.parent.mkdir(parents=True, exist_ok=True)
255
+ toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8")
256
+
257
+
258
+ def _remove_toml_section(toml_path: Path, section: str) -> bool:
259
+ if not toml_path.exists():
260
+ return False
261
+ content = toml_path.read_text(encoding="utf-8")
262
+ header = f"[{section}]"
263
+
264
+ lines = content.splitlines()
265
+ new_lines = []
266
+ skip = False
267
+ removed = False
268
+ for line in lines:
269
+ stripped = line.strip()
270
+ if stripped == header:
271
+ skip = True
272
+ removed = True
273
+ continue
274
+ if skip and stripped.startswith("["):
275
+ skip = False
276
+ if not skip:
277
+ new_lines.append(line)
278
+
279
+ if removed:
280
+ remaining = "\n".join(new_lines).rstrip()
281
+ if remaining:
282
+ toml_path.write_text(remaining + "\n", encoding="utf-8")
283
+ else:
284
+ toml_path.unlink()
285
+ return removed
286
+
287
+
288
+ def _build_mcp_cli_capabilities() -> dict:
289
+ return {
290
+ "commands": [
291
+ {
292
+ "name": "install-mcp",
293
+ "usage": "c3 install-mcp [project_path] [ide] --ide <ide> --mcp-mode <direct|proxy>",
294
+ "summary": "Install or update the C3 MCP entrypoint for the target IDE profile.",
295
+ "options": ["project_path", "ide", "--ide", "--mcp-mode"],
296
+ },
297
+ {
298
+ "name": "mcp-install",
299
+ "usage": "c3 mcp-install [project_path] [ide] --ide <ide> --mcp-mode <direct|proxy>",
300
+ "summary": "Alias for install-mcp.",
301
+ "options": ["project_path", "ide", "--ide", "--mcp-mode"],
302
+ },
303
+ {
304
+ "name": "mcp-remove",
305
+ "usage": "c3 mcp-remove <name> [project_path] --ide <ide>",
306
+ "summary": "Remove a named MCP server from the target IDE configuration.",
307
+ "options": ["name", "project_path", "--ide"],
308
+ },
309
+ ],
310
+ "modes": ["direct", "proxy"],
311
+ "ides": [
312
+ {"value": value, "label": profile.display_name}
313
+ for value, profile in PROFILES.items()
314
+ if value != "antigravity"
315
+ ],
316
+ "tool_categories": [
317
+ {
318
+ "name": name,
319
+ "priority": info.get("priority", 99),
320
+ "tools": info.get("tools", []),
321
+ }
322
+ for name, info in sorted(CATEGORIES.items(), key=lambda item: item[1].get("priority", 99))
323
+ ],
324
+ }
325
+
326
+
327
+ def _serialize_mcp_servers(profile, servers: dict) -> list[dict]:
328
+ items = []
329
+ for name, conf in (servers or {}).items():
330
+ if not isinstance(conf, dict):
331
+ continue
332
+ items.append({
333
+ "name": name,
334
+ "command": conf.get("command", ""),
335
+ "args": conf.get("args", []),
336
+ "type": conf.get("type", ""),
337
+ "env_keys": list((conf.get("env") or {}).keys()),
338
+ "enabled": conf.get("enabled", True),
339
+ })
340
+ return items
341
+
342
+
343
+ def _detail_mode_from_servers(servers: dict, fallback: str) -> str:
344
+ c3_entry = (servers or {}).get("c3", {})
345
+ args = c3_entry.get("args", []) if isinstance(c3_entry, dict) else []
346
+ for arg in args:
347
+ if isinstance(arg, str) and arg.endswith("mcp_proxy.py"):
348
+ return "proxy"
349
+ if isinstance(arg, str) and arg.endswith("mcp_server.py"):
350
+ return "direct"
351
+ return fallback
352
+
353
+
354
+ def _win_find_ide(cmd: str) -> str:
355
+ """Find the full path of VS Code or Cursor on Windows if not in PATH."""
356
+ if sys.platform != "win32":
357
+ return cmd
358
+
359
+ # 1. Try PATH
360
+ p = shutil.which(cmd)
361
+ if p:
362
+ return p
363
+
364
+ # 2. Try common installation paths
365
+ user_appdata = os.environ.get("LocalAppData", "")
366
+ prog_files = os.environ.get("ProgramFiles", "C:\\Program Files")
367
+
368
+ search_paths = []
369
+ if cmd == "code":
370
+ search_paths = [
371
+ Path(user_appdata) / "Programs" / "Microsoft VS Code" / "bin" / "code.cmd",
372
+ Path(prog_files) / "Microsoft VS Code" / "bin" / "code.cmd",
373
+ ]
374
+ elif cmd == "cursor":
375
+ search_paths = [
376
+ Path(user_appdata) / "Programs" / "Cursor" / "bin" / "cursor.cmd",
377
+ Path(user_appdata) / "Programs" / "cursor" / "resources" / "app" / "bin" / "cursor.cmd",
378
+ ]
379
+ elif cmd == "claude-app":
380
+ search_paths = [
381
+ Path(user_appdata) / "Programs" / "Claude" / "Claude.exe",
382
+ Path(user_appdata) / "Programs" / "claude-code" / "Claude Code.exe",
383
+ Path(prog_files) / "Claude" / "Claude.exe",
384
+ ]
385
+
386
+ for p in search_paths:
387
+ if p.exists():
388
+ return str(p)
389
+
390
+ return cmd
391
+
392
+
393
+ # ─── Routes: static ──────────────────────────────────────────────────────────
394
+
395
+ @app.route("/")
396
+ def index():
397
+ return send_from_directory(str(Path(__file__).parent), "hub.html")
398
+
399
+
400
+ # ─── Routes: health & version ────────────────────────────────────────────────
401
+
402
+ @app.route("/api/health")
403
+ def api_health():
404
+ return jsonify({"status": "ok", "service": "c3-hub", "c3_version": C3_VERSION})
405
+
406
+
407
+ @app.route("/api/version")
408
+ def api_version():
409
+ return jsonify({"c3_version": C3_VERSION})
410
+
411
+
412
+ # ─── Routes: hub config ──────────────────────────────────────────────────────
413
+
414
+ @app.route("/api/hub/config", methods=["GET"])
415
+ def api_hub_config_get():
416
+ cfg = _read_hub_config()
417
+ cfg["has_terminal"] = sys.stdin is not None and sys.stdin.isatty()
418
+ return jsonify(cfg)
419
+
420
+
421
+ @app.route("/api/hub/config", methods=["POST"])
422
+ def api_hub_config_set():
423
+ data = request.get_json(force=True) or {}
424
+ cfg = _read_hub_config()
425
+ if "port" in data:
426
+ try:
427
+ cfg["port"] = int(data["port"])
428
+ except (ValueError, TypeError):
429
+ return jsonify({"error": "port must be an integer"}), 400
430
+ if "auto_open_browser" in data:
431
+ cfg["auto_open_browser"] = bool(data["auto_open_browser"])
432
+ if "theme" in data:
433
+ theme = str(data["theme"]).strip().lower()
434
+ if theme not in {"dark", "light"}:
435
+ return jsonify({"error": "theme must be 'dark' or 'light'"}), 400
436
+ cfg["theme"] = theme
437
+ if "projects_view" in data:
438
+ projects_view = str(data["projects_view"]).strip().lower()
439
+ if projects_view not in {"list", "grid"}:
440
+ return jsonify({"error": "projects_view must be 'list' or 'grid'"}), 400
441
+ cfg["projects_view"] = projects_view
442
+ if "oracle_url" in data:
443
+ cfg["oracle_url"] = str(data["oracle_url"]).strip()
444
+ _write_hub_config(cfg)
445
+ return jsonify({"saved": True, "config": cfg})
446
+
447
+
448
+ # ─── Routes: projects ────────────────────────────────────────────────────────
449
+
450
+ def _notification_count(project_path: str) -> int:
451
+ """Count unacknowledged notifications for a project by reading its .c3/notifications.jsonl."""
452
+ nf = Path(project_path) / ".c3" / "notifications.jsonl"
453
+ if not nf.exists():
454
+ return 0
455
+ count = 0
456
+ try:
457
+ for line in nf.read_text(encoding="utf-8").strip().splitlines():
458
+ if not line.strip():
459
+ continue
460
+ try:
461
+ entry = json.loads(line)
462
+ if not entry.get("acknowledged"):
463
+ count += 1
464
+ except json.JSONDecodeError:
465
+ continue
466
+ except OSError:
467
+ pass
468
+ return count
469
+
470
+
471
+ @app.route("/api/projects", methods=["GET"])
472
+ def api_projects_list():
473
+ try:
474
+ projects = _pm().list_projects()
475
+ for p in projects:
476
+ p["notification_count"] = _notification_count(p.get("path", ""))
477
+ return jsonify(projects)
478
+ except Exception as e:
479
+ return jsonify({"error": str(e)}), 500
480
+
481
+
482
+ @app.route("/api/projects", methods=["POST"])
483
+ def api_projects_add():
484
+ data = request.get_json(force=True) or {}
485
+ path = (data.get("path") or "").strip()
486
+ name = (data.get("name") or "").strip() or None
487
+ if not path:
488
+ return jsonify({"error": "path is required"}), 400
489
+ try:
490
+ result = _pm().add_project(path, name)
491
+ resolved = str(Path(path).resolve())
492
+ result["c3_initialized"] = (Path(resolved) / ".c3").exists()
493
+ return jsonify(result), 201
494
+ except Exception as e:
495
+ return jsonify({"error": str(e)}), 500
496
+
497
+
498
+ @app.route("/api/projects/remove", methods=["POST"])
499
+ def api_projects_remove():
500
+ data = request.get_json(force=True) or {}
501
+ path = (data.get("path") or "").strip()
502
+ if not path:
503
+ return jsonify({"error": "path is required"}), 400
504
+ try:
505
+ return jsonify({"removed": _pm().remove_project(path)})
506
+ except Exception as e:
507
+ return jsonify({"error": str(e)}), 500
508
+
509
+
510
+ @app.route("/api/projects/open", methods=["POST"])
511
+ def api_projects_open():
512
+ """Open project directory in OS file explorer. Body: {path}"""
513
+ try:
514
+ data = request.get_json(force=True) or {}
515
+ path_str = (data.get("path") or "").strip()
516
+ if not path_str:
517
+ return jsonify({"error": "path is required"}), 400
518
+
519
+ path = Path(path_str).resolve()
520
+ if not path.exists():
521
+ return jsonify({"error": f"Path does not exist: {path_str}"}), 404
522
+
523
+ if sys.platform == "win32":
524
+ os.startfile(str(path))
525
+ elif sys.platform == "darwin":
526
+ subprocess.run(["open", str(path)], check=True)
527
+ else:
528
+ subprocess.run(["xdg-open", str(path)], check=True)
529
+ return jsonify({"opened": True})
530
+ except Exception as e:
531
+ return jsonify({"error": f"Failed to open folder: {str(e)}"}), 500
532
+
533
+
534
+ @app.route("/api/projects/launch-ide", methods=["POST"])
535
+ def api_launch_ide():
536
+ """Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}"""
537
+ _IDE_CMDS = {
538
+ "claude-code": ("claude", False),
539
+ "claude-app": ("claude-app", True),
540
+ "codex": ("codex", False),
541
+ "gemini": ("gemini", False),
542
+ "antigravity": ("antigravity", False),
543
+ "vscode": ("code", True),
544
+ "cursor": ("cursor", True),
545
+ }
546
+ try:
547
+ data = request.get_json(force=True) or {}
548
+ path_str = (data.get("path") or "").strip()
549
+ ide = (data.get("ide") or "").strip()
550
+ custom_cmd = (data.get("custom_cmd") or "").strip()
551
+
552
+ if not path_str:
553
+ return jsonify({"error": "path is required"}), 400
554
+ if not ide:
555
+ return jsonify({"error": "ide is required"}), 400
556
+
557
+ path = Path(path_str).resolve()
558
+ if not path.exists():
559
+ return jsonify({"error": f"Path does not exist: {path_str}"}), 404
560
+
561
+ if ide == "custom":
562
+ if not custom_cmd:
563
+ return jsonify({"error": "custom_cmd is required for custom IDE"}), 400
564
+ cmd, is_gui = custom_cmd, False
565
+ elif ide in _IDE_CMDS:
566
+ cmd, is_gui = _IDE_CMDS[ide]
567
+ else:
568
+ return jsonify({"error": f"Unknown IDE: {ide}"}), 400
569
+
570
+ if is_gui:
571
+ # GUI IDEs (VS Code, Cursor) accept a path argument directly
572
+ if sys.platform == "win32":
573
+ if cmd == "claude-app":
574
+ # Windows Store app — find package family name dynamically and launch via explorer
575
+ try:
576
+ pfn = subprocess.check_output(
577
+ ["powershell", "-NoProfile", "-Command",
578
+ "(Get-AppxPackage | Where-Object { $_.Name -like '*claude*' } | Select-Object -First 1).PackageFamilyName"],
579
+ text=True, timeout=8
580
+ ).strip()
581
+ except Exception:
582
+ pfn = ""
583
+ if not pfn:
584
+ return jsonify({"error": "Claude app not found. Install it from the Microsoft Store."}), 404
585
+ subprocess.Popen(
586
+ ["explorer.exe", f"shell:AppsFolder\\{pfn}!App"],
587
+ creationflags=subprocess.CREATE_NO_WINDOW,
588
+ )
589
+ else:
590
+ # Find full path if not in PATH
591
+ full_cmd = _win_find_ide(cmd)
592
+ # Launch exe directly — avoids cmd.exe splitting paths with spaces
593
+ subprocess.Popen(
594
+ [full_cmd, str(path)],
595
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
596
+ )
597
+ else:
598
+ kwargs = {"start_new_session": True}
599
+ subprocess.Popen([cmd, str(path)], **kwargs)
600
+ else:
601
+ # Terminal CLIs: open a new terminal window running the command
602
+ if sys.platform == "win32":
603
+ # On Windows, use the command directly (globally installed CLIs)
604
+ win_cmd = cmd
605
+
606
+ # Try Windows Terminal first, fall back to cmd
607
+ try:
608
+ # Windows Terminal 'wt' needs a full command to run
609
+ # We wrap the command in 'cmd /k' so the terminal stays open
610
+ subprocess.Popen(
611
+ ["wt", "-d", str(path), "cmd", "/k", win_cmd],
612
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
613
+ )
614
+ except FileNotFoundError:
615
+ # Fallback to classic cmd.exe
616
+ subprocess.Popen(
617
+ ["cmd", "/c", "start", "", "cmd", "/k", win_cmd],
618
+ cwd=str(path),
619
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
620
+ )
621
+ elif sys.platform == "darwin":
622
+ script = (
623
+ f'tell application "Terminal" to do script '
624
+ f'"cd {shlex.quote(str(path))} && {cmd}"'
625
+ )
626
+ subprocess.Popen(["osascript", "-e", script])
627
+ else:
628
+ q = shlex.quote(str(path))
629
+ for term_args in [
630
+ ["gnome-terminal", "--", "bash", "-c", f"cd {q} && {cmd}; exec bash"],
631
+ ["xterm", "-e", f"bash -c 'cd {q} && {cmd}; exec bash'"],
632
+ ["konsole", "-e", "bash", "-c", f"cd {q} && {cmd}; exec bash"],
633
+ ["xfce4-terminal", "--command", f"bash -c 'cd {q} && {cmd}; exec bash'"],
634
+ ]:
635
+ try:
636
+ subprocess.Popen(term_args, start_new_session=True)
637
+ break
638
+ except FileNotFoundError:
639
+ continue
640
+
641
+ return jsonify({"launched": True})
642
+ except Exception as e:
643
+ return jsonify({"error": str(e)}), 500
644
+
645
+
646
+ @app.route("/api/projects/update", methods=["POST"])
647
+ def api_projects_update():
648
+ """Update editable project fields (name, tags, notes). Body: {path, name?, tags?, notes?}"""
649
+ data = request.get_json(force=True) or {}
650
+ path = (data.get("path") or "").strip()
651
+ if not path:
652
+ return jsonify({"error": "path is required"}), 400
653
+ fields = {}
654
+ if "name" in data:
655
+ fields["name"] = str(data["name"]).strip()
656
+ if "tags" in data:
657
+ # Accept list or comma-separated string
658
+ raw = data["tags"]
659
+ if isinstance(raw, list):
660
+ fields["tags"] = [t.strip() for t in raw if str(t).strip()]
661
+ else:
662
+ fields["tags"] = [t.strip() for t in str(raw).split(",") if t.strip()]
663
+ if "notes" in data:
664
+ fields["notes"] = str(data["notes"])
665
+ try:
666
+ ok = _pm().update_project(path, **fields)
667
+ return jsonify({"updated": ok})
668
+ except Exception as e:
669
+ return jsonify({"error": str(e)}), 500
670
+
671
+
672
+ @app.route("/api/projects/rename", methods=["POST"])
673
+ def api_projects_rename():
674
+ data = request.get_json(force=True) or {}
675
+ path = (data.get("path") or "").strip()
676
+ name = (data.get("name") or "").strip()
677
+ if not path or not name:
678
+ return jsonify({"error": "path and name are required"}), 400
679
+ try:
680
+ return jsonify({"renamed": _pm().rename_project(path, name)})
681
+ except Exception as e:
682
+ return jsonify({"error": str(e)}), 500
683
+
684
+
685
+ @app.route("/api/projects/transfer", methods=["POST"])
686
+ def api_projects_transfer():
687
+ """Transfer project registration to a new path. Body: {old_path, new_path}"""
688
+ data = request.get_json(force=True) or {}
689
+ old_path = (data.get("old_path") or "").strip()
690
+ new_path = (data.get("new_path") or "").strip()
691
+ if not old_path or not new_path:
692
+ return jsonify({"error": "old_path and new_path are required"}), 400
693
+ try:
694
+ result = _pm().transfer_project(old_path, new_path)
695
+ if result.get("error"):
696
+ return jsonify(result), 400
697
+ return jsonify(result)
698
+ except Exception as e:
699
+ return jsonify({"error": str(e)}), 500
700
+
701
+
702
+ @app.route("/api/projects/details", methods=["POST"])
703
+ def api_projects_details():
704
+ data = request.get_json(force=True) or {}
705
+ path = (data.get("path") or "").strip()
706
+ ide_override = (data.get("ide") or "").strip() or None
707
+ if not path:
708
+ return jsonify({"error": "path is required"}), 400
709
+ try:
710
+ details = _pm().get_project_details(path)
711
+ if ide_override:
712
+ project_root, ide_name, profile = _resolve_project_ide_profile(path, ide_override)
713
+ mcp_file = _project_mcp_config_path(project_root, profile)
714
+ servers, _ = _read_project_mcp_servers_for_profile(profile, mcp_file)
715
+ details["ide"] = ide_name
716
+ details["mcp_installed"] = mcp_file.exists()
717
+ details["mcp_config_path"] = str(mcp_file) if mcp_file.exists() else None
718
+ details["mcp_servers"] = _serialize_mcp_servers(profile, servers)
719
+ details["mcp_mode"] = _detail_mode_from_servers(servers, details.get("mcp_mode", "unknown"))
720
+ details["hub_c3_version"] = C3_VERSION
721
+ return jsonify(details)
722
+ except Exception as e:
723
+ return jsonify({"error": str(e)}), 500
724
+
725
+
726
+ @app.route("/api/projects/mcp-capabilities", methods=["GET"])
727
+ def api_projects_mcp_capabilities():
728
+ return jsonify(_build_mcp_cli_capabilities())
729
+
730
+
731
+ @app.route("/api/projects/mcp-server-add", methods=["POST"])
732
+ def api_projects_mcp_server_add():
733
+ try:
734
+ data = request.get_json(force=True) or {}
735
+ path = (data.get("path") or "").strip()
736
+ name = (data.get("name") or "").strip()
737
+ command = (data.get("command") or "").strip()
738
+ ide = (data.get("ide") or "").strip() or None
739
+ args = data.get("args") or []
740
+ env = data.get("env") or {}
741
+ enabled = bool(data.get("enabled", True))
742
+
743
+ if not path or not name or not command:
744
+ return jsonify({"error": "path, name, and command are required"}), 400
745
+ if not isinstance(args, list):
746
+ return jsonify({"error": "args must be an array"}), 400
747
+ if not isinstance(env, dict):
748
+ return jsonify({"error": "env must be an object"}), 400
749
+
750
+ project_root, ide_name, profile = _resolve_project_ide_profile(path, ide)
751
+ mcp_file = _project_mcp_config_path(project_root, profile)
752
+ mcp_file.parent.mkdir(parents=True, exist_ok=True)
753
+
754
+ if profile.config_format == "toml":
755
+ entries = {"command": command, "args": args}
756
+ if profile.name == "codex":
757
+ entries["enabled"] = enabled
758
+ _upsert_toml_section(mcp_file, f"{profile.config_key}.{name}", entries)
759
+ else:
760
+ servers, raw_config = _read_project_mcp_servers_for_profile(profile, mcp_file)
761
+ server_config = {"command": command, "args": args}
762
+ if env:
763
+ server_config["env"] = env
764
+ if profile.needs_type_field:
765
+ server_config["type"] = "stdio"
766
+ if profile.name == "codex":
767
+ server_config["enabled"] = enabled
768
+
769
+ servers[name] = server_config
770
+ if not raw_config:
771
+ raw_config = {}
772
+ raw_config.setdefault(profile.config_key, {})
773
+ raw_config[profile.config_key] = servers
774
+ with open(mcp_file, "w", encoding="utf-8") as f:
775
+ json.dump(raw_config, f, indent=2)
776
+ f.write("\n")
777
+
778
+ return jsonify({"success": True, "ide": ide_name, "config_path": str(mcp_file)})
779
+ except ValueError as e:
780
+ return jsonify({"error": str(e)}), 400
781
+ except Exception as e:
782
+ return jsonify({"error": str(e)}), 500
783
+
784
+
785
+ @app.route("/api/projects/activity", methods=["POST"])
786
+ def api_projects_activity():
787
+ data = request.get_json(force=True) or {}
788
+ path = (data.get("path") or "").strip()
789
+ if not path:
790
+ return jsonify({"error": "path is required"}), 400
791
+
792
+ try:
793
+ limit = int(data.get("limit", 120))
794
+ except (TypeError, ValueError):
795
+ return jsonify({"error": "limit must be an integer"}), 400
796
+
797
+ limit = max(1, min(limit, 500))
798
+ since = (data.get("since") or "").strip() or None
799
+ event_type = (data.get("event_type") or "").strip() or None
800
+
801
+ try:
802
+ projects = _pm().list_projects()
803
+ project = next((p for p in projects if p.get("path") == path), None)
804
+ events = ActivityLog(path).get_recent(limit=limit, event_type=event_type, since=since)
805
+ latest_ts = events[0]["timestamp"] if events else since
806
+ return jsonify({
807
+ "path": path,
808
+ "project": project,
809
+ "events": events,
810
+ "latest_timestamp": latest_ts,
811
+ })
812
+ except Exception as e:
813
+ return jsonify({"error": str(e)}), 500
814
+
815
+
816
+ @app.route("/api/projects/notifications", methods=["POST"])
817
+ def api_project_notifications():
818
+ """Get notifications for a project by reading its .c3/notifications.jsonl."""
819
+ data = request.get_json(force=True) or {}
820
+ path = (data.get("path") or "").strip()
821
+ if not path:
822
+ return jsonify({"error": "path is required"}), 400
823
+ limit = min(int(data.get("limit", 50)), 200)
824
+ nf = Path(path) / ".c3" / "notifications.jsonl"
825
+ if not nf.exists():
826
+ return jsonify({"notifications": [], "total": 0})
827
+ try:
828
+ entries = []
829
+ for line in nf.read_text(encoding="utf-8").strip().splitlines():
830
+ if not line.strip():
831
+ continue
832
+ try:
833
+ entries.append(json.loads(line))
834
+ except json.JSONDecodeError:
835
+ continue
836
+ unacked = [e for e in entries if not e.get("acknowledged")]
837
+ unacked.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
838
+ return jsonify({"notifications": unacked[:limit], "total": len(unacked)})
839
+ except Exception as e:
840
+ return jsonify({"error": str(e)}), 500
841
+
842
+
843
+ @app.route("/api/projects/notifications/clear", methods=["POST"])
844
+ def api_project_notifications_clear():
845
+ """Acknowledge all notifications for a project."""
846
+ data = request.get_json(force=True) or {}
847
+ path = (data.get("path") or "").strip()
848
+ if not path:
849
+ return jsonify({"error": "path is required"}), 400
850
+ nf = Path(path) / ".c3" / "notifications.jsonl"
851
+ if not nf.exists():
852
+ return jsonify({"cleared": 0})
853
+ try:
854
+ entries = []
855
+ for line in nf.read_text(encoding="utf-8").strip().splitlines():
856
+ if not line.strip():
857
+ continue
858
+ try:
859
+ entries.append(json.loads(line))
860
+ except json.JSONDecodeError:
861
+ continue
862
+ count = 0
863
+ for e in entries:
864
+ if not e.get("acknowledged"):
865
+ e["acknowledged"] = True
866
+ count += 1
867
+ if count:
868
+ nf.write_text(
869
+ "\n".join(json.dumps(e, ensure_ascii=False) for e in entries) + "\n",
870
+ encoding="utf-8",
871
+ )
872
+ return jsonify({"cleared": count})
873
+ except Exception as e:
874
+ return jsonify({"error": str(e)}), 500
875
+
876
+
877
+ # ─── Routes: run commands ────────────────────────────────────────────────────
878
+
879
+ @app.route("/api/projects/run-init", methods=["POST"])
880
+ def api_run_init():
881
+ data = request.get_json(force=True) or {}
882
+ path = (data.get("path") or "").strip()
883
+ ide = (data.get("ide") or "").strip() or None
884
+ mcp_mode = (data.get("mcp_mode") or "").strip() or None
885
+ init_mode = (data.get("init_mode") or "force").strip().lower()
886
+ git = bool(data.get("git"))
887
+ if not path:
888
+ return jsonify({"error": "path is required"}), 400
889
+ if init_mode not in {"force", "clear"}:
890
+ return jsonify({"error": "init_mode must be 'force' or 'clear'"}), 400
891
+ args = ["init", path, f"--{init_mode}"]
892
+ if ide:
893
+ args += ["--ide", ide]
894
+ if mcp_mode and init_mode == "force":
895
+ args += ["--mcp-mode", mcp_mode]
896
+ if git and init_mode == "force":
897
+ args += ["--git"]
898
+ return jsonify(_run_c3(args))
899
+
900
+
901
+ @app.route("/api/projects/health", methods=["POST"])
902
+ def api_project_health():
903
+ """Return health-check data for a single project."""
904
+ data = request.get_json(force=True) or {}
905
+ path = (data.get("path") or "").strip()
906
+ if not path:
907
+ return jsonify({"error": "path is required"}), 400
908
+ try:
909
+ resolved = str(Path(path).resolve())
910
+ from cli.c3 import _check_c3_health
911
+ health = _check_c3_health(resolved)
912
+ health["path"] = resolved
913
+ return jsonify(health)
914
+ except Exception as e:
915
+ return jsonify({"error": str(e)}), 500
916
+
917
+
918
+ @app.route("/api/projects/run-component", methods=["POST"])
919
+ def api_run_component():
920
+ """Run a specific init component: index, dictionary, instructions, config, mcp, embeddings, doc_index."""
921
+ data = request.get_json(force=True) or {}
922
+ path = (data.get("path") or "").strip()
923
+ component = (data.get("component") or "").strip().lower()
924
+ ide = (data.get("ide") or "").strip() or None
925
+ mcp_mode = (data.get("mcp_mode") or "").strip() or None
926
+ if not path:
927
+ return jsonify({"error": "path is required"}), 400
928
+ valid = {"index", "dictionary", "instructions", "config", "mcp", "embeddings", "doc_index"}
929
+ if component not in valid:
930
+ return jsonify({"error": f"component must be one of: {', '.join(sorted(valid))}"}), 400
931
+ try:
932
+ resolved = str(Path(path).resolve())
933
+ c3_dir = Path(resolved) / ".c3"
934
+ if not c3_dir.exists():
935
+ return jsonify({"error": "Project not initialized (.c3 directory missing)"}), 400
936
+
937
+ import contextlib
938
+ import io
939
+ buf = io.StringIO()
940
+
941
+ if component == "index":
942
+ from services.indexer import CodeIndex
943
+ with contextlib.redirect_stdout(buf):
944
+ indexer = CodeIndex(resolved)
945
+ result = indexer.build_index()
946
+ output = buf.getvalue() + f"\nIndexed {result['files_indexed']} files, {result['chunks_created']} chunks."
947
+ return jsonify({"success": True, "output": output.strip()})
948
+
949
+ elif component == "dictionary":
950
+ from services.protocol import CompressionProtocol
951
+ with contextlib.redirect_stdout(buf):
952
+ protocol = CompressionProtocol(resolved)
953
+ new_terms = protocol.build_project_dictionary()
954
+ output = buf.getvalue() + f"\nAdded {len(new_terms)} project-specific terms."
955
+ return jsonify({"success": True, "output": output.strip()})
956
+
957
+ elif component == "instructions":
958
+ from cli.c3 import _sync_project_instruction_docs
959
+ from services.session_manager import SessionManager
960
+ sm = SessionManager(resolved)
961
+ with contextlib.redirect_stdout(buf):
962
+ _sync_project_instruction_docs(resolved, sm)
963
+ return jsonify({"success": True, "output": buf.getvalue().strip()})
964
+
965
+ elif component == "config":
966
+ from cli.c3 import _C3_INIT_SUBDIRS, _build_init_config, save_config
967
+ config = _build_init_config(resolved)
968
+ save_config(config, resolved)
969
+ for subdir in _C3_INIT_SUBDIRS:
970
+ (Path(resolved) / ".c3" / subdir).mkdir(parents=True, exist_ok=True)
971
+ return jsonify({"success": True, "output": "Config refreshed and subdirectories ensured."})
972
+
973
+ elif component == "embeddings":
974
+ from core.config import load_hybrid_config
975
+ from services.embedding_index import EmbeddingIndex
976
+ from services.indexer import CodeIndex
977
+ from services.ollama_client import OllamaClient
978
+ cfg = load_hybrid_config(resolved)
979
+ ollama_url = cfg.get("ollama_base_url", "http://localhost:11434")
980
+ ollama = OllamaClient(ollama_url)
981
+ embed_model = cfg.get("embed_model", "nomic-embed-text")
982
+ ei = EmbeddingIndex(resolved, ollama, embed_model=embed_model)
983
+ if not ei.ready:
984
+ return jsonify({"success": True, "output": "Embedding index skipped (Ollama not available or model not pulled)."})
985
+ indexer = CodeIndex(resolved)
986
+ if not indexer.chunks:
987
+ indexer._load_index()
988
+ if not indexer.chunks:
989
+ indexer.build_index()
990
+ result = ei.build(indexer, force=True)
991
+ output = (f"Embedded {result.get('chunks_embedded', 0)} chunks "
992
+ f"from {result.get('files_processed', 0)} files. "
993
+ f"Total: {result.get('total_embedded', 0)} chunks indexed.")
994
+ return jsonify({"success": True, "output": output})
995
+
996
+ elif component == "doc_index":
997
+ from services.doc_index import DocIndex
998
+ di = DocIndex(resolved)
999
+ result = di.build(force=True)
1000
+ output = (f"Indexed {result['docs_indexed']} docs, "
1001
+ f"{result['chunks_created']} chunks. "
1002
+ f"(skipped {result.get('skipped', 0)} unchanged)")
1003
+ return jsonify({"success": True, "output": output})
1004
+
1005
+ elif component == "mcp":
1006
+ args = ["install-mcp", resolved]
1007
+ if ide:
1008
+ args += ["--ide", ide]
1009
+ if mcp_mode:
1010
+ args += ["--mcp-mode", mcp_mode]
1011
+ return jsonify(_run_c3(args))
1012
+
1013
+ except Exception as e:
1014
+ return jsonify({"error": str(e), "success": False}), 500
1015
+
1016
+
1017
+ _batch_state = {
1018
+ "running": False,
1019
+ "cancelled": False,
1020
+ "results": [],
1021
+ "current": None,
1022
+ "current_index": 0,
1023
+ "total": 0,
1024
+ "done": False,
1025
+ "error": None,
1026
+ }
1027
+ _batch_lock = threading.Lock()
1028
+
1029
+
1030
+ def _batch_worker(projects):
1031
+ """Run batch init in background thread, updating _batch_state."""
1032
+ global _batch_state
1033
+ for i, p in enumerate(projects):
1034
+ with _batch_lock:
1035
+ if _batch_state["cancelled"]:
1036
+ break
1037
+ _batch_state["current"] = p.get("name") or p["path"]
1038
+ _batch_state["current_index"] = i
1039
+
1040
+ path = p["path"]
1041
+ args = ["init", path, "--force"]
1042
+ ide = p.get("ide")
1043
+ if ide and ide != "unknown":
1044
+ args += ["--ide", ide]
1045
+
1046
+ res = _run_c3(args)
1047
+ result = {
1048
+ "path": path,
1049
+ "name": p.get("name"),
1050
+ "success": res.get("success"),
1051
+ "output": res.get("output"),
1052
+ "returncode": res.get("returncode"),
1053
+ }
1054
+ with _batch_lock:
1055
+ _batch_state["results"].append(result)
1056
+
1057
+ with _batch_lock:
1058
+ _batch_state["running"] = False
1059
+ _batch_state["done"] = True
1060
+ _batch_state["current"] = None
1061
+
1062
+
1063
+ @app.route("/api/projects/run-init/batch", methods=["POST"])
1064
+ def api_run_init_batch():
1065
+ """Start batch update of specified projects. Runs in background thread."""
1066
+ global _batch_state
1067
+ with _batch_lock:
1068
+ if _batch_state["running"]:
1069
+ return jsonify({"error": "Batch update already in progress"}), 409
1070
+ try:
1071
+ data = request.get_json(force=True) or {}
1072
+ projects = data.get("projects") or _pm().list_projects()
1073
+ if not projects:
1074
+ return jsonify({"error": "No projects to update"}), 400
1075
+ with _batch_lock:
1076
+ _batch_state = {
1077
+ "running": True,
1078
+ "cancelled": False,
1079
+ "results": [],
1080
+ "current": None,
1081
+ "current_index": 0,
1082
+ "total": len(projects),
1083
+ "done": False,
1084
+ "error": None,
1085
+ }
1086
+ t = threading.Thread(target=_batch_worker, args=(projects,), daemon=True)
1087
+ t.start()
1088
+ return jsonify({"started": True, "total": len(projects)})
1089
+ except Exception as e:
1090
+ return jsonify({"error": str(e)}), 500
1091
+
1092
+
1093
+ @app.route("/api/projects/run-init/batch/status", methods=["GET"])
1094
+ def api_batch_status():
1095
+ """Return current batch update state for polling."""
1096
+ with _batch_lock:
1097
+ return jsonify(dict(_batch_state))
1098
+
1099
+
1100
+ @app.route("/api/projects/run-init/batch/cancel", methods=["POST"])
1101
+ def api_batch_cancel():
1102
+ """Signal cancellation of running batch update."""
1103
+ with _batch_lock:
1104
+ if not _batch_state["running"]:
1105
+ return jsonify({"cancelled": False, "message": "No batch in progress"})
1106
+ _batch_state["cancelled"] = True
1107
+ return jsonify({"cancelled": True})
1108
+
1109
+
1110
+ @app.route("/api/projects/run-mcp", methods=["POST"])
1111
+ def api_run_mcp():
1112
+ data = request.get_json(force=True) or {}
1113
+ path = (data.get("path") or "").strip()
1114
+ ide = (data.get("ide") or "").strip() or None
1115
+ mcp_mode = (data.get("mcp_mode") or "").strip() or None
1116
+ if not path:
1117
+ return jsonify({"error": "path is required"}), 400
1118
+ args = ["install-mcp", path]
1119
+ if ide:
1120
+ args += ["--ide", ide]
1121
+ if mcp_mode:
1122
+ args += ["--mcp-mode", mcp_mode]
1123
+ return jsonify(_run_c3(args, cwd=path))
1124
+
1125
+
1126
+ @app.route("/api/projects/run-mcp-remove", methods=["POST"])
1127
+ def api_run_mcp_remove():
1128
+ data = request.get_json(force=True) or {}
1129
+ path = (data.get("path") or "").strip()
1130
+ name = (data.get("name") or "").strip()
1131
+ ide = (data.get("ide") or "").strip() or None
1132
+ if not path or not name:
1133
+ return jsonify({"error": "path and name are required"}), 400
1134
+ args = ["mcp-remove", name, path]
1135
+ if ide:
1136
+ args += ["--ide", ide]
1137
+ return jsonify(_run_c3(args, cwd=path))
1138
+
1139
+
1140
+ # ─── Routes: project budget config ────────────────────────────────
1141
+
1142
+ @app.route("/api/projects/budget", methods=["POST"])
1143
+ def api_projects_budget_get():
1144
+ """Get budget config for a project. Body: {path}"""
1145
+ data = request.get_json(force=True) or {}
1146
+ path = (data.get("path") or "").strip()
1147
+ if not path:
1148
+ return jsonify({"error": "path is required"}), 400
1149
+ config_file = Path(path) / ".c3" / "config.json"
1150
+ config = {}
1151
+ if config_file.exists():
1152
+ try:
1153
+ with open(config_file, encoding="utf-8") as f:
1154
+ config = json.load(f)
1155
+ except Exception:
1156
+ pass
1157
+ budget = config.get("context_budget", {})
1158
+ hybrid = config.get("hybrid", {})
1159
+ return jsonify({
1160
+ "threshold": budget.get("threshold", 35000),
1161
+ "show_context_nudges": hybrid.get("show_context_nudges", True),
1162
+ })
1163
+
1164
+
1165
+ @app.route("/api/projects/budget", methods=["PUT"])
1166
+ def api_projects_budget_put():
1167
+ """Update budget config for a project. Body: {path, ...settings}"""
1168
+ data = request.get_json(force=True) or {}
1169
+ path = (data.get("path") or "").strip()
1170
+ if not path:
1171
+ return jsonify({"error": "path is required"}), 400
1172
+ config_file = Path(path) / ".c3" / "config.json"
1173
+ config = {}
1174
+ if config_file.exists():
1175
+ try:
1176
+ with open(config_file, encoding="utf-8") as f:
1177
+ config = json.load(f)
1178
+ except Exception:
1179
+ pass
1180
+
1181
+ if "threshold" in data:
1182
+ try:
1183
+ config.setdefault("context_budget", {})["threshold"] = max(1000, int(data["threshold"]))
1184
+ except (ValueError, TypeError):
1185
+ return jsonify({"error": "threshold must be an integer"}), 400
1186
+ for k in ("show_context_nudges",):
1187
+ if k in data:
1188
+ config.setdefault("hybrid", {})[k] = bool(data[k])
1189
+
1190
+ config_file.parent.mkdir(parents=True, exist_ok=True)
1191
+ with open(config_file, "w", encoding="utf-8") as f:
1192
+ json.dump(config, f, indent=2)
1193
+
1194
+ budget = config.get("context_budget", {})
1195
+ hybrid = config.get("hybrid", {})
1196
+ return jsonify({
1197
+ "threshold": budget.get("threshold", 35000),
1198
+ "show_context_nudges": hybrid.get("show_context_nudges", True),
1199
+ })
1200
+
1201
+
1202
+ # ─── Routes: project permissions ──────────────────────────────────────────────
1203
+
1204
+ @app.route("/api/projects/permissions", methods=["POST"])
1205
+ def api_projects_permissions_get():
1206
+ """Get permission tier for a project. Body: {path}"""
1207
+ from cli.c3 import PERMISSION_TIERS, _build_permission_tier, _detect_current_tier
1208
+ from core.ide import load_ide_config
1209
+ data = request.get_json(force=True) or {}
1210
+ path = (data.get("path") or "").strip()
1211
+ if not path:
1212
+ return jsonify({"error": "path is required"}), 400
1213
+ project = Path(path)
1214
+ ide = load_ide_config(str(project))
1215
+ settings_path = project / ".claude" / "settings.local.json"
1216
+ current = _detect_current_tier(settings_path)
1217
+ # Check stored tier
1218
+ config_file = project / ".c3" / "config.json"
1219
+ stored_tier = None
1220
+ if config_file.exists():
1221
+ try:
1222
+ with open(config_file, encoding="utf-8") as f:
1223
+ stored_tier = json.load(f).get("permission_tier")
1224
+ except Exception:
1225
+ pass
1226
+ allow_count = deny_count = 0
1227
+ if settings_path.exists():
1228
+ try:
1229
+ with open(settings_path, encoding="utf-8") as f:
1230
+ s = json.load(f)
1231
+ allow_count = len(s.get("permissions", {}).get("allow", []))
1232
+ deny_count = len(s.get("permissions", {}).get("deny", []))
1233
+ except Exception:
1234
+ pass
1235
+ return jsonify({
1236
+ "current_tier": current or stored_tier,
1237
+ "detected_tier": current,
1238
+ "stored_tier": stored_tier,
1239
+ "allow_count": allow_count,
1240
+ "deny_count": deny_count,
1241
+ "ide": ide,
1242
+ "supported": ide == "claude-code",
1243
+ "tiers": {name: {"description": desc, "allow_count": len(_build_permission_tier(name)["permissions"]["allow"]),
1244
+ "deny_count": len(_build_permission_tier(name)["permissions"]["deny"])}
1245
+ for name, desc in PERMISSION_TIERS.items()},
1246
+ })
1247
+
1248
+
1249
+ @app.route("/api/projects/permissions/apply", methods=["POST"])
1250
+ def api_projects_permissions_put():
1251
+ """Apply permission tier to a project. Body: {path, tier}"""
1252
+ from cli.c3 import PERMISSION_TIERS, _build_permission_tier
1253
+ data = request.get_json(force=True) or {}
1254
+ path = (data.get("path") or "").strip()
1255
+ tier = (data.get("tier") or "").strip()
1256
+ if not path:
1257
+ return jsonify({"error": "path is required"}), 400
1258
+ if tier not in PERMISSION_TIERS:
1259
+ return jsonify({"error": f"Unknown tier: {tier}. Available: {', '.join(PERMISSION_TIERS)}"}), 400
1260
+
1261
+ project = Path(path)
1262
+ tier_perms = _build_permission_tier(tier)
1263
+ settings_path = project / ".claude" / "settings.local.json"
1264
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
1265
+
1266
+ # Read existing settings to preserve hooks etc.
1267
+ settings = {}
1268
+ if settings_path.exists():
1269
+ try:
1270
+ with open(settings_path, encoding="utf-8") as f:
1271
+ settings = json.load(f)
1272
+ except Exception:
1273
+ pass
1274
+ settings["permissions"] = tier_perms["permissions"]
1275
+ with open(settings_path, "w", encoding="utf-8") as f:
1276
+ json.dump(settings, f, indent=2)
1277
+
1278
+ # Store tier in .c3/config.json
1279
+ config_file = project / ".c3" / "config.json"
1280
+ config = {}
1281
+ if config_file.exists():
1282
+ try:
1283
+ with open(config_file, encoding="utf-8") as f:
1284
+ config = json.load(f)
1285
+ except Exception:
1286
+ pass
1287
+ config["permission_tier"] = tier
1288
+ config_file.parent.mkdir(parents=True, exist_ok=True)
1289
+ with open(config_file, "w", encoding="utf-8") as f:
1290
+ json.dump(config, f, indent=2)
1291
+
1292
+ return jsonify({
1293
+ "current_tier": tier,
1294
+ "allow_count": len(tier_perms["permissions"]["allow"]),
1295
+ "deny_count": len(tier_perms["permissions"]["deny"]),
1296
+ "message": f"Applied '{tier}' permissions. Restart Claude Code to activate.",
1297
+ })
1298
+
1299
+
1300
+ # ─── Routes: hub service (background daemon) ────────────────────────────────
1301
+
1302
+ @app.route("/api/hub/service", methods=["GET"])
1303
+ def api_hub_service_status():
1304
+ """Return whether the hub is registered as a startup service."""
1305
+ from services.hub_service import HubService
1306
+ status = HubService().status()
1307
+ status["port"] = _read_hub_config().get("port", 3330)
1308
+ return jsonify(status)
1309
+
1310
+
1311
+ @app.route("/api/hub/service/install", methods=["POST"])
1312
+ def api_hub_service_install():
1313
+ """Install hub as a login/startup service using the configured port."""
1314
+ from services.hub_service import HubService
1315
+ port = _read_hub_config().get("port", 3330)
1316
+ result = HubService().install(port)
1317
+ return jsonify(result)
1318
+
1319
+
1320
+ @app.route("/api/hub/service/uninstall", methods=["POST"])
1321
+ def api_hub_service_uninstall():
1322
+ """Remove the startup service registration."""
1323
+ from services.hub_service import HubService
1324
+ return jsonify(HubService().uninstall())
1325
+
1326
+
1327
+ @app.route("/api/hub/service/start", methods=["POST"])
1328
+ def api_hub_service_start():
1329
+ """Start a background hub process (no terminal needed)."""
1330
+ from services.hub_service import HubService
1331
+ port = _read_hub_config().get("port", 3330)
1332
+ return jsonify(HubService().start(port))
1333
+
1334
+
1335
+ @app.route("/api/hub/service/stop", methods=["POST"])
1336
+ def api_hub_service_stop():
1337
+ """Stop the hub process on its configured port (kills current server)."""
1338
+ import threading
1339
+ port = _read_hub_config().get("port", 3330)
1340
+ from services.hub_service import HubService
1341
+ result = HubService().stop(port)
1342
+ # Shut down Flask after sending response
1343
+ def _exit():
1344
+ import time
1345
+ time.sleep(0.4)
1346
+ os._exit(0)
1347
+ threading.Thread(target=_exit, daemon=True).start()
1348
+ return jsonify(result)
1349
+
1350
+
1351
+ @app.route("/api/hub/restart", methods=["POST"])
1352
+ def api_hub_restart():
1353
+ """Restart the hub server in-place."""
1354
+ import threading
1355
+
1356
+ def _restart():
1357
+ import time
1358
+ time.sleep(0.3) # let response flush
1359
+ port = _read_hub_config().get("port", 3330)
1360
+ # Spawn a detached intermediate that waits for this process to fully exit,
1361
+ # then uses _launch_background so the new hub inherits proper PYTHONPATH.
1362
+ parent_dir = str(Path(__file__).parent.parent)
1363
+ launcher = (
1364
+ f"import time, sys; "
1365
+ f"sys.path.insert(0, r'{parent_dir}'); "
1366
+ f"time.sleep(1.5); "
1367
+ f"from services.hub_service import _launch_background; "
1368
+ f"_launch_background({port})"
1369
+ )
1370
+ kwargs: dict = {}
1371
+ if sys.platform == "win32":
1372
+ kwargs["creationflags"] = (
1373
+ subprocess.DETACHED_PROCESS
1374
+ | subprocess.CREATE_NEW_PROCESS_GROUP
1375
+ | subprocess.CREATE_NO_WINDOW
1376
+ )
1377
+ else:
1378
+ kwargs["start_new_session"] = True
1379
+ subprocess.Popen([sys.executable, "-c", launcher], **kwargs)
1380
+ os._exit(0)
1381
+
1382
+ threading.Thread(target=_restart, daemon=True).start()
1383
+ return jsonify({"restarting": True})
1384
+
1385
+
1386
+ # ─── Routes: sessions ────────────────────────────────────────────────────────
1387
+
1388
+ @app.route("/api/sessions/start", methods=["POST"])
1389
+ def api_session_start():
1390
+ data = request.get_json(force=True) or {}
1391
+ path = (data.get("path") or "").strip()
1392
+ if not path:
1393
+ return jsonify({"error": "path is required"}), 400
1394
+ try:
1395
+ result = _pm().launch_session(path)
1396
+ if not result["launched"] and result.get("error"):
1397
+ return jsonify({"error": result["error"]}), 400
1398
+ return jsonify(result)
1399
+ except Exception as e:
1400
+ return jsonify({"error": str(e)}), 500
1401
+
1402
+
1403
+ @app.route("/api/sessions/stop", methods=["POST"])
1404
+ def api_session_stop():
1405
+ data = request.get_json(force=True) or {}
1406
+ port = data.get("port")
1407
+ if not port:
1408
+ return jsonify({"error": "port is required"}), 400
1409
+ try:
1410
+ return jsonify({"stopped": _pm().stop_session(int(port))})
1411
+ except Exception as e:
1412
+ return jsonify({"error": str(e)}), 500
1413
+
1414
+
1415
+ @app.route("/api/sessions/restart", methods=["POST"])
1416
+ def api_session_restart():
1417
+ """Stop the UI server on the given port, then start a fresh one for the project."""
1418
+ data = request.get_json(force=True) or {}
1419
+ path = (data.get("path") or "").strip()
1420
+ port = data.get("port")
1421
+ if not path:
1422
+ return jsonify({"error": "path is required"}), 400
1423
+ pm = _pm()
1424
+ if port:
1425
+ pm.stop_session(int(port))
1426
+ result = pm.launch_session(path)
1427
+ return jsonify(result)
1428
+
1429
+
1430
+ @app.route("/api/sessions/autostart", methods=["POST"])
1431
+ def api_session_autostart():
1432
+ """Toggle per-project UI autostart. Body: {path, enabled: bool}."""
1433
+ data = request.get_json(force=True) or {}
1434
+ path = (data.get("path") or "").strip()
1435
+ enabled = bool(data.get("enabled", False))
1436
+ if not path:
1437
+ return jsonify({"error": "path is required"}), 400
1438
+ ok = _pm().update_project(path, autostart_ui=enabled)
1439
+ if not ok:
1440
+ return jsonify({"error": "project not found"}), 404
1441
+ return jsonify({"success": True, "autostart_ui": enabled})
1442
+
1443
+
1444
+ @app.route("/api/sessions/end", methods=["POST"])
1445
+ def api_session_end():
1446
+ """End an MCP-only session (no UI port) by marking it saved in the activity log."""
1447
+ data = request.get_json(force=True) or {}
1448
+ path = (data.get("path") or "").strip()
1449
+ if not path:
1450
+ return jsonify({"error": "path is required"}), 400
1451
+ try:
1452
+ return jsonify({"stopped": _pm().end_mcp_session(path)})
1453
+ except Exception as e:
1454
+ return jsonify({"error": str(e)}), 500
1455
+
1456
+
1457
+ @app.route("/api/sessions", methods=["GET"])
1458
+ def api_sessions():
1459
+ try:
1460
+ return jsonify(_pm().get_active_sessions())
1461
+ except Exception as e:
1462
+ return jsonify({"error": str(e)}), 500
1463
+
1464
+
1465
+ # ─── Error handlers ──────────────────────────────────────────────────────────
1466
+
1467
+ @app.errorhandler(404)
1468
+ def not_found(e):
1469
+ return jsonify({"error": f"Not found: {request.path}"}), 404
1470
+
1471
+
1472
+ @app.errorhandler(405)
1473
+ def method_not_allowed(e):
1474
+ return jsonify({"error": f"Method not allowed: {request.method} {request.path}"}), 405
1475
+
1476
+
1477
+ # ─── Hook migration ──────────────────────────────────────────────────────────
1478
+
1479
+ def _migrate_project_hooks():
1480
+ """Idempotently add new C3 hooks to all registered projects' Claude and Gemini settings.
1481
+
1482
+ Runs at hub startup so that existing projects pick up hook changes
1483
+ without requiring a manual 'c3 mcp install' on each project.
1484
+ """
1485
+ cli_dir = Path(__file__).parent
1486
+ hook_c3read_cmd = (
1487
+ f"{shlex.quote(sys.executable)} "
1488
+ f"{shlex.quote(str(cli_dir / 'hook_c3read.py'))}"
1489
+ )
1490
+ new_hook = {
1491
+ "matcher": "mcp__c3__c3_read",
1492
+ "hooks": [{"type": "command", "command": hook_c3read_cmd}],
1493
+ }
1494
+
1495
+ # (settings_path, hook_event) pairs to check per project
1496
+ _HOOK_TARGETS = [
1497
+ (".claude/settings.local.json", "PostToolUse"),
1498
+ (".gemini/settings.json", "AfterTool"),
1499
+ ]
1500
+
1501
+ try:
1502
+ projects = _pm().list_projects()
1503
+ except Exception:
1504
+ return
1505
+
1506
+ updated = 0
1507
+ for p in projects:
1508
+ path = p.get("path", "")
1509
+ if not path:
1510
+ continue
1511
+ for rel_settings, hook_event in _HOOK_TARGETS:
1512
+ settings_path = Path(path) / rel_settings
1513
+ if not settings_path.exists():
1514
+ continue
1515
+ try:
1516
+ with open(settings_path, encoding="utf-8") as f:
1517
+ settings = json.load(f)
1518
+ except Exception:
1519
+ continue
1520
+
1521
+ existing = settings.get("hooks", {}).get(hook_event, [])
1522
+ if any(h.get("matcher") == "mcp__c3__c3_read" for h in existing):
1523
+ continue # already present — skip
1524
+
1525
+ existing.append(new_hook)
1526
+ settings.setdefault("hooks", {})[hook_event] = existing
1527
+ try:
1528
+ with open(settings_path, "w", encoding="utf-8") as f:
1529
+ json.dump(settings, f, indent=2)
1530
+ updated += 1
1531
+ except Exception:
1532
+ pass
1533
+
1534
+ if updated:
1535
+ logging.getLogger(__name__).info(
1536
+ "[c3] Migrated hook_c3read to %d project settings file(s)", updated
1537
+ )
1538
+
1539
+
1540
+ # ─── Entry point ─────────────────────────────────────────────────────────────
1541
+
1542
+ def run_hub(
1543
+ port: int = None,
1544
+ open_browser: bool = None,
1545
+ silent: bool = False,
1546
+ quiet: bool = False,
1547
+ ):
1548
+ cfg = _read_hub_config()
1549
+ dedicated_port = port if port is not None else cfg.get("port", 3330)
1550
+ if open_browser is None:
1551
+ open_browser = cfg.get("auto_open_browser", True)
1552
+
1553
+ # Single-instance check: if dedicated port is already our hub, just open it
1554
+ if not _port_free(dedicated_port):
1555
+ if _is_hub_running(dedicated_port):
1556
+ url = f"http://localhost:{dedicated_port}"
1557
+ if not quiet:
1558
+ print(f"C3 Hub already running at {url}")
1559
+ if open_browser:
1560
+ webbrowser.open(url)
1561
+ return
1562
+ # Port taken by something else → find next available
1563
+ actual_port = _find_free_port(dedicated_port + 1)
1564
+ if not quiet:
1565
+ print(f"Warning: dedicated port {dedicated_port} is in use. Using {actual_port} instead.")
1566
+ else:
1567
+ actual_port = dedicated_port
1568
+
1569
+ logging.getLogger("werkzeug").setLevel(logging.ERROR if silent else logging.WARNING)
1570
+ url = f"http://localhost:{actual_port}"
1571
+ if not quiet:
1572
+ print(f"C3 Project Hub → {url} (C3 v{C3_VERSION})")
1573
+
1574
+ if open_browser:
1575
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
1576
+
1577
+ # Clean stale registry entries from before restart
1578
+ try:
1579
+ _pm().sweep_registry()
1580
+ except Exception:
1581
+ pass
1582
+ _migrate_project_hooks()
1583
+
1584
+ # Auto-launch UI servers for projects with autostart_ui=True
1585
+ def _autostart_ui_servers():
1586
+ import time
1587
+ time.sleep(2) # Give Flask a moment to bind
1588
+ try:
1589
+ pm = _pm()
1590
+ for proj_path in pm.get_autostart_projects():
1591
+ try:
1592
+ pm.launch_session(proj_path)
1593
+ except Exception:
1594
+ pass
1595
+ except Exception:
1596
+ pass
1597
+
1598
+ threading.Thread(target=_autostart_ui_servers, daemon=True).start()
1599
+
1600
+ bind_host = str(cfg.get("host", "127.0.0.1") or "127.0.0.1").strip()
1601
+ if bind_host not in ("127.0.0.1", "localhost", "::1") and not quiet:
1602
+ print(
1603
+ f"WARNING: C3 Hub is binding to {bind_host}. The hub has no built-in "
1604
+ "authentication; do not expose it to untrusted networks. Set "
1605
+ '"host": "127.0.0.1" in ~/.c3/hub_config.json to restrict to loopback.'
1606
+ )
1607
+
1608
+ app.run(host=bind_host, port=actual_port, debug=False, use_reloader=False)
1609
+
1610
+
1611
+ def main() -> None:
1612
+ """Entry-point for the ``c3-hub`` console script."""
1613
+ from services import error_reporting
1614
+ error_reporting.init(component="c3-hub", version=C3_VERSION)
1615
+ run_hub()
1616
+
1617
+
1618
+ if __name__ == "__main__":
1619
+ main()