getbased-agent-stack 0.2.0__py3-none-any.whl → 0.4.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.
- getbased_agent_stack/__init__.py +1 -1
- getbased_agent_stack/cli.py +341 -47
- getbased_agent_stack/env_file.py +102 -0
- getbased_agent_stack/mcp_configs.py +177 -0
- getbased_agent_stack/systemd/getbased-dashboard.service +36 -0
- getbased_agent_stack/systemd/getbased-rag.service +41 -0
- getbased_agent_stack/units.py +198 -0
- {getbased_agent_stack-0.2.0.dist-info → getbased_agent_stack-0.4.0.dist-info}/METADATA +54 -24
- getbased_agent_stack-0.4.0.dist-info/RECORD +13 -0
- getbased_agent_stack-0.2.0.dist-info/RECORD +0 -8
- {getbased_agent_stack-0.2.0.dist-info → getbased_agent_stack-0.4.0.dist-info}/WHEEL +0 -0
- {getbased_agent_stack-0.2.0.dist-info → getbased_agent_stack-0.4.0.dist-info}/entry_points.txt +0 -0
- {getbased_agent_stack-0.2.0.dist-info → getbased_agent_stack-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {getbased_agent_stack-0.2.0.dist-info → getbased_agent_stack-0.4.0.dist-info}/top_level.txt +0 -0
getbased_agent_stack/__init__.py
CHANGED
getbased_agent_stack/cli.py
CHANGED
|
@@ -1,72 +1,366 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
"""getbased-stack — orchestration CLI.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
init — interactive one-time setup: token, API key, systemd units
|
|
5
|
+
install — install/refresh the bundled systemd user units
|
|
6
|
+
uninstall — stop, disable, and remove the systemd units
|
|
7
|
+
status — show env file, units, and linger state
|
|
8
|
+
set KEY=VALUE — upsert a single var in the shared env file
|
|
9
|
+
mcp-config CLIENT — print the MCP client config snippet
|
|
10
|
+
version — print installed package versions
|
|
11
|
+
info / serve / everything else → delegate to the `lens` CLI
|
|
12
|
+
|
|
13
|
+
Deliberate zero-dep: argparse + stdlib only. No typer, no click, no
|
|
14
|
+
python-dotenv. The env file format is simple enough that we own it.
|
|
15
|
+
"""
|
|
5
16
|
from __future__ import annotations
|
|
6
17
|
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import shutil
|
|
7
22
|
import sys
|
|
23
|
+
from pathlib import Path
|
|
8
24
|
|
|
25
|
+
from . import env_file, mcp_configs, units
|
|
9
26
|
|
|
10
|
-
HELP = """\
|
|
11
|
-
getbased-stack — thin wrapper over the two binaries installed by this package.
|
|
12
27
|
|
|
13
|
-
|
|
14
|
-
lens — getbased-rag CLI (serve, ingest, stats, ...)
|
|
15
|
-
getbased-mcp — getbased-mcp stdio server (spawned by agents)
|
|
28
|
+
# ── helpers ───────────────────────────────────────────────────────────
|
|
16
29
|
|
|
17
|
-
This wrapper only exists for discoverability:
|
|
18
|
-
getbased-stack serve → lens serve
|
|
19
|
-
getbased-stack info → lens info
|
|
20
|
-
getbased-stack version → print the installed package versions
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
3. configure your MCP agent (Claude Code, Hermes) — see the README
|
|
26
|
-
"""
|
|
31
|
+
def _default_api_key_file() -> Path:
|
|
32
|
+
xdg = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
|
|
33
|
+
return Path(xdg) / "getbased" / "lens" / "api_key"
|
|
27
34
|
|
|
28
35
|
|
|
29
|
-
def
|
|
30
|
-
|
|
36
|
+
def _ensure_api_key(path: Path) -> str:
|
|
37
|
+
"""Generate a key if missing; return the key text either way. Mode 0600."""
|
|
38
|
+
if path.exists():
|
|
39
|
+
return path.read_text(encoding="utf-8").strip()
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
key = secrets.token_urlsafe(32)
|
|
42
|
+
path.write_text(key + "\n", encoding="utf-8")
|
|
43
|
+
os.chmod(path, 0o600)
|
|
44
|
+
return key
|
|
31
45
|
|
|
32
|
-
argv = sys.argv[1:]
|
|
33
|
-
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
34
|
-
print(HELP)
|
|
35
|
-
return 0
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
import
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
def _prompt(msg: str, default: str = "", secret: bool = False) -> str:
|
|
48
|
+
"""Non-intrusive readline prompt. Treats EOF/Ctrl-D as 'use default'."""
|
|
49
|
+
suffix = f" [{default}]" if default else ""
|
|
50
|
+
try:
|
|
51
|
+
if secret:
|
|
52
|
+
import getpass
|
|
53
|
+
|
|
54
|
+
answer = getpass.getpass(f"{msg}{suffix}: ")
|
|
55
|
+
else:
|
|
56
|
+
answer = input(f"{msg}{suffix}: ")
|
|
57
|
+
except EOFError:
|
|
58
|
+
return default
|
|
59
|
+
return answer.strip() or default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _display_value(key: str, value: str) -> str:
|
|
63
|
+
"""Mask only actual secrets (a variable NAMED token/key/secret/password).
|
|
64
|
+
Keys like LENS_API_KEY_FILE that hold a path, not a secret, show verbatim."""
|
|
65
|
+
upper = key.upper()
|
|
66
|
+
# Values that end with _FILE or _PATH are filesystem paths — show them.
|
|
67
|
+
if upper.endswith(("_FILE", "_PATH", "_DIR", "_HOME", "_URL")):
|
|
68
|
+
return value
|
|
69
|
+
if any(upper.endswith("_" + s) or upper == s for s in ("TOKEN", "KEY", "SECRET", "PASSWORD")):
|
|
70
|
+
return "****" + value[-4:] if len(value) > 4 else "****"
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _yesno(msg: str, default: bool = True) -> bool:
|
|
75
|
+
suffix = "[Y/n]" if default else "[y/N]"
|
|
76
|
+
try:
|
|
77
|
+
ans = input(f"{msg} {suffix}: ").strip().lower()
|
|
78
|
+
except EOFError:
|
|
79
|
+
return default
|
|
80
|
+
if not ans:
|
|
81
|
+
return default
|
|
82
|
+
return ans in ("y", "yes")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── subcommand implementations ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
89
|
+
print("getbased-stack init — one-time setup")
|
|
90
|
+
print(
|
|
91
|
+
"Writes ~/.config/getbased/env, (optionally) installs + starts systemd\n"
|
|
92
|
+
"user units for rag and dashboard. Idempotent: safe to re-run."
|
|
93
|
+
)
|
|
94
|
+
print()
|
|
95
|
+
|
|
96
|
+
# 1. token (optional)
|
|
97
|
+
existing = env_file.read_env_file()
|
|
98
|
+
current_token = existing.get("GETBASED_TOKEN", "")
|
|
99
|
+
masked = "****" + current_token[-4:] if current_token else "(unset)"
|
|
100
|
+
print(f"[1/4] getbased sync token (current: {masked})")
|
|
101
|
+
token = _prompt(
|
|
102
|
+
"Paste GETBASED_TOKEN (press Enter to keep current / skip)",
|
|
103
|
+
default=current_token,
|
|
104
|
+
secret=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 2. API key
|
|
108
|
+
key_path = Path(existing.get("LENS_API_KEY_FILE", str(_default_api_key_file())))
|
|
109
|
+
print(f"\n[2/4] rag API key file ({key_path})")
|
|
110
|
+
if key_path.exists():
|
|
111
|
+
print(" existing key found — reusing (init is idempotent).")
|
|
112
|
+
else:
|
|
113
|
+
print(" no key found — one will be generated on first service start.")
|
|
114
|
+
key_value = _ensure_api_key(key_path)
|
|
115
|
+
print(f" key ready (length {len(key_value)} chars, mode 0600)")
|
|
116
|
+
|
|
117
|
+
# 3. write env file
|
|
118
|
+
print("\n[3/4] writing ~/.config/getbased/env")
|
|
119
|
+
merged = {**existing}
|
|
120
|
+
merged["GETBASED_STACK_MANAGED"] = "1"
|
|
121
|
+
if token:
|
|
122
|
+
merged["GETBASED_TOKEN"] = token
|
|
123
|
+
merged["LENS_API_KEY_FILE"] = str(key_path)
|
|
124
|
+
merged.setdefault("LENS_URL", "http://127.0.0.1:8322")
|
|
125
|
+
path = env_file.write_env_file(merged)
|
|
126
|
+
print(f" wrote {path} (mode 0600)")
|
|
127
|
+
|
|
128
|
+
# 4. install units
|
|
129
|
+
print("\n[4/4] install systemd user units?")
|
|
130
|
+
if _yesno("install + start getbased-rag + getbased-dashboard?", default=True):
|
|
131
|
+
mgr = units.UnitManager()
|
|
132
|
+
for line in mgr.install(enable=True, start=True):
|
|
133
|
+
print(f" {line}")
|
|
134
|
+
else:
|
|
135
|
+
print(" skipped — run `getbased-stack install` later to enable.")
|
|
136
|
+
|
|
137
|
+
# 5. linger check
|
|
138
|
+
print("\n── Post-install ──")
|
|
139
|
+
_print_linger_hint(strict=False)
|
|
140
|
+
|
|
141
|
+
# 6. MCP config pointers
|
|
142
|
+
print("\nConfigure your MCP client(s):")
|
|
143
|
+
for client in mcp_configs.SUPPORTED_CLIENTS:
|
|
144
|
+
print(f" getbased-stack mcp-config {client}")
|
|
145
|
+
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _print_linger_hint(strict: bool) -> None:
|
|
150
|
+
user = os.environ.get("USER", "")
|
|
151
|
+
if not user:
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
linger_on = units.has_linger(user)
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
# loginctl not on PATH (uncommon but possible in minimal containers)
|
|
157
|
+
print(" loginctl not found — cannot check linger status.")
|
|
158
|
+
return
|
|
159
|
+
gui = units.is_gui_session()
|
|
160
|
+
if linger_on:
|
|
161
|
+
print(" linger: enabled ✓ (services will survive logout + reboot)")
|
|
162
|
+
return
|
|
163
|
+
# Not on.
|
|
164
|
+
if gui:
|
|
165
|
+
# Laptop with GUI login — user will be logged in when they use this,
|
|
166
|
+
# so linger is nice-to-have, not blocking.
|
|
167
|
+
print(" linger: off (fine for laptops; services only run while you're logged in)")
|
|
168
|
+
print(f" enable with: sudo loginctl enable-linger {user}")
|
|
169
|
+
return
|
|
170
|
+
# Headless + no linger = services die on logout. This is the "silent
|
|
171
|
+
# breakage after reboot" failure mode.
|
|
172
|
+
print(" ⚠ linger: off AND no GUI session detected (headless).")
|
|
173
|
+
print(f" Without linger, rag + dashboard will stop as soon as this SSH session ends.")
|
|
174
|
+
print(f" Run this once, then re-enable with `systemctl --user start getbased-rag.service`:")
|
|
175
|
+
print(f" sudo loginctl enable-linger {user}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
179
|
+
mgr = units.UnitManager()
|
|
180
|
+
for line in mgr.install(enable=not args.no_enable, start=not args.no_start):
|
|
181
|
+
print(line)
|
|
182
|
+
_print_linger_hint(strict=False)
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
187
|
+
mgr = units.UnitManager()
|
|
188
|
+
for line in mgr.uninstall():
|
|
189
|
+
print(line)
|
|
190
|
+
if args.delete_env:
|
|
191
|
+
p = env_file.env_file_path()
|
|
192
|
+
if p.exists():
|
|
193
|
+
p.unlink()
|
|
194
|
+
print(f"removed {p}")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
199
|
+
# env file
|
|
200
|
+
path = env_file.env_file_path()
|
|
201
|
+
if path.exists():
|
|
202
|
+
data = env_file.read_env_file(path)
|
|
203
|
+
keys = sorted(data.keys())
|
|
204
|
+
print(f"env file: {path} ({len(keys)} keys)")
|
|
205
|
+
for k in keys:
|
|
206
|
+
print(f" {k}={_display_value(k, data[k])}")
|
|
207
|
+
else:
|
|
208
|
+
print(f"env file: {path} (not present — run `getbased-stack init`)")
|
|
209
|
+
|
|
210
|
+
# systemd units
|
|
211
|
+
print("\nunits:")
|
|
212
|
+
mgr = units.UnitManager()
|
|
213
|
+
try:
|
|
214
|
+
for svc in mgr.status():
|
|
215
|
+
flags = []
|
|
216
|
+
flags.append("installed" if svc["installed"] else "not installed")
|
|
217
|
+
flags.append("enabled" if svc["enabled"] else "not enabled")
|
|
218
|
+
flags.append("active" if svc["active"] else "inactive")
|
|
219
|
+
print(f" {svc['name']}: {', '.join(flags)}")
|
|
220
|
+
except FileNotFoundError:
|
|
221
|
+
print(" systemctl not found — cannot check unit status.")
|
|
222
|
+
|
|
223
|
+
# linger
|
|
224
|
+
print()
|
|
225
|
+
_print_linger_hint(strict=False)
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_set(args: argparse.Namespace) -> int:
|
|
230
|
+
if "=" not in args.assignment:
|
|
231
|
+
print("usage: getbased-stack set KEY=VALUE", file=sys.stderr)
|
|
232
|
+
return 2
|
|
233
|
+
key, _, value = args.assignment.partition("=")
|
|
234
|
+
key = key.strip()
|
|
235
|
+
value = value.strip()
|
|
236
|
+
path = env_file.set_env_var(key, value)
|
|
237
|
+
print(f"{key} updated in {path}")
|
|
238
|
+
# If rag/dashboard are running, they'll pick up the change on next
|
|
239
|
+
# restart — remind the user.
|
|
240
|
+
print("restart services to apply: systemctl --user restart getbased-rag getbased-dashboard")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_mcp_config(args: argparse.Namespace) -> int:
|
|
245
|
+
try:
|
|
246
|
+
snippet = mcp_configs.emit(args.client)
|
|
247
|
+
except ValueError as e:
|
|
248
|
+
print(str(e), file=sys.stderr)
|
|
249
|
+
return 2
|
|
250
|
+
print(snippet, end="")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_version(args: argparse.Namespace) -> int:
|
|
255
|
+
try:
|
|
256
|
+
import importlib.metadata as md
|
|
257
|
+
|
|
258
|
+
import getbased_agent_stack
|
|
259
|
+
|
|
260
|
+
print(f"getbased-agent-stack {getbased_agent_stack.__version__}")
|
|
261
|
+
for pkg in ("getbased-mcp", "getbased-rag", "getbased-dashboard"):
|
|
48
262
|
try:
|
|
49
|
-
print(f"
|
|
263
|
+
print(f" {pkg} {md.version(pkg)}")
|
|
50
264
|
except md.PackageNotFoundError:
|
|
51
|
-
print("
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
265
|
+
print(f" {pkg} (not installed)")
|
|
266
|
+
return 0
|
|
267
|
+
except ImportError as e:
|
|
268
|
+
print(f"Missing dependency: {e}", file=sys.stderr)
|
|
269
|
+
return 1
|
|
56
270
|
|
|
57
|
-
|
|
271
|
+
|
|
272
|
+
def _delegate_to_lens(argv: "list[str]") -> int:
|
|
273
|
+
"""Historical behavior: unknown subcommands fall through to `lens`
|
|
274
|
+
so the user can do `getbased-stack serve` / `info` / `ingest` without
|
|
275
|
+
needing to remember a separate binary."""
|
|
58
276
|
try:
|
|
59
277
|
from lens.cli import app as lens_app
|
|
60
278
|
|
|
61
279
|
sys.argv = ["lens"] + argv
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
280
|
+
try:
|
|
281
|
+
lens_app()
|
|
282
|
+
return 0
|
|
283
|
+
except SystemExit as e:
|
|
284
|
+
return int(e.code or 0)
|
|
66
285
|
except ImportError:
|
|
67
|
-
print(
|
|
286
|
+
print(
|
|
287
|
+
"getbased-rag not installed — install with "
|
|
288
|
+
"`pipx install getbased-agent-stack[full]`",
|
|
289
|
+
file=sys.stderr,
|
|
290
|
+
)
|
|
68
291
|
return 1
|
|
69
292
|
|
|
70
293
|
|
|
294
|
+
# ── argparse wiring ───────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
298
|
+
p = argparse.ArgumentParser(
|
|
299
|
+
prog="getbased-stack",
|
|
300
|
+
description=(
|
|
301
|
+
"One-command orchestrator for the getbased agent stack. "
|
|
302
|
+
"Use `init` for first-time setup."
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
sub = p.add_subparsers(dest="command")
|
|
306
|
+
|
|
307
|
+
sub.add_parser("init", help="Interactive one-time setup (token, API key, units).")
|
|
308
|
+
|
|
309
|
+
pi = sub.add_parser("install", help="Install + start the systemd user units.")
|
|
310
|
+
pi.add_argument("--no-enable", action="store_true", help="Copy files only; don't enable.")
|
|
311
|
+
pi.add_argument("--no-start", action="store_true", help="Enable but don't start now.")
|
|
312
|
+
|
|
313
|
+
pu = sub.add_parser("uninstall", help="Stop, disable, and remove the systemd units.")
|
|
314
|
+
pu.add_argument(
|
|
315
|
+
"--delete-env",
|
|
316
|
+
action="store_true",
|
|
317
|
+
help="Also delete ~/.config/getbased/env (keeps API key + data).",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
sub.add_parser("status", help="Show env file, unit state, linger.")
|
|
321
|
+
|
|
322
|
+
ps = sub.add_parser("set", help="Upsert a single key in the shared env file.")
|
|
323
|
+
ps.add_argument("assignment", help="KEY=VALUE")
|
|
324
|
+
|
|
325
|
+
pm = sub.add_parser("mcp-config", help="Print an MCP client config snippet.")
|
|
326
|
+
pm.add_argument(
|
|
327
|
+
"client",
|
|
328
|
+
choices=mcp_configs.SUPPORTED_CLIENTS,
|
|
329
|
+
help="Which client to emit for.",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
sub.add_parser("version", help="Print installed package versions.")
|
|
333
|
+
|
|
334
|
+
return p
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
COMMANDS = {
|
|
338
|
+
"init": cmd_init,
|
|
339
|
+
"install": cmd_install,
|
|
340
|
+
"uninstall": cmd_uninstall,
|
|
341
|
+
"status": cmd_status,
|
|
342
|
+
"set": cmd_set,
|
|
343
|
+
"mcp-config": cmd_mcp_config,
|
|
344
|
+
"version": cmd_version,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
349
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
350
|
+
|
|
351
|
+
# Fast path: no args → show our help, not lens's.
|
|
352
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
353
|
+
build_parser().print_help()
|
|
354
|
+
return 0
|
|
355
|
+
|
|
356
|
+
# New commands take priority; everything else falls through to lens
|
|
357
|
+
# (preserves the old thin-wrapper behavior for `serve`, `info`, etc).
|
|
358
|
+
if argv[0] in COMMANDS:
|
|
359
|
+
args = build_parser().parse_args(argv)
|
|
360
|
+
return COMMANDS[args.command](args)
|
|
361
|
+
|
|
362
|
+
return _delegate_to_lens(argv)
|
|
363
|
+
|
|
364
|
+
|
|
71
365
|
if __name__ == "__main__":
|
|
72
366
|
sys.exit(main())
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Shell-style env file I/O for ~/.config/getbased/env.
|
|
2
|
+
|
|
3
|
+
The same file format the opt-in loaders in mcp/rag/dashboard read. Kept
|
|
4
|
+
intentionally tolerant — duplicate keys take last-wins, blanks and `#`
|
|
5
|
+
comments survive round-trips, unknown junk is preserved (we only rewrite
|
|
6
|
+
keys we explicitly care about). This lets a user hand-edit the file
|
|
7
|
+
without `getbased-stack init` clobbering their comments.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ENV_PATH_COMMENT = """\
|
|
17
|
+
# getbased shared env — read by mcp, rag, and dashboard when
|
|
18
|
+
# GETBASED_STACK_MANAGED=1. Written by `getbased-stack init` and
|
|
19
|
+
# `getbased-stack set`. Mode 0600.
|
|
20
|
+
#
|
|
21
|
+
# Lines are KEY=VALUE. Comments start with `#`. Quoted values are unwrapped.
|
|
22
|
+
# Explicit env vars always win — this file only provides defaults.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def env_file_path() -> Path:
|
|
27
|
+
"""Return the XDG-correct shared env file path. Honors $XDG_CONFIG_HOME."""
|
|
28
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
29
|
+
return Path(base) / "getbased" / "env"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_env_text(text: str) -> "dict[str, str]":
|
|
33
|
+
"""Parse shell-style KEY=VALUE text. Last occurrence of a key wins.
|
|
34
|
+
Intentionally permissive — malformed lines are silently skipped."""
|
|
35
|
+
out: dict[str, str] = {}
|
|
36
|
+
for raw in text.splitlines():
|
|
37
|
+
line = raw.strip()
|
|
38
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
39
|
+
continue
|
|
40
|
+
key, _, val = line.partition("=")
|
|
41
|
+
key = key.strip()
|
|
42
|
+
val = val.strip()
|
|
43
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
|
44
|
+
val = val[1:-1]
|
|
45
|
+
if key:
|
|
46
|
+
out[key] = val
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_env_file(path: Path | None = None) -> "dict[str, str]":
|
|
51
|
+
"""Read the shared env file, returning {} on missing file."""
|
|
52
|
+
p = path or env_file_path()
|
|
53
|
+
try:
|
|
54
|
+
return parse_env_text(p.read_text(encoding="utf-8"))
|
|
55
|
+
except OSError:
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_env_file(
|
|
60
|
+
values: "dict[str, str]",
|
|
61
|
+
path: Path | None = None,
|
|
62
|
+
header: str = ENV_PATH_COMMENT,
|
|
63
|
+
) -> Path:
|
|
64
|
+
"""Write values to the shared env file at mode 0600.
|
|
65
|
+
|
|
66
|
+
Creates parent dir if missing. Overwrites — call read_env_file first +
|
|
67
|
+
merge if you want to preserve existing keys. Values are never quoted;
|
|
68
|
+
reader handles quoting-or-not symmetrically.
|
|
69
|
+
|
|
70
|
+
Caveat: systemd's `EnvironmentFile=` parser has stricter quoting rules
|
|
71
|
+
than our Python loader — values containing whitespace or shell
|
|
72
|
+
metacharacters should be avoided. Our default values (tokens,
|
|
73
|
+
URL-safe paths) are already safe; users passing arbitrary values via
|
|
74
|
+
`getbased-stack set FOO="has spaces"` may see systemd-loaded services
|
|
75
|
+
and directly-invoked CLIs parse the value differently.
|
|
76
|
+
"""
|
|
77
|
+
p = path or env_file_path()
|
|
78
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
lines = [header.rstrip() + "\n\n"] if header else []
|
|
80
|
+
for k, v in values.items():
|
|
81
|
+
# Only alphanumeric + underscore are safe as env keys; anything else
|
|
82
|
+
# is a programming bug upstream, fail loud.
|
|
83
|
+
if not k or not all(c.isalnum() or c == "_" for c in k):
|
|
84
|
+
raise ValueError(f"invalid env key: {k!r}")
|
|
85
|
+
lines.append(f"{k}={v}\n")
|
|
86
|
+
p.write_text("".join(lines), encoding="utf-8")
|
|
87
|
+
os.chmod(p, 0o600)
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_env_var(key: str, value: str, path: Path | None = None) -> Path:
|
|
92
|
+
"""Idempotent single-key upsert. Preserves other keys."""
|
|
93
|
+
current = read_env_file(path)
|
|
94
|
+
current[key] = value
|
|
95
|
+
return write_env_file(current, path)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def unset_env_var(key: str, path: Path | None = None) -> Path:
|
|
99
|
+
"""Idempotent single-key remove. No-op if absent."""
|
|
100
|
+
current = read_env_file(path)
|
|
101
|
+
current.pop(key, None)
|
|
102
|
+
return write_env_file(current, path)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""MCP client config snippets.
|
|
2
|
+
|
|
3
|
+
Each client has its own config shape (JSON vs YAML, where to place the
|
|
4
|
+
mcpServers key). All generated snippets point at the absolute path to
|
|
5
|
+
`getbased-mcp` resolved via shutil.which, and carry only one env var:
|
|
6
|
+
GETBASED_STACK_MANAGED=1. Everything else lives in the shared env file,
|
|
7
|
+
read by the MCP process at startup.
|
|
8
|
+
|
|
9
|
+
The stock Hermes snippet is the one exception — it supports richer
|
|
10
|
+
configuration natively (enabled_tools, tool aliases), so we emit those.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SUPPORTED_CLIENTS = ("claude-desktop", "claude-code", "cursor", "cline", "hermes")
|
|
20
|
+
|
|
21
|
+
# Single-token list for the Hermes snippet. Matches the tool set the MCP
|
|
22
|
+
# server exposes as of getbased-mcp 0.2.2. Keep alphabetical for diff-friendliness.
|
|
23
|
+
HERMES_ENABLED_TOOLS = [
|
|
24
|
+
"getbased_lens_config",
|
|
25
|
+
"getbased_list_profiles",
|
|
26
|
+
"getbased_read_profile",
|
|
27
|
+
"knowledge_activate_library",
|
|
28
|
+
"knowledge_list_libraries",
|
|
29
|
+
"knowledge_search",
|
|
30
|
+
"knowledge_stats",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_mcp_binary(resolver: Callable[[str], "str | None"] = shutil.which) -> str:
|
|
35
|
+
"""Absolute path to the `getbased-mcp` binary, or a bare-name fallback.
|
|
36
|
+
GUI-launched MCP clients (Claude Desktop, Cursor) don't inherit the
|
|
37
|
+
shell PATH, so a bare binary name often fails to resolve. We emit the
|
|
38
|
+
absolute path when we can; otherwise bare name — the caller tells the
|
|
39
|
+
user which case they got via `_resolver_warning`.
|
|
40
|
+
"""
|
|
41
|
+
resolved = resolver("getbased-mcp")
|
|
42
|
+
return resolved or "getbased-mcp"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolver_warning(command: str) -> "str | None":
|
|
46
|
+
"""Return a warning string if the command isn't an absolute path,
|
|
47
|
+
else None. Used by emitters to tell the user when the snippet needs
|
|
48
|
+
hand-editing to work from a GUI-launched client."""
|
|
49
|
+
if command.startswith("/") or (len(command) > 1 and command[1] == ":"):
|
|
50
|
+
return None
|
|
51
|
+
return (
|
|
52
|
+
"WARNING: `getbased-mcp` wasn't found on PATH when this snippet was\n"
|
|
53
|
+
"generated. GUI-launched MCP clients don't inherit your shell PATH;\n"
|
|
54
|
+
"replace the `command` below with an absolute path (run\n"
|
|
55
|
+
"`which getbased-mcp` in a shell where it works, paste that result)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _json_block(command: str) -> "dict":
|
|
60
|
+
return {
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"getbased": {
|
|
63
|
+
"command": command,
|
|
64
|
+
"env": {"GETBASED_STACK_MANAGED": "1"},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _prepend_warning(banner: str, warning: "str | None") -> str:
|
|
71
|
+
if not warning:
|
|
72
|
+
return banner
|
|
73
|
+
return "// " + warning.replace("\n", "\n// ") + "\n" + banner
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def emit_claude_desktop(resolver: Callable[[str], "str | None"] = shutil.which) -> str:
|
|
77
|
+
cmd = _resolve_mcp_binary(resolver)
|
|
78
|
+
payload = _json_block(cmd)
|
|
79
|
+
banner = (
|
|
80
|
+
"// Paste into ~/Library/Application Support/Claude/claude_desktop_config.json\n"
|
|
81
|
+
"// (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows).\n"
|
|
82
|
+
"// Merge with existing mcpServers if present.\n"
|
|
83
|
+
)
|
|
84
|
+
banner = _prepend_warning(banner, _resolver_warning(cmd))
|
|
85
|
+
return banner + json.dumps(payload, indent=2) + "\n"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def emit_claude_code(resolver: Callable[[str], "str | None"] = shutil.which) -> str:
|
|
89
|
+
cmd = _resolve_mcp_binary(resolver)
|
|
90
|
+
payload = _json_block(cmd)
|
|
91
|
+
banner = (
|
|
92
|
+
"// Paste into ~/.claude/settings.json (user scope) or <project>/.mcp.json\n"
|
|
93
|
+
"// (project scope). Merge with existing mcpServers if present.\n"
|
|
94
|
+
)
|
|
95
|
+
banner = _prepend_warning(banner, _resolver_warning(cmd))
|
|
96
|
+
return banner + json.dumps(payload, indent=2) + "\n"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def emit_cursor(resolver: Callable[[str], "str | None"] = shutil.which) -> str:
|
|
100
|
+
cmd = _resolve_mcp_binary(resolver)
|
|
101
|
+
payload = _json_block(cmd)
|
|
102
|
+
banner = "// Paste into ~/.cursor/mcp.json (merge with existing mcpServers).\n"
|
|
103
|
+
banner = _prepend_warning(banner, _resolver_warning(cmd))
|
|
104
|
+
return banner + json.dumps(payload, indent=2) + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def emit_cline(resolver: Callable[[str], "str | None"] = shutil.which) -> str:
|
|
108
|
+
cmd = _resolve_mcp_binary(resolver)
|
|
109
|
+
payload = _json_block(cmd)
|
|
110
|
+
banner = (
|
|
111
|
+
"// Cline MCP settings. Paste into the Cline extension settings panel\n"
|
|
112
|
+
"// under 'MCP Servers' (Cursor/VSCode).\n"
|
|
113
|
+
)
|
|
114
|
+
banner = _prepend_warning(banner, _resolver_warning(cmd))
|
|
115
|
+
return banner + json.dumps(payload, indent=2) + "\n"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def emit_hermes(resolver: Callable[[str], "str | None"] = shutil.which) -> str:
|
|
119
|
+
"""Hermes uses YAML — we emit by hand (no yaml dep) since the shape
|
|
120
|
+
is trivial. Includes enabled_tools for parity with the existing
|
|
121
|
+
examples/hermes-mcp.yaml snippet.
|
|
122
|
+
|
|
123
|
+
The emitted snippet carries GETBASED_STACK_MANAGED=1 so MCP reads the
|
|
124
|
+
stack's shared env file. If you'd rather keep Hermes's config.yaml as
|
|
125
|
+
the single source of env (e.g. an existing Hermes VM that already has
|
|
126
|
+
GETBASED_TOKEN + LENS_API_KEY_FILE set explicitly), drop the env block
|
|
127
|
+
entirely. Both modes work — setdefault semantics mean explicit env
|
|
128
|
+
always wins over the shared file — but committing to one keeps
|
|
129
|
+
future debugging simpler.
|
|
130
|
+
"""
|
|
131
|
+
cmd = _resolve_mcp_binary(resolver)
|
|
132
|
+
warning = _resolver_warning(cmd)
|
|
133
|
+
lines = [
|
|
134
|
+
"# Hermes Agent MCP configuration snippet for ~/.hermes/config.yaml",
|
|
135
|
+
"# See https://github.com/hermes-agent/hermes-agent for the full config schema.",
|
|
136
|
+
"# The getbased stack's shared env file carries GETBASED_TOKEN + rag URL +",
|
|
137
|
+
"# api key path; only the opt-in flag belongs in Hermes's config.",
|
|
138
|
+
"# (If your Hermes config already sets GETBASED_TOKEN / LENS_* explicitly,",
|
|
139
|
+
"# drop the env block entirely — the Python loader honors existing env.)",
|
|
140
|
+
]
|
|
141
|
+
if warning:
|
|
142
|
+
lines.append("#")
|
|
143
|
+
for wline in warning.splitlines():
|
|
144
|
+
lines.append(f"# {wline}")
|
|
145
|
+
lines.extend(
|
|
146
|
+
[
|
|
147
|
+
"",
|
|
148
|
+
"mcp_servers:",
|
|
149
|
+
" getbased:",
|
|
150
|
+
f" command: {cmd}",
|
|
151
|
+
" env:",
|
|
152
|
+
' GETBASED_STACK_MANAGED: "1"',
|
|
153
|
+
" enabled_tools:",
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
for tool in HERMES_ENABLED_TOOLS:
|
|
157
|
+
lines.append(f" - {tool}")
|
|
158
|
+
return "\n".join(lines) + "\n"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def emit(client: str, resolver: Callable[[str], "str | None"] = shutil.which) -> str:
|
|
162
|
+
"""Dispatch to the right emitter. Raises ValueError on unknown client."""
|
|
163
|
+
match client:
|
|
164
|
+
case "claude-desktop":
|
|
165
|
+
return emit_claude_desktop(resolver)
|
|
166
|
+
case "claude-code":
|
|
167
|
+
return emit_claude_code(resolver)
|
|
168
|
+
case "cursor":
|
|
169
|
+
return emit_cursor(resolver)
|
|
170
|
+
case "cline":
|
|
171
|
+
return emit_cline(resolver)
|
|
172
|
+
case "hermes":
|
|
173
|
+
return emit_hermes(resolver)
|
|
174
|
+
case _:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"unknown client {client!r}. Supported: {', '.join(SUPPORTED_CLIENTS)}"
|
|
177
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=getbased-dashboard (web UI for rag + mcp management)
|
|
3
|
+
Documentation=https://github.com/elkimek/getbased-agents/tree/main/packages/dashboard
|
|
4
|
+
After=network.target getbased-rag.service
|
|
5
|
+
Wants=getbased-rag.service
|
|
6
|
+
|
|
7
|
+
[Service]
|
|
8
|
+
Type=exec
|
|
9
|
+
# Same shared env file as rag + mcp — one token, one key path, one flag.
|
|
10
|
+
EnvironmentFile=-%h/.config/getbased/env
|
|
11
|
+
EnvironmentFile=-/etc/default/getbased-dashboard
|
|
12
|
+
Environment=GETBASED_STACK_MANAGED=1
|
|
13
|
+
Environment=DASHBOARD_HOST=127.0.0.1
|
|
14
|
+
Environment=DASHBOARD_PORT=8323
|
|
15
|
+
ExecStart=%h/.local/bin/getbased-dashboard serve
|
|
16
|
+
Restart=always
|
|
17
|
+
RestartSec=5s
|
|
18
|
+
|
|
19
|
+
# Hardening — the dashboard is a localhost-only HTTP server.
|
|
20
|
+
NoNewPrivileges=true
|
|
21
|
+
ProtectSystem=strict
|
|
22
|
+
ProtectHome=read-only
|
|
23
|
+
# Needs write access for session state, activity log tailing, and writing
|
|
24
|
+
# the shared env file when the user edits the token in the MCP tab.
|
|
25
|
+
ReadWritePaths=%h/.local/state/getbased %h/.config/getbased
|
|
26
|
+
PrivateTmp=true
|
|
27
|
+
ProtectKernelTunables=true
|
|
28
|
+
ProtectKernelModules=true
|
|
29
|
+
ProtectControlGroups=true
|
|
30
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
31
|
+
RestrictNamespaces=true
|
|
32
|
+
LockPersonality=true
|
|
33
|
+
RestrictRealtime=true
|
|
34
|
+
|
|
35
|
+
[Install]
|
|
36
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=getbased-rag (local RAG knowledge server)
|
|
3
|
+
Documentation=https://github.com/elkimek/getbased-agents/tree/main/packages/rag
|
|
4
|
+
After=network.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=exec
|
|
8
|
+
# Shared env file written by `getbased-stack init` — carries GETBASED_TOKEN,
|
|
9
|
+
# LENS_API_KEY_FILE, LENS_URL (and the GETBASED_STACK_MANAGED=1 opt-in
|
|
10
|
+
# flag, which must be set for the Python env loader to pick up this file).
|
|
11
|
+
# Optional: /etc/default/getbased-rag for system-wide overrides.
|
|
12
|
+
EnvironmentFile=-%h/.config/getbased/env
|
|
13
|
+
EnvironmentFile=-/etc/default/getbased-rag
|
|
14
|
+
Environment=GETBASED_STACK_MANAGED=1
|
|
15
|
+
Environment=LENS_HOST=127.0.0.1
|
|
16
|
+
Environment=LENS_PORT=8322
|
|
17
|
+
ExecStart=%h/.local/bin/lens serve
|
|
18
|
+
# Restart=always (not on-failure) so a clean SIGTERM — e.g. operator stop
|
|
19
|
+
# during troubleshooting — still brings the service back at the next boot.
|
|
20
|
+
# Matches the failure mode that kept lens-rag.service dead for hours on
|
|
21
|
+
# the Hermes VM until someone noticed.
|
|
22
|
+
Restart=always
|
|
23
|
+
RestartSec=5s
|
|
24
|
+
|
|
25
|
+
# Hardening — the server only needs its data dir + a network socket.
|
|
26
|
+
NoNewPrivileges=true
|
|
27
|
+
ProtectSystem=strict
|
|
28
|
+
ProtectHome=read-only
|
|
29
|
+
ReadWritePaths=%h/.local/share/getbased
|
|
30
|
+
PrivateTmp=true
|
|
31
|
+
ProtectKernelTunables=true
|
|
32
|
+
ProtectKernelModules=true
|
|
33
|
+
ProtectControlGroups=true
|
|
34
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
35
|
+
RestrictNamespaces=true
|
|
36
|
+
LockPersonality=true
|
|
37
|
+
MemoryDenyWriteExecute=false # ONNX Runtime needs W^X exception
|
|
38
|
+
RestrictRealtime=true
|
|
39
|
+
|
|
40
|
+
[Install]
|
|
41
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Systemd user-unit install/uninstall/status helpers.
|
|
2
|
+
|
|
3
|
+
Tests drive this through a `UnitManager` class whose shell-out boundary
|
|
4
|
+
(`run_systemctl`, `run_loginctl`) can be monkeypatched. The default
|
|
5
|
+
`shell` implementation uses subprocess; tests swap it for a fake that
|
|
6
|
+
records calls without touching the real systemd.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Iterable
|
|
17
|
+
|
|
18
|
+
# Units this package ships. Order matters for start: rag first (dashboard
|
|
19
|
+
# Wants=+After= rag), so systemctl picks the right order naturally anyway.
|
|
20
|
+
SERVICE_NAMES = ("getbased-rag.service", "getbased-dashboard.service")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_unit_dir() -> Path:
|
|
24
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
25
|
+
return Path(base) / "systemd" / "user"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bundled_units() -> "list[tuple[str, str]]":
|
|
29
|
+
"""Return the (name, text) pairs for every service this package ships.
|
|
30
|
+
Read via importlib.resources — unit files live inside the package tree
|
|
31
|
+
so it works the same from wheel, sdist, and editable installs."""
|
|
32
|
+
results = []
|
|
33
|
+
for name in SERVICE_NAMES:
|
|
34
|
+
ref = resources.files("getbased_agent_stack") / "systemd" / name
|
|
35
|
+
results.append((name, ref.read_text(encoding="utf-8")))
|
|
36
|
+
return results
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CommandResult:
|
|
41
|
+
returncode: int
|
|
42
|
+
stdout: str
|
|
43
|
+
stderr: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _real_shell(cmd: "list[str]") -> CommandResult:
|
|
47
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
48
|
+
return CommandResult(proc.returncode, proc.stdout, proc.stderr)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UnitManager:
|
|
52
|
+
"""Orchestrates install/uninstall/status of the bundled service units.
|
|
53
|
+
|
|
54
|
+
The shell boundary is injected so tests can assert on calls without
|
|
55
|
+
touching real systemctl. Production code constructs with defaults."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
unit_dir: Path | None = None,
|
|
60
|
+
shell: Callable[["list[str]"], CommandResult] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.unit_dir = unit_dir or _default_unit_dir()
|
|
63
|
+
# Resolve at call time (not class definition) so tests can
|
|
64
|
+
# monkeypatch `units._real_shell` and have new instances pick
|
|
65
|
+
# it up without needing to wire the shell through every call site.
|
|
66
|
+
self._shell = shell if shell is not None else _real_shell
|
|
67
|
+
|
|
68
|
+
# ── file operations ────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def install_files(self) -> "list[Path]":
|
|
71
|
+
"""Copy bundled unit texts into the user unit dir. Overwrites to
|
|
72
|
+
keep install idempotent across upgrades."""
|
|
73
|
+
self.unit_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
written: "list[Path]" = []
|
|
75
|
+
for name, text in bundled_units():
|
|
76
|
+
dest = self.unit_dir / name
|
|
77
|
+
dest.write_text(text, encoding="utf-8")
|
|
78
|
+
written.append(dest)
|
|
79
|
+
return written
|
|
80
|
+
|
|
81
|
+
def remove_files(self) -> "list[Path]":
|
|
82
|
+
"""Delete unit files from the user unit dir. No-op per file if
|
|
83
|
+
absent — idempotent."""
|
|
84
|
+
removed: "list[Path]" = []
|
|
85
|
+
for name in SERVICE_NAMES:
|
|
86
|
+
p = self.unit_dir / name
|
|
87
|
+
if p.exists():
|
|
88
|
+
p.unlink()
|
|
89
|
+
removed.append(p)
|
|
90
|
+
return removed
|
|
91
|
+
|
|
92
|
+
# ── systemctl orchestration ────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def daemon_reload(self) -> CommandResult:
|
|
95
|
+
return self._shell(["systemctl", "--user", "daemon-reload"])
|
|
96
|
+
|
|
97
|
+
def enable(self, now: bool = True) -> CommandResult:
|
|
98
|
+
args = ["systemctl", "--user", "enable"]
|
|
99
|
+
if now:
|
|
100
|
+
args.append("--now")
|
|
101
|
+
args.extend(SERVICE_NAMES)
|
|
102
|
+
return self._shell(args)
|
|
103
|
+
|
|
104
|
+
def disable(self, now: bool = True) -> CommandResult:
|
|
105
|
+
args = ["systemctl", "--user", "disable"]
|
|
106
|
+
if now:
|
|
107
|
+
args.append("--now")
|
|
108
|
+
args.extend(SERVICE_NAMES)
|
|
109
|
+
return self._shell(args)
|
|
110
|
+
|
|
111
|
+
def is_active(self, service: str) -> bool:
|
|
112
|
+
r = self._shell(["systemctl", "--user", "is-active", service])
|
|
113
|
+
return r.returncode == 0 and r.stdout.strip() == "active"
|
|
114
|
+
|
|
115
|
+
def is_enabled(self, service: str) -> bool:
|
|
116
|
+
r = self._shell(["systemctl", "--user", "is-enabled", service])
|
|
117
|
+
return r.returncode == 0 and r.stdout.strip() in ("enabled", "alias", "static")
|
|
118
|
+
|
|
119
|
+
# ── high-level ops ─────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def install(self, enable: bool = True, start: bool = True) -> "list[str]":
|
|
122
|
+
"""Run the full install sequence. Returns a log of human-readable
|
|
123
|
+
steps for the caller to print."""
|
|
124
|
+
log: "list[str]" = []
|
|
125
|
+
written = self.install_files()
|
|
126
|
+
for p in written:
|
|
127
|
+
log.append(f"wrote {p}")
|
|
128
|
+
r = self.daemon_reload()
|
|
129
|
+
if r.returncode != 0:
|
|
130
|
+
log.append(f"daemon-reload FAILED: {r.stderr.strip()}")
|
|
131
|
+
return log
|
|
132
|
+
if enable:
|
|
133
|
+
r = self.enable(now=start)
|
|
134
|
+
if r.returncode != 0:
|
|
135
|
+
log.append(f"enable FAILED: {r.stderr.strip()}")
|
|
136
|
+
else:
|
|
137
|
+
log.append("enabled " + ", ".join(SERVICE_NAMES))
|
|
138
|
+
if start:
|
|
139
|
+
log.append("started " + ", ".join(SERVICE_NAMES))
|
|
140
|
+
return log
|
|
141
|
+
|
|
142
|
+
def uninstall(self) -> "list[str]":
|
|
143
|
+
log: "list[str]" = []
|
|
144
|
+
r = self.disable(now=True)
|
|
145
|
+
if r.returncode != 0:
|
|
146
|
+
# Already-disabled units return non-zero on `disable --now`.
|
|
147
|
+
# Treat as advisory, keep going.
|
|
148
|
+
log.append(f"disable: {r.stderr.strip() or r.stdout.strip()}")
|
|
149
|
+
removed = self.remove_files()
|
|
150
|
+
for p in removed:
|
|
151
|
+
log.append(f"removed {p}")
|
|
152
|
+
if removed:
|
|
153
|
+
r = self.daemon_reload()
|
|
154
|
+
if r.returncode != 0:
|
|
155
|
+
log.append(f"daemon-reload FAILED: {r.stderr.strip()}")
|
|
156
|
+
return log
|
|
157
|
+
|
|
158
|
+
def status(self) -> "list[dict]":
|
|
159
|
+
"""Return one dict per service with {name, installed, enabled, active}.
|
|
160
|
+
Cheap: 2 systemctl calls per service."""
|
|
161
|
+
out: "list[dict]" = []
|
|
162
|
+
for name in SERVICE_NAMES:
|
|
163
|
+
out.append(
|
|
164
|
+
{
|
|
165
|
+
"name": name,
|
|
166
|
+
"installed": (self.unit_dir / name).exists(),
|
|
167
|
+
"enabled": self.is_enabled(name),
|
|
168
|
+
"active": self.is_active(name),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Linger detection (separate from UnitManager — independent concern) ──
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def has_linger(user: str | None = None, shell: Callable = _real_shell) -> bool:
|
|
178
|
+
"""True when `loginctl show-user --property=Linger` reports `yes`.
|
|
179
|
+
|
|
180
|
+
Linger keeps user services running when the user isn't logged in —
|
|
181
|
+
required for rag/dashboard to come back after a headless reboot.
|
|
182
|
+
"""
|
|
183
|
+
user = user or os.environ.get("USER", "")
|
|
184
|
+
if not user:
|
|
185
|
+
return False
|
|
186
|
+
r = shell(["loginctl", "show-user", user, "--property=Linger"])
|
|
187
|
+
return r.returncode == 0 and r.stdout.strip().lower() == "linger=yes"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_gui_session() -> bool:
|
|
191
|
+
"""Heuristic: does the current session have a display?
|
|
192
|
+
If not, we treat this as a headless host and require linger.
|
|
193
|
+
"""
|
|
194
|
+
return bool(
|
|
195
|
+
os.environ.get("DISPLAY")
|
|
196
|
+
or os.environ.get("WAYLAND_DISPLAY")
|
|
197
|
+
or os.environ.get("XDG_SESSION_TYPE") in ("x11", "wayland")
|
|
198
|
+
)
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: getbased-agent-stack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: One-command install of the full getbased agent stack — getbased-mcp + getbased-rag + getbased-dashboard
|
|
5
5
|
License-Expression: GPL-3.0-only
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Requires-Dist: getbased-mcp>=0.2.
|
|
10
|
-
Requires-Dist: getbased-rag>=0.
|
|
11
|
-
Requires-Dist: getbased-dashboard>=0.
|
|
9
|
+
Requires-Dist: getbased-mcp>=0.2.3
|
|
10
|
+
Requires-Dist: getbased-rag>=0.7.1
|
|
11
|
+
Requires-Dist: getbased-dashboard>=0.6.1
|
|
12
12
|
Provides-Extra: full
|
|
13
|
-
Requires-Dist: getbased-rag[full]>=0.
|
|
13
|
+
Requires-Dist: getbased-rag[full]>=0.7.1; extra == "full"
|
|
14
14
|
Provides-Extra: test
|
|
15
15
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
16
16
|
Requires-Dist: httpx>=0.27; extra == "test"
|
|
@@ -19,7 +19,7 @@ Dynamic: license-file
|
|
|
19
19
|
|
|
20
20
|
# getbased-agent-stack
|
|
21
21
|
|
|
22
|
-
Meta-package bundling the full [getbased](https://getbased.health) agent stack into one install: the MCP adapter, the RAG engine, the browser dashboard,
|
|
22
|
+
Meta-package bundling the full [getbased](https://getbased.health) agent stack into one install: the MCP adapter, the RAG engine, the browser dashboard, an orchestration CLI (`init` / `install` / `mcp-config`), hardened systemd units for rag + dashboard, and paste-ready configs for Claude Desktop/Code, Cursor, Cline, and Hermes.
|
|
23
23
|
|
|
24
24
|
Part of the [getbased-agents monorepo](https://github.com/elkimek/getbased-agents).
|
|
25
25
|
|
|
@@ -39,36 +39,65 @@ Pulls:
|
|
|
39
39
|
|
|
40
40
|
Total install: ~500 MB (the ML deps dominate). Smaller installs available — `pipx install getbased-mcp` (10 MB, agent only), `pipx install "getbased-rag[full]"` (RAG only), `pipx install getbased-dashboard` (UI + MCP; pulls rag if you want the Knowledge tab working).
|
|
41
41
|
|
|
42
|
-
## Quickstart
|
|
42
|
+
## Quickstart — one command
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
getbased-stack init
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The wizard (~30 seconds):
|
|
49
|
+
|
|
50
|
+
1. Prompts for your `GETBASED_TOKEN` (skip if you don't use sync)
|
|
51
|
+
2. Generates a rag API key if one doesn't exist
|
|
52
|
+
3. Writes `~/.config/getbased/env` (mode 0600) — the shared config file
|
|
53
|
+
4. Installs systemd user units for rag + dashboard, enables them, starts them
|
|
54
|
+
|
|
55
|
+
Then paste one line into your MCP client:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
getbased-stack mcp-config claude-desktop # or: claude-code, cursor, cline, hermes
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The snippet carries only `GETBASED_STACK_MANAGED=1` in its env block. No secrets in client configs — every MCP spawn reads the shared env file and loads the token + rag URL + API key path from there.
|
|
62
|
+
|
|
63
|
+
Open the dashboard:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
http://127.0.0.1:8323
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Login URL with bearer key:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
getbased-dashboard login-url # prints http://127.0.0.1:8323/?key=...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Upload docs, create libraries, manage sources, and test the MCP probe from the web UI. Rotate the sync token from the CLI (see below) or by editing `~/.config/getbased/env` directly.
|
|
48
76
|
|
|
49
|
-
|
|
50
|
-
getbased-dashboard serve # serves on 127.0.0.1:8323
|
|
77
|
+
### Surviving reboot on headless hosts
|
|
51
78
|
|
|
52
|
-
|
|
53
|
-
# Create libraries, drag-drop files to ingest (live chunks/sec pill),
|
|
54
|
-
# run the MCP Test button to verify your agent path
|
|
79
|
+
User systemd services stop on logout. On a headless server (no GUI session), they won't come back at boot unless you enable linger once:
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# Claude Desktop, Claude Code, Cursor, Cline, and Hermes.
|
|
81
|
+
```bash
|
|
82
|
+
sudo loginctl enable-linger $USER
|
|
59
83
|
```
|
|
60
84
|
|
|
61
|
-
|
|
85
|
+
`getbased-stack init` prints this reminder when it detects a headless environment. On a laptop with a GUI login, linger is nice-to-have — services start when you log in.
|
|
62
86
|
|
|
63
|
-
|
|
87
|
+
### Other commands
|
|
64
88
|
|
|
65
89
|
```bash
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
90
|
+
getbased-stack status # env file, unit state, linger
|
|
91
|
+
getbased-stack set GETBASED_TOKEN=new # rotate the token
|
|
92
|
+
getbased-stack install # re-apply unit files after package upgrade
|
|
93
|
+
getbased-stack uninstall # stop + disable + remove units
|
|
69
94
|
```
|
|
70
95
|
|
|
71
|
-
|
|
96
|
+
### Migrating from an older install
|
|
97
|
+
|
|
98
|
+
If you have a hand-rolled setup (standalone `lens-rag.service`, hermes-style `~/.hermes/config.yaml` with MCP env), **leave it alone** — `getbased-stack init` only writes new paths and installs new unit names (`getbased-rag.service`, `getbased-dashboard.service`), so it can coexist. The opt-in loader in every binary is gated on `GETBASED_STACK_MANAGED=1`; without that flag set, every binary behaves exactly as before.
|
|
99
|
+
|
|
100
|
+
If you're running the existing Hermes VM deployment on this host, don't run `init` there. Your `~/.hermes/config.yaml` continues to supply env explicitly; nothing from this package touches it.
|
|
72
101
|
|
|
73
102
|
## Architecture
|
|
74
103
|
|
|
@@ -97,6 +126,7 @@ The dashboard is likewise stateless — it proxies rag for Knowledge operations,
|
|
|
97
126
|
|---|---|---|---|---|
|
|
98
127
|
| 0.1.x | ≥0.2.0 | ≥0.1.0 | — | v1 (multi-library) |
|
|
99
128
|
| 0.2.x | ≥0.2.2 | ≥0.6.0 | ≥0.5.0 | v1 (+ streaming ingest, per-library models) |
|
|
129
|
+
| 0.4.x | ≥0.2.3 | ≥0.7.1 | ≥0.6.1 | v1 (+ shared env file, `getbased-stack init`, systemd units) |
|
|
100
130
|
|
|
101
131
|
Bump the meta's major when sibling protocols break; bump siblings freely for normal features.
|
|
102
132
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
getbased_agent_stack/__init__.py,sha256=MgsNcLFuwotjltI3UtO_1ltIdoPW345EA-AyfV4S_-Y,281
|
|
2
|
+
getbased_agent_stack/cli.py,sha256=jkk1o8DaXLooeU12vb9DZRRaeUDHeirOYxQEt8exJOc,12610
|
|
3
|
+
getbased_agent_stack/env_file.py,sha256=lg_LmTspdIbmVTpsxEO4Szdv7wXtvrlCJp5lFN9xCTg,3792
|
|
4
|
+
getbased_agent_stack/mcp_configs.py,sha256=1du4ChGuFv1sTR5RQ9wJgloKC6vnu8iZ0KNl8H-2MKE,6937
|
|
5
|
+
getbased_agent_stack/units.py,sha256=40yvr1DbHiYkpvwKc3RCArhTD4cwwbchKZHbIrDAUNs,7577
|
|
6
|
+
getbased_agent_stack/systemd/getbased-dashboard.service,sha256=ndnJlLD3eu-kWrhi9wfxHksunWNI0-BYAhzcuocPvJg,1173
|
|
7
|
+
getbased_agent_stack/systemd/getbased-rag.service,sha256=wAkL2cb3kB-AzsqMsN7rO3CwzpgNJDpzH08I3pgSU-o,1468
|
|
8
|
+
getbased_agent_stack-0.4.0.dist-info/licenses/LICENSE,sha256=K-IjLWkez1gJQMrlqA5zgyw8vh19mDzk4hKM9Dslmts,1024
|
|
9
|
+
getbased_agent_stack-0.4.0.dist-info/METADATA,sha256=QS4voLGPSuUtMXoxKzVFXQewJAQ_cw8AR1trCyZ4UTE,6831
|
|
10
|
+
getbased_agent_stack-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
getbased_agent_stack-0.4.0.dist-info/entry_points.txt,sha256=oIyVEEt9t6HuGncV_3y_6d0fjkY2PDOVQ2OEpyq2Q04,65
|
|
12
|
+
getbased_agent_stack-0.4.0.dist-info/top_level.txt,sha256=NH7AGQBXKHxl7hzWSYDXOaIXCg_9GV3hIQbj8YR8XZQ,21
|
|
13
|
+
getbased_agent_stack-0.4.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
getbased_agent_stack/__init__.py,sha256=LSo9NAptb9YiMiamzJLzIZTali0QS0bT8JU_975ddv4,281
|
|
2
|
-
getbased_agent_stack/cli.py,sha256=IASj51V3gl0v9eebjuDwXfjZplqCBYlMWGCQwdGlNn4,2445
|
|
3
|
-
getbased_agent_stack-0.2.0.dist-info/licenses/LICENSE,sha256=K-IjLWkez1gJQMrlqA5zgyw8vh19mDzk4hKM9Dslmts,1024
|
|
4
|
-
getbased_agent_stack-0.2.0.dist-info/METADATA,sha256=4JFkTXngtk0Yhymmb6ZlUkzhQYzUzTFfVBxq9zhHBbo,5572
|
|
5
|
-
getbased_agent_stack-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
-
getbased_agent_stack-0.2.0.dist-info/entry_points.txt,sha256=oIyVEEt9t6HuGncV_3y_6d0fjkY2PDOVQ2OEpyq2Q04,65
|
|
7
|
-
getbased_agent_stack-0.2.0.dist-info/top_level.txt,sha256=NH7AGQBXKHxl7hzWSYDXOaIXCg_9GV3hIQbj8YR8XZQ,21
|
|
8
|
-
getbased_agent_stack-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
{getbased_agent_stack-0.2.0.dist-info → getbased_agent_stack-0.4.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{getbased_agent_stack-0.2.0.dist-info → getbased_agent_stack-0.4.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|