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
services/hub_service.py
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-platform background service manager for C3 Project Hub.
|
|
3
|
+
|
|
4
|
+
Windows → Windows Task Scheduler (ONLOGON trigger, pythonw.exe)
|
|
5
|
+
macOS → launchd LaunchAgent (~/.config/LaunchAgents/)
|
|
6
|
+
Linux → systemd user service (~/.config/systemd/user/)
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
svc = HubService()
|
|
10
|
+
svc.status() # {"installed", "running", "platform", "log_path"}
|
|
11
|
+
svc.install(port=3330) # register + immediately start background process
|
|
12
|
+
svc.uninstall() # remove auto-start registration
|
|
13
|
+
svc.start(port=3330) # start background process now
|
|
14
|
+
svc.stop(port=3330) # kill process listening on port
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import winreg
|
|
24
|
+
except ImportError:
|
|
25
|
+
winreg = None
|
|
26
|
+
|
|
27
|
+
_C3_PY = Path(__file__).parent.parent / "cli" / "c3.py"
|
|
28
|
+
_LOG_FILE = Path.home() / ".c3" / "hub.log"
|
|
29
|
+
|
|
30
|
+
# ── Windows helpers ───────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
def _pythonw() -> str:
|
|
33
|
+
"""Return pythonw.exe path (silent, no console window) on Windows."""
|
|
34
|
+
pw = Path(sys.executable).parent / "pythonw.exe"
|
|
35
|
+
return str(pw) if pw.exists() else sys.executable
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _win_startup_dir() -> Path:
|
|
39
|
+
appdata = os.environ.get("APPDATA")
|
|
40
|
+
if appdata:
|
|
41
|
+
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
|
|
42
|
+
return (
|
|
43
|
+
Path.home()
|
|
44
|
+
/ "AppData"
|
|
45
|
+
/ "Roaming"
|
|
46
|
+
/ "Microsoft"
|
|
47
|
+
/ "Windows"
|
|
48
|
+
/ "Start Menu"
|
|
49
|
+
/ "Programs"
|
|
50
|
+
/ "Startup"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _vbs_escape(value: str) -> str:
|
|
55
|
+
return value.replace('"', '""')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _win_reg_registered(task_name: str) -> bool:
|
|
59
|
+
"""Check if the hub is registered in the HKCU Run key."""
|
|
60
|
+
if not winreg:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
with winreg.OpenKey(
|
|
64
|
+
winreg.HKEY_CURRENT_USER,
|
|
65
|
+
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
|
66
|
+
0,
|
|
67
|
+
winreg.KEY_READ,
|
|
68
|
+
) as key:
|
|
69
|
+
winreg.QueryValueEx(key, task_name)
|
|
70
|
+
return True
|
|
71
|
+
except (OSError, FileNotFoundError):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _kill_port_win(port: int) -> bool:
|
|
76
|
+
try:
|
|
77
|
+
kwargs = {}
|
|
78
|
+
if sys.platform == "win32":
|
|
79
|
+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
80
|
+
r = subprocess.run(
|
|
81
|
+
f"netstat -ano | findstr :{port}",
|
|
82
|
+
shell=True, capture_output=True, text=True,
|
|
83
|
+
**kwargs
|
|
84
|
+
)
|
|
85
|
+
pids = set()
|
|
86
|
+
for line in r.stdout.strip().splitlines():
|
|
87
|
+
if f":{port}" in line and "LISTENING" in line:
|
|
88
|
+
parts = line.strip().split()
|
|
89
|
+
if len(parts) >= 5:
|
|
90
|
+
pids.add(parts[-1])
|
|
91
|
+
for pid in pids:
|
|
92
|
+
subprocess.run(f"taskkill /PID {pid} /F", shell=True, capture_output=True, **kwargs)
|
|
93
|
+
return bool(pids)
|
|
94
|
+
except Exception:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _kill_port_unix(port: int) -> bool:
|
|
99
|
+
try:
|
|
100
|
+
subprocess.run(
|
|
101
|
+
f"lsof -ti:{port} | xargs kill -9",
|
|
102
|
+
shell=True, capture_output=True,
|
|
103
|
+
)
|
|
104
|
+
return True
|
|
105
|
+
except Exception:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _make_hub_start_script(repo_root: str, port: int) -> Path:
|
|
110
|
+
"""Write ~/.c3/hub_start.py — a self-contained launcher for the background hub.
|
|
111
|
+
|
|
112
|
+
Stored on the local drive so it is always accessible even before network
|
|
113
|
+
drives mount. Sets sys.path itself (no PYTHONPATH needed) and redirects
|
|
114
|
+
all output to hub.log so startup errors are visible.
|
|
115
|
+
"""
|
|
116
|
+
script_path = Path.home() / ".c3" / "hub_start.py"
|
|
117
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
escaped = repr(repo_root) # produces 'r"..."' or regular quoted string
|
|
120
|
+
content = (
|
|
121
|
+
"import sys, os, time\n"
|
|
122
|
+
"from pathlib import Path\n"
|
|
123
|
+
"\n"
|
|
124
|
+
f"_REPO = {escaped}\n"
|
|
125
|
+
f"_PORT = {port}\n"
|
|
126
|
+
"_LOG = Path.home() / '.c3' / 'hub.log'\n"
|
|
127
|
+
"\n"
|
|
128
|
+
"# Capture all output so errors are visible in hub.log\n"
|
|
129
|
+
"_LOG.parent.mkdir(parents=True, exist_ok=True)\n"
|
|
130
|
+
"_fh = open(str(_LOG), 'a', encoding='utf-8', buffering=1)\n"
|
|
131
|
+
"sys.stdout = _fh\n"
|
|
132
|
+
"sys.stderr = _fh\n"
|
|
133
|
+
"\n"
|
|
134
|
+
"# Wait up to 60 s for the repo to be accessible (network-drive mounts)\n"
|
|
135
|
+
"for _i in range(12):\n"
|
|
136
|
+
" if Path(_REPO).exists():\n"
|
|
137
|
+
" break\n"
|
|
138
|
+
" time.sleep(5)\n"
|
|
139
|
+
"else:\n"
|
|
140
|
+
" import datetime\n"
|
|
141
|
+
" print(f'[c3-hub] {datetime.datetime.now()} repo not accessible after 60 s: {_REPO}', flush=True)\n"
|
|
142
|
+
" sys.exit(1)\n"
|
|
143
|
+
"\n"
|
|
144
|
+
"sys.path.insert(0, _REPO)\n"
|
|
145
|
+
"os.chdir(_REPO)\n"
|
|
146
|
+
"\n"
|
|
147
|
+
"try:\n"
|
|
148
|
+
" from cli.hub_server import run_hub\n"
|
|
149
|
+
" run_hub(port=_PORT, open_browser=False, silent=True, quiet=True)\n"
|
|
150
|
+
"except Exception as _e:\n"
|
|
151
|
+
" import traceback, datetime\n"
|
|
152
|
+
" print(f'[c3-hub] {datetime.datetime.now()} STARTUP ERROR: {_e}', flush=True)\n"
|
|
153
|
+
" traceback.print_exc(file=_fh)\n"
|
|
154
|
+
)
|
|
155
|
+
script_path.write_text(content, encoding="utf-8")
|
|
156
|
+
return script_path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _launch_background(port: int):
|
|
160
|
+
"""Start hub as a detached background process in quiet background mode."""
|
|
161
|
+
start_script = _make_hub_start_script(str(Path(__file__).parent.parent), port)
|
|
162
|
+
_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
exe = _pythonw() if sys.platform == "win32" else sys.executable
|
|
164
|
+
cmd = [exe, str(start_script)]
|
|
165
|
+
kwargs: dict = {}
|
|
166
|
+
if sys.platform == "win32":
|
|
167
|
+
kwargs["creationflags"] = (
|
|
168
|
+
subprocess.DETACHED_PROCESS
|
|
169
|
+
| subprocess.CREATE_NEW_PROCESS_GROUP
|
|
170
|
+
| subprocess.CREATE_NO_WINDOW
|
|
171
|
+
)
|
|
172
|
+
kwargs["close_fds"] = True
|
|
173
|
+
else:
|
|
174
|
+
kwargs["start_new_session"] = True
|
|
175
|
+
subprocess.Popen(cmd, **kwargs)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── HubService ────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
class HubService:
|
|
181
|
+
TASK_NAME = "C3ProjectHub"
|
|
182
|
+
PLIST_LABEL = "com.c3.projecthub"
|
|
183
|
+
SYSTEMD_NAME = "c3hub.service"
|
|
184
|
+
|
|
185
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def status(self) -> dict:
|
|
188
|
+
if sys.platform == "win32":
|
|
189
|
+
return self._win_status()
|
|
190
|
+
elif sys.platform == "darwin":
|
|
191
|
+
return self._mac_status()
|
|
192
|
+
else:
|
|
193
|
+
return self._linux_status()
|
|
194
|
+
|
|
195
|
+
def install(self, port: int) -> dict:
|
|
196
|
+
if sys.platform == "win32":
|
|
197
|
+
r = self._win_install(port)
|
|
198
|
+
elif sys.platform == "darwin":
|
|
199
|
+
r = self._mac_install(port)
|
|
200
|
+
else:
|
|
201
|
+
r = self._linux_install(port)
|
|
202
|
+
if r.get("success"):
|
|
203
|
+
_launch_background(port)
|
|
204
|
+
return r
|
|
205
|
+
|
|
206
|
+
def uninstall(self) -> dict:
|
|
207
|
+
if sys.platform == "win32":
|
|
208
|
+
return self._win_uninstall()
|
|
209
|
+
elif sys.platform == "darwin":
|
|
210
|
+
return self._mac_uninstall()
|
|
211
|
+
else:
|
|
212
|
+
return self._linux_uninstall()
|
|
213
|
+
|
|
214
|
+
def start(self, port: int) -> dict:
|
|
215
|
+
try:
|
|
216
|
+
_launch_background(port)
|
|
217
|
+
return {"success": True, "output": f"Hub starting on port {port}…"}
|
|
218
|
+
except Exception as e:
|
|
219
|
+
return {"success": False, "output": str(e)}
|
|
220
|
+
|
|
221
|
+
def stop(self, port: int) -> dict:
|
|
222
|
+
try:
|
|
223
|
+
if sys.platform == "win32":
|
|
224
|
+
ok = _kill_port_win(port)
|
|
225
|
+
else:
|
|
226
|
+
ok = _kill_port_unix(port)
|
|
227
|
+
return {"success": ok, "output": f"Killed process on :{port}" if ok else "No process found"}
|
|
228
|
+
except Exception as e:
|
|
229
|
+
return {"success": False, "output": str(e)}
|
|
230
|
+
|
|
231
|
+
# ── Windows ───────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def _startup_script_path(self) -> Path:
|
|
235
|
+
return _win_startup_dir() / f"{self.TASK_NAME}.vbs"
|
|
236
|
+
|
|
237
|
+
def _win_task_registered(self) -> bool:
|
|
238
|
+
r = subprocess.run(
|
|
239
|
+
["schtasks", "/query", "/tn", self.TASK_NAME, "/fo", "LIST"],
|
|
240
|
+
capture_output=True, text=True,
|
|
241
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
242
|
+
)
|
|
243
|
+
return r.returncode == 0
|
|
244
|
+
|
|
245
|
+
def _win_status(self) -> dict:
|
|
246
|
+
cfg = self._read_hub_config()
|
|
247
|
+
port = cfg.get("port", 3330)
|
|
248
|
+
task_installed = self._win_task_registered()
|
|
249
|
+
reg_installed = _win_reg_registered(self.TASK_NAME)
|
|
250
|
+
startup_installed = self._startup_script_path.exists()
|
|
251
|
+
running = self._is_port_alive(port)
|
|
252
|
+
|
|
253
|
+
if task_installed:
|
|
254
|
+
method = "Windows Task Scheduler (runs at login, no terminal)"
|
|
255
|
+
elif reg_installed:
|
|
256
|
+
method = "Windows Registry Run key (runs at login, silent)"
|
|
257
|
+
elif startup_installed:
|
|
258
|
+
method = "Windows Startup folder — legacy, consider reinstalling"
|
|
259
|
+
else:
|
|
260
|
+
method = "not installed"
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"installed": task_installed or reg_installed or startup_installed,
|
|
264
|
+
"running": running,
|
|
265
|
+
"port": port,
|
|
266
|
+
"platform": "windows",
|
|
267
|
+
"log_path": str(_LOG_FILE),
|
|
268
|
+
"method": method,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
def _is_port_alive(self, port: int) -> bool:
|
|
272
|
+
"""Check if anything is listening on the given port."""
|
|
273
|
+
import socket
|
|
274
|
+
try:
|
|
275
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
|
|
276
|
+
return True
|
|
277
|
+
except Exception:
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
def _read_hub_config(self) -> dict:
|
|
281
|
+
"""Read hub config from ~/.c3/hub_config.json."""
|
|
282
|
+
config_path = Path.home() / ".c3" / "hub_config.json"
|
|
283
|
+
if config_path.exists():
|
|
284
|
+
try:
|
|
285
|
+
with open(config_path, encoding="utf-8") as f:
|
|
286
|
+
return json.load(f)
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
return {}
|
|
290
|
+
|
|
291
|
+
def _win_reg_install(self, pythonw: str, start_script: Path) -> bool:
|
|
292
|
+
"""Register the hub in the HKCU Run key."""
|
|
293
|
+
if not winreg:
|
|
294
|
+
return False
|
|
295
|
+
try:
|
|
296
|
+
with winreg.OpenKey(
|
|
297
|
+
winreg.HKEY_CURRENT_USER,
|
|
298
|
+
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
|
299
|
+
0,
|
|
300
|
+
winreg.KEY_SET_VALUE,
|
|
301
|
+
) as key:
|
|
302
|
+
# Command: "pythonw.exe" "hub_start.py"
|
|
303
|
+
cmd = f'"{pythonw}" "{start_script}"'
|
|
304
|
+
winreg.SetValueEx(key, self.TASK_NAME, 0, winreg.REG_SZ, cmd)
|
|
305
|
+
return True
|
|
306
|
+
except OSError:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
def _win_reg_uninstall(self) -> bool:
|
|
310
|
+
"""Remove the hub from the HKCU Run key."""
|
|
311
|
+
if not winreg:
|
|
312
|
+
return False
|
|
313
|
+
try:
|
|
314
|
+
with winreg.OpenKey(
|
|
315
|
+
winreg.HKEY_CURRENT_USER,
|
|
316
|
+
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
|
317
|
+
0,
|
|
318
|
+
winreg.KEY_SET_VALUE,
|
|
319
|
+
) as key:
|
|
320
|
+
winreg.DeleteValue(key, self.TASK_NAME)
|
|
321
|
+
return True
|
|
322
|
+
except (OSError, FileNotFoundError):
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
def _win_install(self, port: int) -> dict:
|
|
326
|
+
r"""Register the hub for auto-start on Windows.
|
|
327
|
+
|
|
328
|
+
Tries Windows Task Scheduler first (allows 30s delay).
|
|
329
|
+
Falls back to HKCU\...\Run registry key if Task Scheduler fails (e.g. Access Denied).
|
|
330
|
+
"""
|
|
331
|
+
pythonw = _pythonw()
|
|
332
|
+
repo_root = str(Path(__file__).parent.parent)
|
|
333
|
+
|
|
334
|
+
# Write the launcher script to the local drive (~/.c3/hub_start.py)
|
|
335
|
+
start_script = _make_hub_start_script(repo_root, port)
|
|
336
|
+
|
|
337
|
+
def _xe(s: str) -> str:
|
|
338
|
+
"""Minimal XML attribute/text escaping."""
|
|
339
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
340
|
+
|
|
341
|
+
task_xml = (
|
|
342
|
+
'<?xml version="1.0" encoding="UTF-16"?>\n'
|
|
343
|
+
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n'
|
|
344
|
+
' <RegistrationInfo>\n'
|
|
345
|
+
' <Description>C3 Project Hub background server</Description>\n'
|
|
346
|
+
' </RegistrationInfo>\n'
|
|
347
|
+
' <Triggers>\n'
|
|
348
|
+
' <LogonTrigger>\n'
|
|
349
|
+
' <Enabled>true</Enabled>\n'
|
|
350
|
+
' <Delay>PT30S</Delay>\n'
|
|
351
|
+
' </LogonTrigger>\n'
|
|
352
|
+
' </Triggers>\n'
|
|
353
|
+
' <Principals>\n'
|
|
354
|
+
' <Principal id="Author">\n'
|
|
355
|
+
' <LogonType>InteractiveToken</LogonType>\n'
|
|
356
|
+
' <RunLevel>LeastPrivilege</RunLevel>\n'
|
|
357
|
+
' </Principal>\n'
|
|
358
|
+
' </Principals>\n'
|
|
359
|
+
' <Settings>\n'
|
|
360
|
+
' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n'
|
|
361
|
+
' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n'
|
|
362
|
+
' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n'
|
|
363
|
+
' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n'
|
|
364
|
+
' <WakeToRun>false</WakeToRun>\n'
|
|
365
|
+
' </Settings>\n'
|
|
366
|
+
' <Actions Context="Author">\n'
|
|
367
|
+
' <Exec>\n'
|
|
368
|
+
f' <Command>{_xe(pythonw)}</Command>\n'
|
|
369
|
+
f' <Arguments>{_xe(str(start_script))}</Arguments>\n'
|
|
370
|
+
' </Exec>\n'
|
|
371
|
+
' </Actions>\n'
|
|
372
|
+
'</Task>\n'
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
import tempfile
|
|
376
|
+
tmp_xml = None
|
|
377
|
+
messages = []
|
|
378
|
+
try:
|
|
379
|
+
with tempfile.NamedTemporaryFile(
|
|
380
|
+
mode="w", suffix=".xml", delete=False,
|
|
381
|
+
encoding="utf-16", prefix="c3hub_",
|
|
382
|
+
) as f:
|
|
383
|
+
f.write(task_xml)
|
|
384
|
+
tmp_xml = f.name
|
|
385
|
+
|
|
386
|
+
# Try Task Scheduler
|
|
387
|
+
r = subprocess.run(
|
|
388
|
+
["schtasks", "/create", "/tn", self.TASK_NAME, "/xml", tmp_xml, "/f"],
|
|
389
|
+
capture_output=True, text=True,
|
|
390
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
391
|
+
)
|
|
392
|
+
if r.returncode == 0:
|
|
393
|
+
messages.append(f"Task '{self.TASK_NAME}' registered in Task Scheduler.")
|
|
394
|
+
# Clean up registry if it was there before
|
|
395
|
+
self._win_reg_uninstall()
|
|
396
|
+
else:
|
|
397
|
+
# Fallback to Registry
|
|
398
|
+
if self._win_reg_install(pythonw, start_script):
|
|
399
|
+
messages.append("Task Scheduler failed (Access Denied); registered in Registry Run key instead.")
|
|
400
|
+
else:
|
|
401
|
+
out = (r.stdout + r.stderr).strip()
|
|
402
|
+
return {"success": False, "output": out or "Failed to register via Task Scheduler or Registry."}
|
|
403
|
+
|
|
404
|
+
# Remove legacy Startup-folder VBS if present
|
|
405
|
+
if self._startup_script_path.exists():
|
|
406
|
+
self._startup_script_path.unlink()
|
|
407
|
+
messages.append("Removed legacy startup-folder script.")
|
|
408
|
+
|
|
409
|
+
return {"success": True, "output": "\n".join(messages)}
|
|
410
|
+
|
|
411
|
+
except Exception as e:
|
|
412
|
+
return {"success": False, "output": str(e)}
|
|
413
|
+
finally:
|
|
414
|
+
if tmp_xml:
|
|
415
|
+
try:
|
|
416
|
+
os.unlink(tmp_xml)
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
def _win_uninstall(self) -> dict:
|
|
421
|
+
messages = []
|
|
422
|
+
success = True
|
|
423
|
+
|
|
424
|
+
# Remove Task Scheduler task
|
|
425
|
+
if self._win_task_registered():
|
|
426
|
+
r = subprocess.run(
|
|
427
|
+
["schtasks", "/delete", "/tn", self.TASK_NAME, "/f"],
|
|
428
|
+
capture_output=True, text=True,
|
|
429
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
430
|
+
)
|
|
431
|
+
out = (r.stdout + r.stderr).strip()
|
|
432
|
+
if r.returncode == 0:
|
|
433
|
+
messages.append("Task Scheduler task removed.")
|
|
434
|
+
else:
|
|
435
|
+
success = False
|
|
436
|
+
messages.append(out or "Failed to remove Task Scheduler task.")
|
|
437
|
+
|
|
438
|
+
# Remove Registry key
|
|
439
|
+
if _win_reg_registered(self.TASK_NAME):
|
|
440
|
+
if self._win_reg_uninstall():
|
|
441
|
+
messages.append("Registry Run key removed.")
|
|
442
|
+
else:
|
|
443
|
+
success = False
|
|
444
|
+
messages.append("Failed to remove Registry Run key.")
|
|
445
|
+
|
|
446
|
+
# Remove the hub_start.py launcher script
|
|
447
|
+
start_script = Path.home() / ".c3" / "hub_start.py"
|
|
448
|
+
if start_script.exists():
|
|
449
|
+
start_script.unlink()
|
|
450
|
+
messages.append("Launcher script removed.")
|
|
451
|
+
|
|
452
|
+
# Remove legacy Startup-folder VBS if still present
|
|
453
|
+
if self._startup_script_path.exists():
|
|
454
|
+
self._startup_script_path.unlink()
|
|
455
|
+
messages.append("Legacy startup-folder script removed.")
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
"success": success,
|
|
459
|
+
"output": "\n".join(messages) or "No startup registration found.",
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
# ── macOS ─────────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def _plist_path(self) -> Path:
|
|
466
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{self.PLIST_LABEL}.plist"
|
|
467
|
+
|
|
468
|
+
def _mac_status(self) -> dict:
|
|
469
|
+
installed = self._plist_path.exists()
|
|
470
|
+
running = None
|
|
471
|
+
if installed:
|
|
472
|
+
r = subprocess.run(
|
|
473
|
+
["launchctl", "list", self.PLIST_LABEL],
|
|
474
|
+
capture_output=True, text=True,
|
|
475
|
+
)
|
|
476
|
+
running = r.returncode == 0
|
|
477
|
+
return {
|
|
478
|
+
"installed": installed,
|
|
479
|
+
"running": running,
|
|
480
|
+
"platform": "macos",
|
|
481
|
+
"log_path": str(_LOG_FILE),
|
|
482
|
+
"method": "launchd LaunchAgent (RunAtLoad)",
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
def _mac_install(self, port: int) -> dict:
|
|
486
|
+
plist = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
487
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
488
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
489
|
+
<plist version="1.0"><dict>
|
|
490
|
+
<key>Label</key><string>{self.PLIST_LABEL}</string>
|
|
491
|
+
<key>ProgramArguments</key>
|
|
492
|
+
<array>
|
|
493
|
+
<string>{sys.executable}</string>
|
|
494
|
+
<string>{_C3_PY}</string>
|
|
495
|
+
<string>hub</string>
|
|
496
|
+
<string>--port</string><string>{port}</string>
|
|
497
|
+
<string>--no-browser</string>
|
|
498
|
+
<string>--silent</string>
|
|
499
|
+
<string>--extra-silent</string>
|
|
500
|
+
</array>
|
|
501
|
+
<key>RunAtLoad</key><true/>
|
|
502
|
+
<key>KeepAlive</key><true/>
|
|
503
|
+
<key>StandardOutPath</key><string>{_LOG_FILE}</string>
|
|
504
|
+
<key>StandardErrorPath</key><string>{_LOG_FILE}</string>
|
|
505
|
+
</dict></plist>"""
|
|
506
|
+
self._plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
self._plist_path.write_text(plist, encoding="utf-8")
|
|
508
|
+
r = subprocess.run(
|
|
509
|
+
["launchctl", "load", str(self._plist_path)],
|
|
510
|
+
capture_output=True, text=True,
|
|
511
|
+
)
|
|
512
|
+
return {
|
|
513
|
+
"success": r.returncode == 0,
|
|
514
|
+
"output": (r.stdout + r.stderr).strip() or "LaunchAgent loaded.",
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
def _mac_uninstall(self) -> dict:
|
|
518
|
+
if self._plist_path.exists():
|
|
519
|
+
subprocess.run(
|
|
520
|
+
["launchctl", "unload", str(self._plist_path)],
|
|
521
|
+
capture_output=True,
|
|
522
|
+
)
|
|
523
|
+
self._plist_path.unlink()
|
|
524
|
+
return {"success": True, "output": "LaunchAgent removed."}
|
|
525
|
+
|
|
526
|
+
# ── Linux (systemd user) ──────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def _service_path(self) -> Path:
|
|
530
|
+
return (
|
|
531
|
+
Path.home() / ".config" / "systemd" / "user" / self.SYSTEMD_NAME
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def _linux_status(self) -> dict:
|
|
535
|
+
installed = self._service_path.exists()
|
|
536
|
+
running = None
|
|
537
|
+
if installed:
|
|
538
|
+
r = subprocess.run(
|
|
539
|
+
["systemctl", "--user", "is-active", self.SYSTEMD_NAME],
|
|
540
|
+
capture_output=True, text=True,
|
|
541
|
+
)
|
|
542
|
+
running = r.stdout.strip() == "active"
|
|
543
|
+
return {
|
|
544
|
+
"installed": installed,
|
|
545
|
+
"running": running,
|
|
546
|
+
"platform": "linux",
|
|
547
|
+
"log_path": str(_LOG_FILE),
|
|
548
|
+
"method": "systemd user service (loginctl linger recommended)",
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
def _linux_install(self, port: int) -> dict:
|
|
552
|
+
unit = (
|
|
553
|
+
"[Unit]\n"
|
|
554
|
+
"Description=C3 Project Hub\n"
|
|
555
|
+
"After=network.target\n\n"
|
|
556
|
+
"[Service]\n"
|
|
557
|
+
f"ExecStart={sys.executable} {_C3_PY} hub --port {port} --no-browser --silent --extra-silent\n"
|
|
558
|
+
"Restart=on-failure\n"
|
|
559
|
+
"RestartSec=5\n"
|
|
560
|
+
f"StandardOutput=append:{_LOG_FILE}\n"
|
|
561
|
+
f"StandardError=append:{_LOG_FILE}\n\n"
|
|
562
|
+
"[Install]\n"
|
|
563
|
+
"WantedBy=default.target\n"
|
|
564
|
+
)
|
|
565
|
+
self._service_path.parent.mkdir(parents=True, exist_ok=True)
|
|
566
|
+
self._service_path.write_text(unit, encoding="utf-8")
|
|
567
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
|
568
|
+
r = subprocess.run(
|
|
569
|
+
["systemctl", "--user", "enable", "--now", self.SYSTEMD_NAME],
|
|
570
|
+
capture_output=True, text=True,
|
|
571
|
+
)
|
|
572
|
+
return {
|
|
573
|
+
"success": r.returncode == 0,
|
|
574
|
+
"output": (r.stdout + r.stderr).strip() or "Service enabled and started.",
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
def _linux_uninstall(self) -> dict:
|
|
578
|
+
subprocess.run(
|
|
579
|
+
["systemctl", "--user", "disable", "--now", self.SYSTEMD_NAME],
|
|
580
|
+
capture_output=True,
|
|
581
|
+
)
|
|
582
|
+
if self._service_path.exists():
|
|
583
|
+
self._service_path.unlink()
|
|
584
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
|
585
|
+
return {"success": True, "output": "systemd user service removed."}
|