directops-operator 0.1.9__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.
agent/discovery.py ADDED
@@ -0,0 +1,229 @@
1
+ """Machine capability discovery — runs at install time and on demand.
2
+
3
+ Probes the local system for installed tools, configured accounts, and
4
+ available credentials. Results are written to ~/.config/directops/capabilities.json
5
+ and sent to the Director in RegisterMessage.system_context on every connect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ CAPABILITIES_PATH = Path.home() / ".config" / "directops" / "capabilities.json"
21
+
22
+ # Curated categories for semantic context. The Director uses these to understand
23
+ # what *role* a tool plays (e.g. msmtp = email). The full available_tools list
24
+ # comes from a live PATH scan and includes everything on the machine.
25
+ _TOOLS: dict[str, list[str]] = {
26
+ "email": ["msmtp", "sendmail", "mutt", "mailx", "s-nail", "swaks"],
27
+ "vcs": ["git", "gh", "svn", "hg"],
28
+ "containers": ["docker", "podman", "kubectl", "helm", "compose", "docker-compose"],
29
+ "languages": ["python3", "python", "node", "go", "ruby", "java", "rustc", "php",
30
+ "perl", "lua", "elixir", "erlang", "scala", "kotlinc"],
31
+ "package_managers": ["apt", "apt-get", "brew", "dnf", "yum", "pacman", "zypper",
32
+ "pip3", "pip", "npm", "yarn", "pnpm", "cargo", "gem", "composer"],
33
+ "shell_tools": ["curl", "wget", "jq", "make", "cmake", "ssh", "rsync",
34
+ "tmux", "screen", "socat", "envsubst", "parallel"],
35
+ "editors": ["vim", "nvim", "nano", "emacs", "code", "helix", "micro"],
36
+ "network": ["nmap", "masscan", "arp-scan", "nc", "ncat", "netcat",
37
+ "ss", "ip", "ifconfig", "netstat", "tcpdump", "tshark",
38
+ "dig", "nslookup", "host", "ping", "ping6", "traceroute",
39
+ "tracepath", "mtr", "whois", "ngrep", "iftop", "nethogs",
40
+ "bmon", "iperf", "iperf3", "speedtest-cli", "bandwhich"],
41
+ "security": ["openssl", "gpg", "gpg2", "age", "sshpass", "ssh-keygen",
42
+ "fail2ban-client", "iptables", "ip6tables", "ufw", "nft",
43
+ "firewall-cmd", "apparmor_status", "semanage"],
44
+ "browsers": ["firefox", "firefox-esr", "chromium", "chromium-browser",
45
+ "google-chrome", "google-chrome-stable", "brave-browser",
46
+ "brave", "opera", "vivaldi", "lynx", "w3m", "links",
47
+ "elinks", "epiphany", "midori"],
48
+ "system": ["systemctl", "service", "journalctl", "crontab", "at",
49
+ "ps", "top", "htop", "btop", "lsof", "strace", "ltrace",
50
+ "perf", "dmesg", "lshw", "lscpu", "lsblk", "lspci", "lsusb",
51
+ "df", "du", "free", "vmstat", "iostat", "sar", "uptime",
52
+ "dmidecode", "hwinfo", "inxi"],
53
+ "database": ["mysql", "mysqldump", "psql", "pg_dump", "sqlite3",
54
+ "redis-cli", "mongosh", "mongo", "mongodump", "mongorestore",
55
+ "influx", "clickhouse-client"],
56
+ "automation": ["ansible", "ansible-playbook", "terraform", "puppet",
57
+ "chef", "salt", "fabric", "capistrano"],
58
+ "media": ["ffmpeg", "ffprobe", "convert", "magick", "identify",
59
+ "vlc", "mpv", "yt-dlp", "youtube-dl", "sox", "mediainfo"],
60
+ "archive": ["tar", "zip", "unzip", "7z", "gzip", "bzip2", "xz",
61
+ "zstd", "rar", "unrar", "lz4", "lzma"],
62
+ "text": ["grep", "sed", "awk", "sort", "uniq", "cut", "tr",
63
+ "xargs", "find", "fd", "rg", "ripgrep", "bat", "fzf",
64
+ "less", "head", "tail", "wc", "column", "jq", "yq",
65
+ "xmllint", "pandoc", "miller", "csvkit"],
66
+ "monitoring": ["prometheus", "grafana-server", "datadog-agent",
67
+ "telegraf", "collectd", "munin-run", "zabbix_agentd"],
68
+ "cloud": ["aws", "az", "gcloud", "doctl", "linode-cli", "vultr-cli",
69
+ "terraform", "vault", "consul", "nomad", "packer"],
70
+ }
71
+
72
+
73
+ def _probe_all_executables() -> list[str]:
74
+ """Walk every directory in $PATH and return all executable file names.
75
+
76
+ This is the primary source of available_tools — it finds everything on
77
+ the machine regardless of whether it's in the curated _TOOLS list above.
78
+ nmap, browsers, custom scripts — anything in PATH shows up here.
79
+ """
80
+ found: set[str] = set()
81
+ for dir_str in os.environ.get("PATH", "").split(os.pathsep):
82
+ d = Path(dir_str)
83
+ if not d.is_dir():
84
+ continue
85
+ try:
86
+ for entry in d.iterdir():
87
+ if (
88
+ entry.is_file()
89
+ and os.access(entry, os.X_OK)
90
+ and len(entry.name) >= 2
91
+ and not entry.name.startswith(".")
92
+ ):
93
+ found.add(entry.name)
94
+ except PermissionError:
95
+ pass
96
+ return sorted(found)
97
+
98
+
99
+ def _probe_tools() -> dict[str, dict[str, Any]]:
100
+ """Check which curated tools are installed. Returns {tool: {available, path}}."""
101
+ found: dict[str, dict[str, Any]] = {}
102
+ for _category, tools in _TOOLS.items():
103
+ for tool in tools:
104
+ path = shutil.which(tool)
105
+ found[tool] = {"available": path is not None, "path": path or ""}
106
+ return found
107
+
108
+
109
+ def _probe_email_accounts() -> dict[str, str]:
110
+ """Parse ~/.msmtprc and return {account_name: from_address} mappings.
111
+
112
+ msmtp accounts are referenced by their NAME (e.g. 'gmail'), NOT by
113
+ email address. Always pass the account name to --account / -a.
114
+ """
115
+ accounts: dict[str, str] = {}
116
+ msmtprc = Path.home() / ".msmtprc"
117
+ if not msmtprc.exists():
118
+ return accounts
119
+ try:
120
+ current: str | None = None
121
+ for raw in msmtprc.read_text().splitlines():
122
+ line = raw.strip()
123
+ if not line or line.startswith("#"):
124
+ continue
125
+ parts = line.split(None, 1)
126
+ key = parts[0].lower()
127
+ val = parts[1].strip() if len(parts) > 1 else ""
128
+ if key == "account" and ":" not in val:
129
+ current = val
130
+ elif key == "from" and "@" in val and current:
131
+ accounts[current] = val
132
+ except Exception:
133
+ pass
134
+ return accounts
135
+
136
+
137
+ def _probe_git_identity() -> str:
138
+ """Return git user.email if configured."""
139
+ try:
140
+ result = subprocess.run(
141
+ ["git", "config", "--global", "user.email"],
142
+ capture_output=True, text=True, timeout=5,
143
+ )
144
+ return result.stdout.strip()
145
+ except Exception:
146
+ return ""
147
+
148
+
149
+ def _probe_python_packages() -> list[str]:
150
+ """Return list of notable installed Python packages."""
151
+ notable = ["requests", "httpx", "boto3", "paramiko", "fabric", "ansible"]
152
+ try:
153
+ out = subprocess.run(
154
+ ["pip3", "show"] + notable,
155
+ capture_output=True, text=True, timeout=10,
156
+ ).stdout
157
+ except Exception:
158
+ return []
159
+ found = []
160
+ for line in out.splitlines():
161
+ if line.startswith("Name:"):
162
+ found.append(line.split(":", 1)[1].strip())
163
+ return found
164
+
165
+
166
+ def run_discovery() -> dict[str, Any]:
167
+ """Probe the machine and return a capabilities dict."""
168
+ import platform
169
+ import socket
170
+
171
+ logger.info("Running capability discovery…")
172
+
173
+ # Full PATH scan — the authoritative list of what's installed
174
+ all_executables = _probe_all_executables()
175
+
176
+ # Curated probe — adds semantic category context for the Director
177
+ categorized = _probe_tools()
178
+
179
+ msmtp_accounts = _probe_email_accounts()
180
+ git_email = _probe_git_identity()
181
+
182
+ context: dict[str, Any] = {
183
+ "hostname": socket.gethostname(),
184
+ "os": f"{platform.system()} {platform.release()}",
185
+ "user": os.getenv("USER", os.getenv("LOGNAME", "unknown")),
186
+ "tools": categorized,
187
+ "accounts": {
188
+ "msmtp": msmtp_accounts, # {name: email} — use name with -a flag
189
+ "email": list(msmtp_accounts.values()),
190
+ "git": git_email,
191
+ },
192
+ "python_packages": _probe_python_packages(),
193
+ # Full PATH scan: every executable on the machine. This is what Devstral
194
+ # and Claude check when planning — nothing gets missed.
195
+ "available_tools": all_executables,
196
+ }
197
+
198
+ logger.info(
199
+ "Discovery complete: %d executables in PATH, email accounts: %s",
200
+ len(all_executables),
201
+ list(msmtp_accounts.keys()) or "none",
202
+ )
203
+ return context
204
+
205
+
206
+ def load_or_run() -> dict[str, Any]:
207
+ """Load cached capabilities, or run discovery if cache is missing."""
208
+ if CAPABILITIES_PATH.exists():
209
+ try:
210
+ data = json.loads(CAPABILITIES_PATH.read_text())
211
+ logger.debug("Loaded capabilities from %s", CAPABILITIES_PATH)
212
+ return data
213
+ except Exception:
214
+ pass
215
+ return run_discovery()
216
+
217
+
218
+ def save(context: dict[str, Any]) -> None:
219
+ """Write capabilities to ~/.config/directops/capabilities.json."""
220
+ CAPABILITIES_PATH.parent.mkdir(parents=True, exist_ok=True)
221
+ CAPABILITIES_PATH.write_text(json.dumps(context, indent=2))
222
+ logger.info("Capabilities saved to %s", CAPABILITIES_PATH)
223
+
224
+
225
+ def run_and_save() -> dict[str, Any]:
226
+ """Run discovery, save result, and return it."""
227
+ context = run_discovery()
228
+ save(context)
229
+ return context
agent/dops.py ADDED
@@ -0,0 +1,408 @@
1
+ """dops — DirectOps Operator control CLI.
2
+
3
+ Commands:
4
+ dops start Start the operator service
5
+ dops stop Stop the operator service
6
+ dops restart Restart the operator service
7
+ dops status Show current status
8
+ dops enable Install + enable as systemd service (start on boot)
9
+ dops disable Disable + remove systemd service
10
+ dops update Check for and install updates
11
+ dops update --check Check only, do not install
12
+ dops config auto-update on|off Toggle automatic updates
13
+ dops config show Show current configuration
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ SERVICE_NAME = "directops-operator"
26
+ CONFIG_DIR = Path.home() / ".config" / "directops"
27
+ ENV_FILE = CONFIG_DIR / "operator.env"
28
+ PID_FILE = CONFIG_DIR / "operator.pid"
29
+ SERVICE_FILE = Path(f"/etc/systemd/system/{SERVICE_NAME}.service")
30
+
31
+ # ── helpers ────────────────────────────────────────────────────────────────────
32
+
33
+ def _has_systemd() -> bool:
34
+ return shutil.which("systemctl") is not None and SERVICE_FILE.exists()
35
+
36
+
37
+ def _systemctl(*args: str) -> subprocess.CompletedProcess:
38
+ cmd = ["sudo", "systemctl", *args, SERVICE_NAME]
39
+ return subprocess.run(cmd, capture_output=True, text=True)
40
+
41
+
42
+ def _read_pid() -> int | None:
43
+ try:
44
+ return int(PID_FILE.read_text().strip())
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ def _proc_running(pid: int) -> bool:
50
+ try:
51
+ os.kill(pid, 0)
52
+ return True
53
+ except (ProcessLookupError, PermissionError):
54
+ return False
55
+
56
+
57
+ # ── commands ───────────────────────────────────────────────────────────────────
58
+
59
+ def cmd_start(args) -> None:
60
+ if _has_systemd():
61
+ r = _systemctl("start")
62
+ if r.returncode == 0:
63
+ print("Operator started (systemd).")
64
+ else:
65
+ print(f"Failed to start: {r.stderr.strip()}", file=sys.stderr)
66
+ sys.exit(1)
67
+ else:
68
+ pid = _read_pid()
69
+ if pid and _proc_running(pid):
70
+ print(f"Operator already running (PID {pid}).")
71
+ return
72
+
73
+ operator_bin = shutil.which("directops-operator")
74
+ if not operator_bin:
75
+ print("directops-operator not found in PATH.", file=sys.stderr)
76
+ sys.exit(1)
77
+
78
+ env_vars = _load_env()
79
+ cmd = [operator_bin]
80
+ if env_vars.get("DIRECTOR_URL"):
81
+ cmd += ["--url", env_vars["DIRECTOR_URL"]]
82
+ if env_vars.get("OPERATOR_TOKEN"):
83
+ cmd += ["--token", env_vars["OPERATOR_TOKEN"]]
84
+ if env_vars.get("MACHINE_NAME"):
85
+ cmd += ["--name", env_vars["MACHINE_NAME"]]
86
+
87
+ proc = subprocess.Popen(cmd, start_new_session=True)
88
+ PID_FILE.write_text(str(proc.pid))
89
+ print(f"Operator started (PID {proc.pid}).")
90
+
91
+
92
+ def cmd_stop(args) -> None:
93
+ if _has_systemd():
94
+ r = _systemctl("stop")
95
+ if r.returncode == 0:
96
+ print("Operator stopped.")
97
+ else:
98
+ print(f"Failed to stop: {r.stderr.strip()}", file=sys.stderr)
99
+ sys.exit(1)
100
+ else:
101
+ pid = _read_pid()
102
+ if not pid or not _proc_running(pid):
103
+ print("Operator is not running.")
104
+ return
105
+ import signal
106
+ os.kill(pid, signal.SIGTERM)
107
+ PID_FILE.unlink(missing_ok=True)
108
+ print(f"Operator stopped (PID {pid}).")
109
+
110
+
111
+ def cmd_restart(args) -> None:
112
+ if _has_systemd():
113
+ r = _systemctl("restart")
114
+ if r.returncode != 0:
115
+ print(f"Failed to restart: {r.stderr.strip()}", file=sys.stderr)
116
+ sys.exit(1)
117
+ # Give it a moment, then confirm it didn't immediately crash-loop.
118
+ time.sleep(2)
119
+ active = _systemctl("is-active").stdout.strip()
120
+ if active == "active":
121
+ print("Operator restarted.")
122
+ else:
123
+ print(f"Operator restarted but is now '{active}' — check logs (dops logs).", file=sys.stderr)
124
+ sys.exit(1)
125
+ else:
126
+ cmd_stop(args)
127
+ cmd_start(args)
128
+
129
+
130
+ def cmd_status(args) -> None:
131
+ from .updater import get_installed_version
132
+ version = get_installed_version()
133
+
134
+ if _has_systemd():
135
+ r = _systemctl("is-active")
136
+ active = r.stdout.strip()
137
+ r2 = subprocess.run(
138
+ ["sudo", "systemctl", "status", SERVICE_NAME, "--no-pager", "-l"],
139
+ capture_output=True, text=True,
140
+ )
141
+ state = "running" if active == "active" else active
142
+ print(f"Operator v{version} — {state} (systemd)")
143
+ print(r2.stdout)
144
+ else:
145
+ pid = _read_pid()
146
+ if pid and _proc_running(pid):
147
+ print(f"Operator v{version} — running (PID {pid})")
148
+ else:
149
+ print(f"Operator v{version} — stopped")
150
+
151
+ env_vars = _load_env()
152
+ auto = env_vars.get("AUTO_UPDATE", "false")
153
+ url = env_vars.get("DIRECTOR_URL", "(not set)")
154
+ print(f" Director URL : {url}")
155
+ print(f" Auto-update : {auto}")
156
+
157
+
158
+ def cmd_enable(args) -> None:
159
+ """Install and enable systemd service."""
160
+ operator_bin = shutil.which("directops-operator")
161
+ if not operator_bin:
162
+ print("directops-operator not found in PATH.", file=sys.stderr)
163
+ sys.exit(1)
164
+
165
+ env_vars = _load_env()
166
+ user = os.environ.get("USER", os.environ.get("LOGNAME", ""))
167
+ service_content = f"""[Unit]
168
+ Description=DirectOps Operator
169
+ After=network-online.target
170
+ Wants=network-online.target
171
+
172
+ [Service]
173
+ Type=simple
174
+ User={user}
175
+ EnvironmentFile={ENV_FILE}
176
+ ExecStart={operator_bin} --url ${{DIRECTOR_URL}} --token ${{OPERATOR_TOKEN}} --name "${{MACHINE_NAME}}"
177
+ Restart=on-failure
178
+ RestartSec=10
179
+
180
+ [Install]
181
+ WantedBy=multi-user.target
182
+ """
183
+ tmp = Path("/tmp/directops-operator.service")
184
+ tmp.write_text(service_content)
185
+
186
+ print("Admin password required to install the DirectOps systemd service.")
187
+ subprocess.check_call(["sudo", "-v"])
188
+ subprocess.check_call(["sudo", "cp", str(tmp), str(SERVICE_FILE)])
189
+ tmp.unlink()
190
+ subprocess.check_call(["sudo", "systemctl", "daemon-reload"])
191
+ subprocess.check_call(["sudo", "systemctl", "enable", "--now", SERVICE_NAME])
192
+ print(f"Service installed and enabled. Operator will start on boot.")
193
+
194
+
195
+ def cmd_disable(args) -> None:
196
+ """Disable and remove systemd service."""
197
+ print("Admin password required to remove the DirectOps systemd service.")
198
+ subprocess.check_call(["sudo", "-v"])
199
+ subprocess.check_call(["sudo", "systemctl", "disable", "--now", SERVICE_NAME])
200
+ subprocess.check_call(["sudo", "rm", "-f", str(SERVICE_FILE)])
201
+ subprocess.check_call(["sudo", "systemctl", "daemon-reload"])
202
+ print("Service disabled and removed.")
203
+
204
+
205
+ def cmd_update(args) -> None:
206
+ from .updater import (
207
+ fetch_latest, get_installed_version, is_newer, install_update
208
+ )
209
+
210
+ check_only = getattr(args, "check", False)
211
+ installed = get_installed_version()
212
+ print(f"Installed: v{installed}")
213
+ print("Checking for updates…")
214
+
215
+ manifest = fetch_latest()
216
+ if not manifest:
217
+ print("Could not reach update server.", file=sys.stderr)
218
+ sys.exit(1)
219
+
220
+ latest = manifest.get("version", "")
221
+ if not is_newer(latest, installed):
222
+ print(f"Already up to date (v{installed}).")
223
+ return
224
+
225
+ changelog = manifest.get("changelog", "")
226
+ print(f"Update available: v{latest}")
227
+ if changelog:
228
+ print(f" {changelog}")
229
+
230
+ if check_only:
231
+ print("Run `dops update` to install.")
232
+ return
233
+
234
+ wheel_url = manifest.get("wheel_url", "")
235
+ sha256 = manifest.get("sha256", "")
236
+ if not wheel_url:
237
+ print("No download URL in manifest.", file=sys.stderr)
238
+ sys.exit(1)
239
+
240
+ print(f"Installing v{latest}…")
241
+ if install_update(wheel_url, sha256):
242
+ print(f"Updated to v{latest}. Restart the operator to apply: dops restart")
243
+ else:
244
+ print("Update failed.", file=sys.stderr)
245
+ sys.exit(1)
246
+
247
+
248
+ def cmd_logs(args) -> None:
249
+ """Stream operator logs. Falls back to log file if systemd is unavailable."""
250
+ n = getattr(args, "lines", 50)
251
+ follow = getattr(args, "follow", False)
252
+
253
+ if _has_systemd():
254
+ cmd = ["journalctl", "-u", SERVICE_NAME, "--no-pager", "-n", str(n)]
255
+ if follow:
256
+ cmd.append("-f")
257
+ try:
258
+ subprocess.run(cmd)
259
+ except KeyboardInterrupt:
260
+ pass
261
+ return
262
+
263
+ # Fallback: look for a log file written by the operator
264
+ log_candidates = [
265
+ CONFIG_DIR / "operator.log",
266
+ Path("/tmp/directops-operator.log"),
267
+ ]
268
+ for log_file in log_candidates:
269
+ if log_file.exists():
270
+ lines = log_file.read_text().splitlines()
271
+ print("\n".join(lines[-n:]))
272
+ return
273
+
274
+ print("No systemd service and no log file found. Run the operator via systemd for logs.", file=sys.stderr)
275
+ sys.exit(1)
276
+
277
+
278
+ def cmd_set_token(args) -> None:
279
+ """Write a new token to operator.env and restart the service."""
280
+ token = args.token.strip()
281
+ if not token:
282
+ print("Error: token must not be empty.", file=sys.stderr)
283
+ sys.exit(1)
284
+
285
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
286
+ env_vars = _load_env()
287
+ env_vars["OPERATOR_TOKEN"] = token
288
+
289
+ lines = "\n".join(f"{k}={v}" for k, v in env_vars.items()) + "\n"
290
+ ENV_FILE.write_text(lines)
291
+ ENV_FILE.chmod(0o600)
292
+ print("Token updated.")
293
+
294
+ if _has_systemd():
295
+ r = _systemctl("restart")
296
+ if r.returncode == 0:
297
+ print("Operator restarted.")
298
+ else:
299
+ print(f"Restart failed: {r.stderr.strip()}", file=sys.stderr)
300
+ sys.exit(1)
301
+
302
+
303
+ def cmd_config(args) -> None:
304
+ from .updater import set_auto_update_setting
305
+
306
+ sub = getattr(args, "setting", None)
307
+
308
+ if sub == "show" or sub is None:
309
+ env_vars = _load_env()
310
+ print("Current configuration:")
311
+ for k, v in env_vars.items():
312
+ display = v if k != "OPERATOR_TOKEN" else v[:8] + "…" if len(v) > 8 else v
313
+ print(f" {k}={display}")
314
+ return
315
+
316
+ if sub == "auto-update":
317
+ value = getattr(args, "value", "").lower()
318
+ if value in ("on", "true", "1", "yes"):
319
+ set_auto_update_setting(True)
320
+ print("Auto-update enabled. Operator will update automatically on startup.")
321
+ elif value in ("off", "false", "0", "no"):
322
+ set_auto_update_setting(False)
323
+ print("Auto-update disabled. Run `dops update` to update manually.")
324
+ else:
325
+ print("Usage: dops config auto-update on|off", file=sys.stderr)
326
+ sys.exit(1)
327
+
328
+
329
+ # ── env file ───────────────────────────────────────────────────────────────────
330
+
331
+ def _load_env() -> dict[str, str]:
332
+ result: dict[str, str] = {}
333
+ if not ENV_FILE.exists():
334
+ return result
335
+ for line in ENV_FILE.read_text().splitlines():
336
+ line = line.strip()
337
+ if line and not line.startswith("#") and "=" in line:
338
+ k, _, v = line.partition("=")
339
+ result[k.strip()] = v.strip()
340
+ return result
341
+
342
+
343
+ # ── CLI wiring ─────────────────────────────────────────────────────────────────
344
+
345
+ def main() -> None:
346
+ import argparse
347
+
348
+ parser = argparse.ArgumentParser(
349
+ prog="dops",
350
+ description="DirectOps Operator control CLI",
351
+ )
352
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
353
+
354
+ sub.add_parser("start", help="Start the operator service")
355
+ sub.add_parser("stop", help="Stop the operator service")
356
+ sub.add_parser("restart", help="Restart the operator service")
357
+ sub.add_parser("status", help="Show operator status")
358
+ sub.add_parser("enable", help="Install + enable systemd service (start on boot)")
359
+ sub.add_parser("disable", help="Disable + remove systemd service")
360
+
361
+ p_update = sub.add_parser("update", help="Check for and install updates")
362
+ p_update.add_argument(
363
+ "--check", action="store_true",
364
+ help="Check only — do not install",
365
+ )
366
+
367
+ p_logs = sub.add_parser("logs", help="Show operator logs (live with -f)")
368
+ p_logs.add_argument("-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)")
369
+ p_logs.add_argument("-f", "--follow", action="store_true", help="Follow log output in real time")
370
+
371
+ p_set_token = sub.add_parser("set-token", help="Update the auth token and restart the operator")
372
+ p_set_token.add_argument("token", help="New token from the DirectOps dashboard")
373
+
374
+ p_config = sub.add_parser("config", help="View or change configuration")
375
+ p_config.add_argument(
376
+ "setting", nargs="?", default="show",
377
+ choices=["show", "auto-update"],
378
+ help="Setting to view or change",
379
+ )
380
+ p_config.add_argument(
381
+ "value", nargs="?", default="",
382
+ help="Value for the setting (e.g. on|off for auto-update)",
383
+ )
384
+
385
+ args = parser.parse_args()
386
+
387
+ dispatch = {
388
+ "start": cmd_start,
389
+ "stop": cmd_stop,
390
+ "restart": cmd_restart,
391
+ "status": cmd_status,
392
+ "logs": cmd_logs,
393
+ "enable": cmd_enable,
394
+ "disable": cmd_disable,
395
+ "update": cmd_update,
396
+ "set-token": cmd_set_token,
397
+ "config": cmd_config,
398
+ }
399
+
400
+ if not args.command:
401
+ parser.print_help()
402
+ sys.exit(0)
403
+
404
+ dispatch[args.command](args)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()