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 +8 -0
- gctrl/__main__.py +6 -0
- gctrl/cli.py +68 -0
- gctrl/installer.py +632 -0
- gctrl-0.1.0.dist-info/METADATA +69 -0
- gctrl-0.1.0.dist-info/RECORD +8 -0
- gctrl-0.1.0.dist-info/WHEEL +4 -0
- gctrl-0.1.0.dist-info/entry_points.txt +2 -0
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
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,,
|