getbased-agent-stack 0.3.1__tar.gz → 0.4.1__tar.gz

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.
Files changed (26) hide show
  1. {getbased_agent_stack-0.3.1/src/getbased_agent_stack.egg-info → getbased_agent_stack-0.4.1}/PKG-INFO +54 -24
  2. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/README.md +49 -19
  3. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/pyproject.toml +10 -5
  4. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/src/getbased_agent_stack/__init__.py +1 -1
  5. getbased_agent_stack-0.4.1/src/getbased_agent_stack/cli.py +366 -0
  6. getbased_agent_stack-0.4.1/src/getbased_agent_stack/env_file.py +102 -0
  7. getbased_agent_stack-0.4.1/src/getbased_agent_stack/mcp_configs.py +177 -0
  8. getbased_agent_stack-0.4.1/src/getbased_agent_stack/systemd/getbased-dashboard.service +32 -0
  9. getbased_agent_stack-0.4.1/src/getbased_agent_stack/systemd/getbased-rag.service +40 -0
  10. getbased_agent_stack-0.4.1/src/getbased_agent_stack/units.py +198 -0
  11. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1/src/getbased_agent_stack.egg-info}/PKG-INFO +54 -24
  12. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/src/getbased_agent_stack.egg-info/SOURCES.txt +11 -1
  13. getbased_agent_stack-0.4.1/src/getbased_agent_stack.egg-info/requires.txt +11 -0
  14. getbased_agent_stack-0.4.1/tests/test_cli.py +310 -0
  15. getbased_agent_stack-0.4.1/tests/test_env_file.py +106 -0
  16. getbased_agent_stack-0.4.1/tests/test_mcp_configs.py +125 -0
  17. getbased_agent_stack-0.4.1/tests/test_systemd_units.py +109 -0
  18. getbased_agent_stack-0.4.1/tests/test_units.py +296 -0
  19. getbased_agent_stack-0.3.1/src/getbased_agent_stack/cli.py +0 -72
  20. getbased_agent_stack-0.3.1/src/getbased_agent_stack.egg-info/requires.txt +0 -11
  21. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/LICENSE +0 -0
  22. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/setup.cfg +0 -0
  23. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/src/getbased_agent_stack.egg-info/dependency_links.txt +0 -0
  24. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/src/getbased_agent_stack.egg-info/entry_points.txt +0 -0
  25. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/src/getbased_agent_stack.egg-info/top_level.txt +0 -0
  26. {getbased_agent_stack-0.3.1 → getbased_agent_stack-0.4.1}/tests/test_integration.py +0 -0
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getbased-agent-stack
3
- Version: 0.3.1
3
+ Version: 0.4.1
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.2
10
- Requires-Dist: getbased-rag>=0.7.0
11
- Requires-Dist: getbased-dashboard>=0.6.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.7.0; extra == "full"
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, a thin discovery CLI, a hardened systemd unit, and example configs for Claude Code + Hermes.
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
- # 1. Start the RAG server — local Qdrant DB + MiniLM embedder
46
- lens serve # blocks; serves on 127.0.0.1:8322
47
- lens key # prints the bearer token
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
- # 2. Start the dashboard in another terminal
50
- getbased-dashboard serve # serves on 127.0.0.1:8323
77
+ ### Surviving reboot on headless hosts
51
78
 
52
- # 3. Open http://127.0.0.1:8323 in your browser, paste the lens key
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
- # 4. Wire the MCP into your AI client
57
- # The dashboard's MCP tab generates paste-ready config blocks for
58
- # Claude Desktop, Claude Code, Cursor, Cline, and Hermes.
81
+ ```bash
82
+ sudo loginctl enable-linger $USER
59
83
  ```
60
84
 
61
- Both the RAG server and the getbased PWA talk to the same `lens` instance point the PWA at `http://127.0.0.1:8322` under **Settings AI Knowledge Base External server** and the same corpus feeds the browser chat, the dashboard, and any MCP-connected agent.
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
- ## Running as a systemd service
87
+ ### Other commands
64
88
 
