gctrl 0.1.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.
gctrl/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Ground Control (GCTRL) installer & lifecycle CLI.
2
+
3
+ `pip install gctrl` gives you the `gctrl` command to self-host the GCTRL stack
4
+ (the same Docker Compose stack the curl installer deploys) on macOS, Linux, or
5
+ Windows — Docker is the only prerequisite.
6
+ """
7
+
8
+ __version__ = "0.1.0"
gctrl/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enables `python -m gctrl` as an alias for the `gctrl` console script."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
gctrl/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ """`gctrl` command - installer & lifecycle for the self-hosted GCTRL stack.
2
+
3
+ This is the pip-side sibling of the curl installer. It does NOT talk to a running
4
+ instance's API (that's the separate @gctrl/cli npm tool); it installs and manages
5
+ the local Docker stack.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from . import __version__
14
+ from . import installer
15
+
16
+
17
+ def _build_parser() -> argparse.ArgumentParser:
18
+ p = argparse.ArgumentParser(
19
+ prog="gctrl",
20
+ description="Install and manage a self-hosted Ground Control (GCTRL) stack. "
21
+ "Docker is the only prerequisite.",
22
+ epilog="Docs: https://gctrl.tech/docs - Run `gctrl install` to get started.",
23
+ )
24
+ p.add_argument("-V", "--version", action="version", version=f"gctrl {__version__}")
25
+ sub = p.add_subparsers(dest="command", metavar="<command>")
26
+
27
+ sub.add_parser("install", help="Install & start GCTRL (fetch compose + images, docker compose up)")
28
+ sub.add_parser("update", help="Re-pull compose.yml + images and restart (preserves your .env/secrets)")
29
+ sub.add_parser("up", help="Start an already-installed stack (docker compose up -d)")
30
+ sub.add_parser("down", help="Stop the stack (docker compose down)")
31
+ sub.add_parser("status", help="Show container status (docker compose ps)")
32
+ logs = sub.add_parser("logs", help="Tail logs (docker compose logs -f)")
33
+ logs.add_argument("--no-follow", action="store_true", help="Print current logs and exit")
34
+
35
+ return p
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ args = _build_parser().parse_args(argv if argv is not None else sys.argv[1:])
40
+
41
+ try:
42
+ if args.command in (None, "install"):
43
+ # No subcommand -> the thing everyone wants: install.
44
+ if args.command is None:
45
+ print("gctrl - Ground Control installer. Running `gctrl install`...\n"
46
+ "(see `gctrl --help` for up/down/status/logs/update)\n")
47
+ installer.Installer().install()
48
+ elif args.command == "update":
49
+ installer.update()
50
+ elif args.command == "up":
51
+ installer.up()
52
+ elif args.command == "down":
53
+ installer.down()
54
+ elif args.command == "status":
55
+ installer.status()
56
+ elif args.command == "logs":
57
+ installer.logs(follow=not args.no_follow)
58
+ else: # pragma: no cover - argparse rejects unknown commands first
59
+ _build_parser().print_help()
60
+ return 2
61
+ except KeyboardInterrupt:
62
+ print("\nAborted.", file=sys.stderr)
63
+ return 130
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__": # pragma: no cover
68
+ raise SystemExit(main())
gctrl/installer.py ADDED
@@ -0,0 +1,632 @@
1
+ """
2
+ Ground Control (GCTRL) installer - a faithful, cross-platform Python port of the
3
+ canonical shell installer served at https://gctrl.tech/install.
4
+
5
+ The shell script (services/portal/public/install) remains the reference and is
6
+ NOT replaced - this is an ADDITIONAL entry point (`pip install gctrl`) that
7
+ installs the exact same stack by reusing the exact same server-side artifacts:
8
+ the published docker-compose.yml, the license public key, and the pinned image
9
+ tags. Nothing about GCTRL itself changes; the pip CLI only orchestrates Docker.
10
+
11
+ Why re-port instead of shelling out to the bash script: Python removes the
12
+ curl/openssl/bash prerequisites (uses urllib / secrets / sockets) so the SAME
13
+ install works natively on Windows too, not just macOS/Linux.
14
+
15
+ Keep this behaviourally in sync with the bash reference when that script changes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import platform
23
+ import secrets
24
+ import shutil
25
+ import socket
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ import urllib.request
30
+ from pathlib import Path
31
+
32
+ # -- Constants (mirror the bash reference) ------------------------------------
33
+ GCTRL_VERSION = os.environ.get("GCTRL_VERSION", "latest")
34
+ API_URL = "https://api.gctrl.tech"
35
+ COMPOSE_URL = "https://gctrl.tech/compose.yml"
36
+ PUBKEY_URL = f"{API_URL}/v1/public-key"
37
+ INSTALL_DIR = Path(os.environ.get("GCTRL_INSTALL_DIR", str(Path.home() / "gctrl")))
38
+ CONFIG_DIR = INSTALL_DIR / "config"
39
+ PROFILES_FILE = INSTALL_DIR / ".profiles" # persisted so `gctrl up` reuses them
40
+
41
+ WEB_URL = "http://localhost:3001"
42
+
43
+ NEO4J_USER = "neo4j"
44
+
45
+ # -- Console output -----------------------------------------------------------
46
+ _USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
47
+ def _c(code: str, s: str) -> str:
48
+ return f"\033[{code}m{s}\033[0m" if _USE_COLOR else s
49
+
50
+ def info(msg: str) -> None: print(_c("0;34", "[GCTRL]"), msg)
51
+ def success(msg: str) -> None: print(_c("0;32", "[GCTRL]"), msg)
52
+ def warn(msg: str) -> None: print(_c("1;33", "[GCTRL]"), msg)
53
+ def die(msg: str) -> None:
54
+ print(_c("0;31", "[GCTRL]"), msg, file=sys.stderr)
55
+ raise SystemExit(1)
56
+
57
+
58
+ # -- Small helpers ------------------------------------------------------------
59
+ def have(cmd: str) -> bool:
60
+ return shutil.which(cmd) is not None
61
+
62
+ def run(args: list[str], **kw) -> subprocess.CompletedProcess:
63
+ """Run a command, inheriting stdio by default. check defaults to False."""
64
+ return subprocess.run(args, **kw)
65
+
66
+ def run_quiet(args: list[str]) -> int:
67
+ return subprocess.run(
68
+ args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
69
+ ).returncode
70
+
71
+ def probe_tcp(port: int, host: str = "127.0.0.1", timeout: float = 2.0) -> bool:
72
+ """True if something is listening on host:port (the /dev/tcp probe, portably)."""
73
+ try:
74
+ with socket.create_connection((host, port), timeout=timeout):
75
+ return True
76
+ except OSError:
77
+ return False
78
+
79
+ def download(url: str, dest: Path) -> None:
80
+ dest.parent.mkdir(parents=True, exist_ok=True)
81
+ req = urllib.request.Request(url, headers={"User-Agent": "gctrl-pip-installer"})
82
+ with urllib.request.urlopen(req, timeout=30) as r: # noqa: S310 (trusted host)
83
+ dest.write_bytes(r.read())
84
+
85
+ def _read_prev_env(key: str) -> str:
86
+ """Value of `key` from an existing INSTALL_DIR/.env, or '' - so secrets and
87
+ a bundled DB's baked-in auth are preserved on re-install (never rotated)."""
88
+ env = INSTALL_DIR / ".env"
89
+ if not env.exists():
90
+ return ""
91
+ for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
92
+ if line.startswith(key + "="):
93
+ return line[len(key) + 1:].strip()
94
+ return ""
95
+
96
+
97
+ class Installer:
98
+ """Holds the state the bash script keeps in globals, and the install steps."""
99
+
100
+ def __init__(self) -> None:
101
+ self.profiles: list[str] = []
102
+ self.neo4j_uri = ""
103
+ self.neo4j_password = "gctrl-neo4j-password"
104
+ self.qdrant_url = ""
105
+ self.ollama_base = ""
106
+ self.gpu_enabled = False
107
+ self.gpu_type = "none" # none | nvidia | amd
108
+ self.chat_model = "" # chosen generation model ("" = pick later)
109
+ self.vllm_selected = False
110
+
111
+ # -- prerequisites --------------------------------------------------------
112
+ def check_prereqs(self) -> None:
113
+ info("Checking prerequisites...")
114
+ missing = [c for c in ("docker",) if not have(c)]
115
+ if run_quiet(["docker", "compose", "version"]) != 0:
116
+ missing.append("docker-compose-plugin")
117
+ if missing:
118
+ die(
119
+ f"Missing: {', '.join(missing)}\n"
120
+ "Install Docker (includes Compose): https://docs.docker.com/engine/install/"
121
+ )
122
+ if run_quiet(["docker", "info"]) != 0:
123
+ warn("Docker daemon not running - please start Docker Desktop...")
124
+ waited = 0
125
+ while run_quiet(["docker", "info"]) != 0:
126
+ time.sleep(3)
127
+ waited += 3
128
+ sys.stdout.write(".")
129
+ sys.stdout.flush()
130
+ if waited >= 60:
131
+ print()
132
+ die("Docker is not running after 60s. Start Docker and re-run.")
133
+ print()
134
+ success("Prerequisites OK")
135
+
136
+ # -- GPU detection --------------------------------------------------------
137
+ def detect_gpu(self) -> None:
138
+ if have("nvidia-smi") and run_quiet(["nvidia-smi"]) == 0:
139
+ name = self._nvidia_name() or "NVIDIA GPU"
140
+ if have("nvidia-ctk") or self._docker_has_nvidia_runtime():
141
+ self.gpu_enabled, self.gpu_type = True, "nvidia"
142
+ success(f"GPU detected: {name} - KEX will use NVIDIA acceleration")
143
+ else:
144
+ warn(f"NVIDIA GPU found ({name}) but nvidia-container-toolkit is missing - KEX on CPU.")
145
+ info(" Enable later: install nvidia-container-toolkit, then re-run `gctrl update`.")
146
+ return
147
+ # AMD (Linux only): rocm-smi or /dev/kfd
148
+ if platform.system() == "Linux":
149
+ if (have("rocm-smi") and run_quiet(["rocm-smi"]) == 0) or Path("/dev/kfd").exists():
150
+ if Path("/dev/kfd").exists() and Path("/dev/dri").exists():
151
+ self.gpu_enabled, self.gpu_type = True, "amd"
152
+ success("GPU detected: AMD - KEX will use ROCm acceleration")
153
+ else:
154
+ warn("AMD GPU found but /dev/kfd or /dev/dri not accessible - KEX on CPU.")
155
+ return
156
+ info("No discrete GPU detected - KEX will run on CPU")
157
+
158
+ @staticmethod
159
+ def _nvidia_name() -> str:
160
+ try:
161
+ out = subprocess.check_output(
162
+ ["nvidia-smi", "--query-gpu=gpu_name", "--format=csv,noheader"],
163
+ stderr=subprocess.DEVNULL, text=True,
164
+ )
165
+ return out.strip().splitlines()[0].strip()
166
+ except Exception:
167
+ return ""
168
+
169
+ @staticmethod
170
+ def _docker_has_nvidia_runtime() -> bool:
171
+ try:
172
+ out = subprocess.check_output(
173
+ ["docker", "info", "--format", "{{json .Runtimes}}"],
174
+ stderr=subprocess.DEVNULL, text=True,
175
+ )
176
+ return "nvidia" in out.lower()
177
+ except Exception:
178
+ return False
179
+
180
+ # -- service detection ----------------------------------------------------
181
+ def detect_services(self) -> None:
182
+ print("\n" + "-" * 46)
183
+ info("Detecting existing infrastructure...\n")
184
+
185
+ if probe_tcp(7687):
186
+ self.neo4j_uri = "bolt://host.docker.internal:7687"
187
+ success("Neo4j detected on :7687 - connecting to existing instance")
188
+ else:
189
+ self.profiles.append("bundled-neo4j")
190
+ self.neo4j_uri = "bolt://gctrl-neo4j:7687"
191
+ info("Neo4j not found - deploying bundled container")
192
+
193
+ if probe_tcp(6333):
194
+ self.qdrant_url = "http://host.docker.internal:6333"
195
+ success("Qdrant detected on :6333 - connecting to existing instance")
196
+ else:
197
+ self.profiles.append("bundled-qdrant")
198
+ self.qdrant_url = "http://gctrl-qdrant:6333"
199
+ info("Qdrant not found - deploying bundled container")
200
+
201
+ if probe_tcp(11434):
202
+ if self._ollama_reachable_from_containers():
203
+ self.ollama_base = "http://host.docker.internal:11434"
204
+ success("Ollama detected on :11434 - connecting to your native instance (GPU-capable)")
205
+ else:
206
+ self.profiles.append("bundled-ollama")
207
+ self.ollama_base = "http://gctrl-ollama:11434"
208
+ warn("Native Ollama listens on localhost only - Docker can't reach it.")
209
+ warn("-> Using the bundled (CPU) Ollama so GCTRL works out of the box.")
210
+ warn(" For GPU: set OLLAMA_HOST=0.0.0.0, then switch in Settings -> AI Models.")
211
+ else:
212
+ self.profiles.append("bundled-ollama")
213
+ self.ollama_base = "http://gctrl-ollama:11434"
214
+ info("Ollama not found - deploying bundled container")
215
+
216
+ print()
217
+ if not self.profiles:
218
+ success("All infrastructure already present - connecting to your existing stack")
219
+ else:
220
+ info(f"Will deploy bundled: {' '.join(self.profiles)}")
221
+ print("-" * 46 + "\n")
222
+
223
+ @staticmethod
224
+ def _ollama_reachable_from_containers() -> bool:
225
+ """Best-effort: is Ollama bound to all interfaces (containers can reach it)
226
+ or localhost-only? Parse ss/netstat; unknown -> assume reachable (matches bash)."""
227
+ for cmd in (["ss", "-tlnH"], ["netstat", "-tln"], ["netstat", "-an"]):
228
+ if not have(cmd[0]):
229
+ continue
230
+ try:
231
+ out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True)
232
+ except Exception:
233
+ continue
234
+ lines = [ln for ln in out.splitlines() if ":11434" in ln or ".11434" in ln]
235
+ if not lines:
236
+ continue
237
+ joined = "\n".join(lines)
238
+ if any(b in joined for b in ("0.0.0.0:11434", "*:11434", "[::]:11434", "0.0.0.0.11434")):
239
+ return True
240
+ if "127.0.0.1:11434" in joined or "127.0.0.1.11434" in joined:
241
+ return False
242
+ return True
243
+ return True # unknown -> assume OK; the dashboard guides the user otherwise
244
+
245
+ # -- config generation ----------------------------------------------------
246
+ def generate_config(self) -> None:
247
+ info(f"Creating {INSTALL_DIR}...")
248
+ (INSTALL_DIR / "data").mkdir(parents=True, exist_ok=True)
249
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
250
+
251
+ download(COMPOSE_URL, INSTALL_DIR / "docker-compose.yml")
252
+ download(PUBKEY_URL, CONFIG_DIR / "license.pub")
253
+ try:
254
+ (CONFIG_DIR / "license.pub").chmod(0o600)
255
+ except OSError:
256
+ pass # Windows
257
+
258
+ # Secrets: preserved on re-install (a bundled DB bakes auth into its volume
259
+ # on first start - rotating would lock us out), else freshly generated.
260
+ jwt_secret = _read_prev_env("JWT_SECRET") or secrets.token_hex(32)
261
+ pg_pw = _read_prev_env("POSTGRES_PASSWORD") or secrets.token_hex(24)
262
+ internal_secret = _read_prev_env("INTERNAL_API_SECRET") or secrets.token_hex(32)
263
+ if self.neo4j_uri == "bolt://gctrl-neo4j:7687":
264
+ self.neo4j_password = _read_prev_env("NEO4J_PASSWORD") or secrets.token_hex(24)
265
+ vllm_model = _read_prev_env("VLLM_MODEL") or "Qwen/Qwen2.5-3B-Instruct"
266
+
267
+ env = "\n".join([
268
+ "GCTRL_API_URL=https://api.gctrl.tech",
269
+ f"GCTRL_DATA_DIR={INSTALL_DIR / 'data'}",
270
+ f"JWT_SECRET={jwt_secret}",
271
+ f"INTERNAL_API_SECRET={internal_secret}",
272
+ f"NEO4J_URI={self.neo4j_uri}",
273
+ f"NEO4J_USER={NEO4J_USER}",
274
+ f"NEO4J_PASSWORD={self.neo4j_password}",
275
+ f"QDRANT_URL={self.qdrant_url}",
276
+ f"OLLAMA_BASE={self.ollama_base}",
277
+ f"RELEX_MODEL={self.chat_model or 'qwen2.5:7b'}",
278
+ f"AUTO_CLASSIFY_MODEL={self.chat_model or 'llama3.2'}",
279
+ f"POSTGRES_PASSWORD={pg_pw}",
280
+ f"VLLM_MODEL={vllm_model}",
281
+ "",
282
+ ])
283
+ (INSTALL_DIR / ".env").write_text(env, encoding="utf-8")
284
+ try:
285
+ (INSTALL_DIR / ".env").chmod(0o600)
286
+ except OSError:
287
+ pass
288
+
289
+ if self.gpu_enabled:
290
+ self._write_gpu_override()
291
+ success("Config generated")
292
+
293
+ def _write_gpu_override(self) -> None:
294
+ path = INSTALL_DIR / "docker-compose.override.yml"
295
+ if self.gpu_type == "nvidia":
296
+ info("Writing NVIDIA GPU override...")
297
+ path.write_text(_NVIDIA_OVERRIDE, encoding="utf-8")
298
+ elif self.gpu_type == "amd":
299
+ info("Writing AMD ROCm GPU override...")
300
+ path.write_text(_AMD_OVERRIDE, encoding="utf-8")
301
+
302
+ # -- hardware.json (Settings -> Infrastructure) ----------------------------
303
+ def detect_hardware(self) -> None:
304
+ hw = {
305
+ "cpu_cores": os.cpu_count() or 0,
306
+ "ram_gb": _ram_gb(),
307
+ "gpu_name": self._nvidia_name(),
308
+ "vram_gb": _nvidia_vram_gb(),
309
+ "nvidia_toolkit": have("nvidia-ctk") or self._docker_has_nvidia_runtime(),
310
+ "arch": _arch(),
311
+ "os": "darwin" if platform.system() == "Darwin" else ("windows" if platform.system() == "Windows" else "linux"),
312
+ }
313
+ (INSTALL_DIR / "hardware.json").write_text(json.dumps(hw, indent=2), encoding="utf-8")
314
+ info(f"Hardware profile written (cpu={hw['cpu_cores']}, ram={hw['ram_gb']}GB, "
315
+ f"gpu='{hw['gpu_name']}', vram={hw['vram_gb']}GB, toolkit={hw['nvidia_toolkit']})")
316
+
317
+ # -- model + runtime pickers ----------------------------------------------
318
+ def choose_model(self) -> None:
319
+ env_model = os.environ.get("GCTRL_MODEL")
320
+ if env_model:
321
+ self.chat_model = env_model
322
+ info(f"Model (from GCTRL_MODEL): {self.chat_model}")
323
+ return
324
+ default = "llama3.2:3b"
325
+ if not _interactive():
326
+ self.chat_model = default
327
+ info(f"Non-interactive - default model {self.chat_model} (override: GCTRL_MODEL=...)")
328
+ return
329
+ print("\n" + "-" * 46)
330
+ print(" Choose the local model GCTRL uses for extraction & RAG:")
331
+ print(" 1) llama3.2:3b - lightweight, fast (~2 GB) [default]")
332
+ print(" 2) qwen2.5:7b - best extraction quality (~5 GB)")
333
+ print(" 3) qwen2.5:14b - higher quality (~9 GB, 16 GB+ RAM)")
334
+ print(" 4) qwen2.5:32b - max quality (~20 GB, 32 GB+ RAM)")
335
+ print(" 5) custom - enter any Ollama tag")
336
+ print(" 6) skip - choose later in the dashboard")
337
+ choice = _ask(" Choice [1]: ") or "1"
338
+ self.chat_model = {
339
+ "1": "llama3.2:3b", "2": "qwen2.5:7b", "3": "qwen2.5:14b",
340
+ "4": "qwen2.5:32b", "6": "",
341
+ }.get(choice, default)
342
+ if choice == "5":
343
+ self.chat_model = _ask(" Ollama model tag: ") or default
344
+ print("-" * 46)
345
+ info(f"Model: {self.chat_model}" if self.chat_model else "Skipping model - pick later in Settings -> Models")
346
+ print()
347
+
348
+ def choose_runtime(self) -> None:
349
+ """Offer vLLM only when nvidia_toolkit AND vram_gb >= 8 (mirrors infra.rs gate)."""
350
+ hw_file = INSTALL_DIR / "hardware.json"
351
+ if not hw_file.exists():
352
+ return
353
+ try:
354
+ hw = json.loads(hw_file.read_text(encoding="utf-8"))
355
+ except Exception:
356
+ return
357
+ if not hw.get("nvidia_toolkit"):
358
+ return
359
+ try:
360
+ vram = float(hw.get("vram_gb") or 0)
361
+ except (TypeError, ValueError):
362
+ vram = 0.0
363
+ if vram < 8:
364
+ warn(f"NVIDIA GPU with {vram} GB VRAM < 8 GB - skipping vLLM offer")
365
+ return
366
+ env_rt = os.environ.get("GCTRL_RUNTIME")
367
+ if env_rt:
368
+ if env_rt == "vllm":
369
+ self.vllm_selected = True
370
+ self.profiles.append("bundled-vllm")
371
+ info("Runtime (from GCTRL_RUNTIME): vLLM selected")
372
+ return
373
+ if not _interactive():
374
+ return # safe default: don't pull the ~10 GB vLLM image unattended
375
+ print("\n" + "-" * 46)
376
+ info(f"NVIDIA GPU with {vram} GB VRAM detected")
377
+ print(" 1) No - use bundled Ollama (default, always works)")
378
+ print(" 2) Yes - enable vLLM (pulls vllm/vllm-openai:latest, ~10 GB)")
379
+ if (_ask(" Enable vLLM? [1]: ") or "1") == "2":
380
+ self.vllm_selected = True
381
+ self.profiles.append("bundled-vllm")
382
+ success("vLLM selected - will deploy gctrl-vllm container")
383
+ else:
384
+ info("Keeping bundled Ollama (enable vLLM later in Settings -> Infrastructure)")
385
+ print("-" * 46 + "\n")
386
+
387
+ # -- stack lifecycle ------------------------------------------------------
388
+ def _profile_flags(self) -> list[str]:
389
+ flags: list[str] = []
390
+ for p in self.profiles:
391
+ flags += ["--profile", p]
392
+ return flags
393
+
394
+ def _compose(self, *args: str) -> list[str]:
395
+ return [
396
+ "docker", "compose",
397
+ "-f", str(INSTALL_DIR / "docker-compose.yml"),
398
+ "--env-file", str(INSTALL_DIR / ".env"),
399
+ *self._profile_flags(), *args,
400
+ ]
401
+
402
+ def start_stack(self) -> None:
403
+ info("Starting GCTRL...")
404
+ run(self._compose("pull"))
405
+ run(self._compose("up", "-d"))
406
+ # Persist profiles so `gctrl up/down/logs` reuse them without re-detecting.
407
+ PROFILES_FILE.write_text("\n".join(self.profiles), encoding="utf-8")
408
+
409
+ info("Waiting for the web UI (up to 120s)...")
410
+ waited = 0
411
+ while not _http_ok(WEB_URL):
412
+ time.sleep(3)
413
+ waited += 3
414
+ if waited >= 120:
415
+ die(f"Timeout. Check: docker compose -f {INSTALL_DIR / 'docker-compose.yml'} logs")
416
+ success(f"Web UI is live -> {WEB_URL}\n")
417
+
418
+ def finalize(self) -> None:
419
+ if self.chat_model:
420
+ self._patch_env_model()
421
+ run(self._compose("up", "-d", "gctrl-kex", "gctrl-fuse"),
422
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
423
+ print("\n" + "-" * 46)
424
+ success("GCTRL is running!\n")
425
+ print(f" Open {WEB_URL} to activate your license and complete setup")
426
+ print(f" Installed : {INSTALL_DIR}")
427
+ print(f" GPU : {self.gpu_type} acceleration" if self.gpu_enabled else " GPU : CPU mode")
428
+ print()
429
+ if "bundled-ollama" in self.profiles:
430
+ self._pull_ollama_models()
431
+ else:
432
+ print(f" Ollama : connected at {self.ollama_base}")
433
+ print("-" * 46)
434
+
435
+ def _patch_env_model(self) -> None:
436
+ env = INSTALL_DIR / ".env"
437
+ lines = env.read_text(encoding="utf-8").splitlines()
438
+ out = []
439
+ for ln in lines:
440
+ if ln.startswith("RELEX_MODEL="):
441
+ out.append(f"RELEX_MODEL={self.chat_model}")
442
+ elif ln.startswith("AUTO_CLASSIFY_MODEL="):
443
+ out.append(f"AUTO_CLASSIFY_MODEL={self.chat_model}")
444
+ else:
445
+ out.append(ln)
446
+ env.write_text("\n".join(out) + "\n", encoding="utf-8")
447
+
448
+ def _pull_ollama_models(self) -> None:
449
+ print(" Ollama : bundled - pulling required local models (one-time)...")
450
+ models = ["nomic-embed-text"]
451
+ if self.chat_model:
452
+ models = [self.chat_model, *models]
453
+ for m in models:
454
+ print(f" pulling {m} ...")
455
+ if run_quiet(["docker", "exec", "gctrl-ollama", "ollama", "pull", m]) != 0:
456
+ print(f" WARN: failed to pull {m} - retry: docker exec gctrl-ollama ollama pull {m}")
457
+
458
+ # -- entry point ----------------------------------------------------------
459
+ def install(self) -> None:
460
+ self.check_prereqs()
461
+ self.detect_gpu()
462
+ self.detect_services()
463
+ self.generate_config()
464
+ self.detect_hardware()
465
+ self.choose_runtime()
466
+ self.start_stack() # bring the stack up FIRST -> UI live at :3001
467
+ self.choose_model() # ask model question only after the UI is up
468
+ # choose_model may have set chat_model; config was written with placeholders.
469
+ self.finalize()
470
+
471
+
472
+ # -- module-level lifecycle helpers used by `gctrl up/down/...` ----------------
473
+ def _saved_profiles() -> list[str]:
474
+ if PROFILES_FILE.exists():
475
+ return [p for p in PROFILES_FILE.read_text(encoding="utf-8").splitlines() if p]
476
+ return []
477
+
478
+ def _compose_base(*args: str, with_env: bool = True) -> list[str]:
479
+ cmd = ["docker", "compose", "-f", str(INSTALL_DIR / "docker-compose.yml")]
480
+ if with_env and (INSTALL_DIR / ".env").exists():
481
+ cmd += ["--env-file", str(INSTALL_DIR / ".env")]
482
+ for p in _saved_profiles():
483
+ cmd += ["--profile", p]
484
+ return cmd + list(args)
485
+
486
+ def _require_installed() -> None:
487
+ if not (INSTALL_DIR / "docker-compose.yml").exists():
488
+ die(f"GCTRL is not installed at {INSTALL_DIR}. Run: gctrl install")
489
+
490
+ def up() -> None:
491
+ _require_installed()
492
+ run(_compose_base("up", "-d"))
493
+ success(f"GCTRL started -> {WEB_URL}")
494
+
495
+ def down() -> None:
496
+ _require_installed()
497
+ run(_compose_base("down"))
498
+ success("GCTRL stopped")
499
+
500
+ def status() -> None:
501
+ _require_installed()
502
+ run(_compose_base("ps"))
503
+
504
+ def logs(follow: bool = True) -> None:
505
+ _require_installed()
506
+ run(_compose_base("logs", *(["-f"] if follow else [])))
507
+
508
+ def update() -> None:
509
+ """Re-run the installer: re-pulls compose.yml + images, preserves .env/secrets."""
510
+ Installer().install()
511
+
512
+
513
+ # -- platform detection helpers -----------------------------------------------
514
+ def _interactive() -> bool:
515
+ return sys.stdin is not None and sys.stdin.isatty()
516
+
517
+ def _ask(prompt: str) -> str:
518
+ try:
519
+ return input(prompt).strip()
520
+ except (EOFError, KeyboardInterrupt):
521
+ print()
522
+ return ""
523
+
524
+ def _http_ok(url: str) -> bool:
525
+ try:
526
+ with urllib.request.urlopen(url, timeout=3) as r: # noqa: S310
527
+ return 200 <= r.status < 500
528
+ except Exception:
529
+ return False
530
+
531
+ def _arch() -> str:
532
+ m = platform.machine().lower()
533
+ return {"aarch64": "arm64", "arm64": "arm64", "x86_64": "x86_64", "amd64": "x86_64"}.get(m, m or "unknown")
534
+
535
+ def _ram_gb() -> float:
536
+ try:
537
+ sysname = platform.system()
538
+ if sysname == "Linux" and Path("/proc/meminfo").exists():
539
+ for line in Path("/proc/meminfo").read_text().splitlines():
540
+ if line.startswith("MemTotal:"):
541
+ kb = int(line.split()[1])
542
+ return round(kb / 1048576, 1)
543
+ if sysname == "Darwin":
544
+ out = subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True)
545
+ return round(int(out.strip()) / 1073741824, 1)
546
+ if sysname == "Windows":
547
+ import ctypes
548
+ class MEMORYSTATUSEX(ctypes.Structure):
549
+ _fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
550
+ ("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
551
+ ("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
552
+ ("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
553
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
554
+ stat = MEMORYSTATUSEX()
555
+ stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
556
+ ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)) # type: ignore[attr-defined]
557
+ return round(stat.ullTotalPhys / 1073741824, 1)
558
+ except Exception:
559
+ pass
560
+ return 0.0
561
+
562
+ def _nvidia_vram_gb() -> float:
563
+ try:
564
+ out = subprocess.check_output(
565
+ ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
566
+ stderr=subprocess.DEVNULL, text=True,
567
+ )
568
+ mib = int(out.strip().splitlines()[0].strip())
569
+ return round(mib / 1024, 1)
570
+ except Exception:
571
+ return 0.0
572
+
573
+
574
+ _NVIDIA_OVERRIDE = """\
575
+ # Auto-generated by the GCTRL installer - NVIDIA GPU detected.
576
+ # Delete this file to switch back to CPU mode, then run: gctrl update
577
+ services:
578
+ gctrl-fuse:
579
+ image: ghcr.io/gctrl-tech/fuse:latest-cuda
580
+ deploy:
581
+ resources:
582
+ reservations:
583
+ devices:
584
+ - driver: nvidia
585
+ count: all
586
+ capabilities: [gpu]
587
+ gctrl-kex:
588
+ image: ghcr.io/gctrl-tech/kex:latest-cuda
589
+ deploy:
590
+ resources:
591
+ reservations:
592
+ devices:
593
+ - driver: nvidia
594
+ count: all
595
+ capabilities: [gpu]
596
+ gctrl-ollama:
597
+ deploy:
598
+ resources:
599
+ reservations:
600
+ devices:
601
+ - driver: nvidia
602
+ count: all
603
+ capabilities: [gpu]
604
+ """
605
+
606
+ _AMD_OVERRIDE = """\
607
+ # Auto-generated by the GCTRL installer - AMD GPU detected.
608
+ # Delete this file to switch back to CPU mode, then run: gctrl update
609
+ services:
610
+ gctrl-fuse:
611
+ image: ghcr.io/gctrl-tech/fuse:latest-cuda
612
+ devices:
613
+ - /dev/kfd:/dev/kfd
614
+ - /dev/dri:/dev/dri
615
+ group_add:
616
+ - video
617
+ - render
618
+ gctrl-kex:
619
+ devices:
620
+ - /dev/kfd:/dev/kfd
621
+ - /dev/dri:/dev/dri
622
+ group_add:
623
+ - video
624
+ - render
625
+ gctrl-ollama:
626
+ devices:
627
+ - /dev/kfd:/dev/kfd
628
+ - /dev/dri:/dev/dri
629
+ group_add:
630
+ - video
631
+ - render
632
+ """
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: gctrl
3
+ Version: 0.1.0
4
+ Summary: Ground Control (GCTRL) installer & lifecycle CLI — self-host the sovereign knowledge-graph memory layer for AI with one command.
5
+ Project-URL: Homepage, https://gctrl.tech
6
+ Project-URL: Documentation, https://gctrl.tech/docs
7
+ Project-URL: Source, https://github.com/GCTRL-TECH/platform
8
+ Author-email: "Cinque Monti Ltd. (GCTRL)" <hello@gctrl.tech>
9
+ License-Expression: AGPL-3.0-or-later
10
+ Keywords: ai-memory,docker,gctrl,knowledge-graph,rag,self-hosted
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Installation/Setup
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+
21
+ # GCTRL — pip installer
22
+
23
+ Self-host **Ground Control (GCTRL)**, the sovereign knowledge-graph memory layer
24
+ for AI, with one command. This is the `pip` sibling of the `curl | bash` installer
25
+ — same stack, same images, but cross-platform (macOS, Linux, **and Windows**) with
26
+ **Docker as the only prerequisite** (no curl / openssl / bash needed).
27
+
28
+ ```bash
29
+ pipx install gctrl # recommended (isolated CLI)
30
+ # or: pip install gctrl
31
+
32
+ gctrl install # fetches compose.yml + images, brings the stack up
33
+ ```
34
+
35
+ Then open <http://localhost:3001> to activate your license and finish setup.
36
+
37
+ ## Commands
38
+
39
+ | Command | What it does |
40
+ |---|---|
41
+ | `gctrl install` | Detect infra (Neo4j/Qdrant/Ollama/GPU), write `~/gctrl/.env`, `docker compose up -d`, wait for the UI |
42
+ | `gctrl update` | Re-pull `compose.yml` + images and restart — **preserves** your `.env` and generated secrets |
43
+ | `gctrl up` / `gctrl down` | Start / stop the installed stack |
44
+ | `gctrl status` | `docker compose ps` |
45
+ | `gctrl logs` | Tail logs (`--no-follow` to print and exit) |
46
+
47
+ ## Non-interactive install
48
+
49
+ Honors the same environment variables as the shell installer:
50
+
51
+ ```bash
52
+ GCTRL_MODEL=qwen2.5:7b GCTRL_RUNTIME=vllm gctrl install
53
+ ```
54
+
55
+ - `GCTRL_MODEL` — local generation model (default `llama3.2:3b`)
56
+ - `GCTRL_RUNTIME=vllm` — enable vLLM (only on NVIDIA hosts with ≥8 GB VRAM)
57
+ - `GCTRL_INSTALL_DIR` — install location (default `~/gctrl`)
58
+
59
+ ## How it relates to the curl installer
60
+
61
+ `curl -fsSL https://gctrl.tech/install | bash` still works and is unchanged. This
62
+ package installs the **same** stack by reusing the same server-side artifacts
63
+ (`https://gctrl.tech/compose.yml`, the license public key, the pinned image tags).
64
+ Picking pip vs curl changes nothing about GCTRL itself — only how you bootstrap it.
65
+
66
+ Not to be confused with the `@gctrl/cli` npm package, which *talks to* a running
67
+ instance's API. This package *installs and runs* the stack.
68
+
69
+ Docs: <https://gctrl.tech/docs> · License: AGPL-3.0-or-later
@@ -0,0 +1,8 @@
1
+ gctrl/__init__.py,sha256=xNW9JgPGEE1VU6ceHZYAGPyCwbUhhRl3BWtFr9IxMRk,284
2
+ gctrl/__main__.py,sha256=FsoRV5FyVsXBHA5IDsL1evRLR65YnakUWrdRTEWmTtw,156
3
+ gctrl/cli.py,sha256=1RATjxITIMrU57Z2bYaS-EaLZ8dqrM-PCXm72DDmkKE,2700
4
+ gctrl/installer.py,sha256=fj5ZqG1qv6V8LqN5hJ9eTW81K0HFkYuCqN32dxnqlT0,25660
5
+ gctrl-0.1.0.dist-info/METADATA,sha256=WL_9d69aGlujwO4hKYF_yylnrAByO4VjW2W2gXeVa_E,2893
6
+ gctrl-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ gctrl-0.1.0.dist-info/entry_points.txt,sha256=Rclj85TMwGmR7nuk6wD-sSwUMgjxPGx4ixTBEaP528s,41
8
+ gctrl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gctrl = gctrl.cli:main