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.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- 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()
|