65
89
  ```bash
66
- cp systemd/getbased-rag.service ~/.config/systemd/user/
67
- systemctl --user daemon-reload
68
- systemctl --user enable --now getbased-rag
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
- The unit is hardened (`ProtectSystem=strict`, `NoNewPrivileges`, `RestrictAddressFamilies`, etc.); run `systemd-analyze security getbased-rag` to see the score.
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
 
@@ -1,6 +1,6 @@
1
1
  # getbased-agent-stack
2
2
 
3
- Meta-package bundling the full [getbased](https://getbased.health) agent stack into one install: the MCP adapter, the RAG engine, the browser dashboard, a thin discovery CLI, a hardened systemd unit, and example configs for Claude Code + Hermes.
3
+ 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.
4
4
 
5
5
  Part of the [getbased-agents monorepo](https://github.com/elkimek/getbased-agents).
6
6
 
@@ -20,36 +20,65 @@ Pulls:
20
20
 
21
21
  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).
22
22
 
23
- ## Quickstart
23
+ ## Quickstart — one command
24
24
 
25
25
  ```bash
26
- # 1. Start the RAG server — local Qdrant DB + MiniLM embedder
27
- lens serve # blocks; serves on 127.0.0.1:8322
28
- lens key # prints the bearer token
26
+ getbased-stack init
27
+ ```
28
+
29
+ The wizard (~30 seconds):
30
+
31
+ 1. Prompts for your `GETBASED_TOKEN` (skip if you don't use sync)
32
+ 2. Generates a rag API key if one doesn't exist
33
+ 3. Writes `~/.config/getbased/env` (mode 0600) — the shared config file
34
+ 4. Installs systemd user units for rag + dashboard, enables them, starts them
35
+
36
+ Then paste one line into your MCP client:
37
+
38
+ ```bash
39
+ getbased-stack mcp-config claude-desktop # or: claude-code, cursor, cline, hermes
40
+ ```
41
+
42
+ 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.
43
+
44
+ Open the dashboard:
45
+
46
+ ```
47
+ http://127.0.0.1:8323
48
+ ```
49
+
50
+ Login URL with bearer key:
51
+
52
+ ```bash
53
+ getbased-dashboard login-url # prints http://127.0.0.1:8323/?key=...
54
+ ```
55
+
56
+ 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.
29
57
 
30
- # 2. Start the dashboard in another terminal
31
- getbased-dashboard serve # serves on 127.0.0.1:8323
58
+ ### Surviving reboot on headless hosts
32
59
 
33
- # 3. Open http://127.0.0.1:8323 in your browser, paste the lens key
34
- # Create libraries, drag-drop files to ingest (live chunks/sec pill),
35
- # run the MCP Test button to verify your agent path
60
+ 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:
36
61
 
37
- # 4. Wire the MCP into your AI client
38
- # The dashboard's MCP tab generates paste-ready config blocks for
39
- # Claude Desktop, Claude Code, Cursor, Cline, and Hermes.
62
+ ```bash
63
+ sudo loginctl enable-linger $USER
40
64
  ```
41
65
 
42
- Both the RAG server and the getbased PWA talk to the same `lens` instance point the PWA at `http://127.0.0.1:8322` under **Settings AI Knowledge Base External server** and the same corpus feeds the browser chat, the dashboard, and any MCP-connected agent.
66
+ `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.
43
67
 
44
- ## Running as a systemd service
68
+ ### Other commands
45
69
 
46
70
  ```bash
47
- cp systemd/getbased-rag.service ~/.config/systemd/user/
48
- systemctl --user daemon-reload
49
- systemctl --user enable --now getbased-rag
71
+ getbased-stack status # env file, unit state, linger
72
+ getbased-stack set GETBASED_TOKEN=new # rotate the token
73
+ getbased-stack install # re-apply unit files after package upgrade
74
+ getbased-stack uninstall # stop + disable + remove units
50
75
  ```
51
76
 
52
- The unit is hardened (`ProtectSystem=strict`, `NoNewPrivileges`, `RestrictAddressFamilies`, etc.); run `systemd-analyze security getbased-rag` to see the score.
77
+ ### Migrating from an older install
78
+
79
+ 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.
80
+
81
+ 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.
53
82
 
54
83
  ## Architecture
55
84
 
@@ -78,6 +107,7 @@ The dashboard is likewise stateless — it proxies rag for Knowledge operations,
78
107
  |---|---|---|---|---|
79
108
  | 0.1.x | ≥0.2.0 | ≥0.1.0 | — | v1 (multi-library) |
80
109
  | 0.2.x | ≥0.2.2 | ≥0.6.0 | ≥0.5.0 | v1 (+ streaming ingest, per-library models) |
110
+ | 0.4.x | ≥0.2.3 | ≥0.7.1 | ≥0.6.1 | v1 (+ shared env file, `getbased-stack init`, systemd units) |
81
111
 
82
112
  Bump the meta's major when sibling protocols break; bump siblings freely for normal features.
83
113
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "getbased-agent-stack"
7
- version = "0.3.1"
7
+ version = "0.4.1"
8
8
  description = "One-command install of the full getbased agent stack — getbased-mcp + getbased-rag + getbased-dashboard"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-only"
@@ -12,15 +12,15 @@ requires-python = ">=3.10"
12
12
  # Pulls every sibling package. Bump this meta when a sibling protocol
13
13
  # bump requires coordinated release.
14
14
  dependencies = [
15
- "getbased-mcp>=0.2.2",
16
- "getbased-rag>=0.7.0",
17
- "getbased-dashboard>=0.6.0",
15
+ "getbased-mcp>=0.2.3",
16
+ "getbased-rag>=0.7.1",
17
+ "getbased-dashboard>=0.6.1",
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
21
  # `full` includes PDF/DOCX parsers and ONNX acceleration (pre-exported
22
22
  # weights — no PyTorch/transformers/optimum dance).
23
- full = ["getbased-rag[full]>=0.7.0"]
23
+ full = ["getbased-rag[full]>=0.7.1"]
24
24
  # For contributors running the integration test harness.
25
25
  test = ["pytest>=8.0", "httpx>=0.27", "pytest-timeout>=2.3"]
26
26
 
@@ -36,6 +36,11 @@ getbased-stack = "getbased_agent_stack.cli:main"
36
36
  [tool.setuptools.packages.find]
37
37
  where = ["src"]
38
38
 
39
+ [tool.setuptools.package-data]
40
+ # Ship the user systemd unit files inside the wheel so
41
+ # `getbased-stack install` can locate them via importlib.resources.
42
+ getbased_agent_stack = ["systemd/*.service"]
43
+
39
44
  [tool.pytest.ini_options]
40
45
  testpaths = ["tests"]
41
46
  addopts = "-ra -q"
@@ -5,4 +5,4 @@ plus a small CLI that proxies to the real binaries. Everything
5
5
  interesting lives in the sibling repos.
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.4.1"
@@ -0,0 +1,366 @@
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
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import os
20
+ import secrets
21
+ import shutil
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from . import env_file, mcp_configs, units
26
+
27
+
28
+ # ── helpers ───────────────────────────────────────────────────────────
29
+
30
+
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"
34
+
35
+
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
45
+
46
+
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"):
262
+ try:
263
+ print(f" {pkg} {md.version(pkg)}")
264
+ except md.PackageNotFoundError:
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
270
+
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."""
276
+ try:
277
+ from lens.cli import app as lens_app
278
+
279
+ sys.argv = ["lens"] + argv
280
+ try:
281
+ lens_app()
282
+ return 0
283
+ except SystemExit as e:
284
+ return int(e.code or 0)
285
+ except ImportError:
286
+ print(
287
+ "getbased-rag not installed — install with "
288
+ "`pipx install getbased-agent-stack[full]`",
289
+ file=sys.stderr,
290
+ )
291
+ return 1
292
+
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
+
365
+ if __name__ == "__main__":
366
+ sys.exit(main())