cloudagent-daemon 0.2.1__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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Detect AI coding runtimes installed on the user's machine.
|
|
2
|
+
|
|
3
|
+
Two-stage scan:
|
|
4
|
+
1. ``shutil.which`` against the daemon process PATH (set by the service
|
|
5
|
+
wrapper to include ~/.local/bin, /opt/homebrew/bin, etc.).
|
|
6
|
+
2. Well-known per-user directories that no static PATH can cover —
|
|
7
|
+
primarily Node-version-manager dirs (nvm, fnm, volta) where the
|
|
8
|
+
version segment is dynamic.
|
|
9
|
+
|
|
10
|
+
Captures version via ``<binary> --version`` (or equivalent) and returns a
|
|
11
|
+
list of ``DetectedRuntime`` objects for the WS ``ready`` frame.
|
|
12
|
+
|
|
13
|
+
Spec: docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md §4.3
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import glob
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from dataclasses import dataclass, asdict
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class DetectedRuntime:
|
|
26
|
+
name: str
|
|
27
|
+
version: str
|
|
28
|
+
binary: str
|
|
29
|
+
|
|
30
|
+
def to_payload(self) -> dict:
|
|
31
|
+
return asdict(self)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# (canonical_name, [binary candidates in priority order], [version-args])
|
|
35
|
+
_PROBES: list[tuple[str, list[str], list[str]]] = [
|
|
36
|
+
("claude_code", ["claude", "claude-code"], ["--version"]),
|
|
37
|
+
("codex", ["codex"], ["--version"]),
|
|
38
|
+
("opencode", ["opencode", "open-code"], ["version"]),
|
|
39
|
+
("aider", ["aider"], ["--version"]),
|
|
40
|
+
("cursor_agent", ["cursor-agent"], ["--version"]),
|
|
41
|
+
("gemini_cli", ["gemini"], ["--version"]),
|
|
42
|
+
("gh_copilot", ["gh"], ["copilot", "--version"]),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Stable user-local bin directories. Plist/systemd PATH covers the common
|
|
47
|
+
# cases (~/.local/bin etc.), but listing them here too means we still find
|
|
48
|
+
# tools when the user installed the daemon before this PATH change shipped.
|
|
49
|
+
_EXTRA_DIRS: list[str] = [
|
|
50
|
+
"~/.local/bin",
|
|
51
|
+
"~/bin",
|
|
52
|
+
"~/.npm-global/bin",
|
|
53
|
+
"~/.volta/bin",
|
|
54
|
+
"~/.bun/bin",
|
|
55
|
+
"~/.yarn/bin",
|
|
56
|
+
"~/.local/share/pnpm",
|
|
57
|
+
"~/.pnpm-global/bin",
|
|
58
|
+
"~/Library/pnpm",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Dynamic per-version dirs that no static PATH can express. Globs are
|
|
62
|
+
# expanded at scan time; missing dirs are silently skipped.
|
|
63
|
+
_EXTRA_GLOBS: list[str] = [
|
|
64
|
+
"~/.nvm/versions/node/*/bin",
|
|
65
|
+
"~/.fnm/node-versions/*/installation/bin",
|
|
66
|
+
"~/.local/share/fnm/node-versions/*/installation/bin",
|
|
67
|
+
"~/Library/Application Support/fnm/node-versions/*/installation/bin",
|
|
68
|
+
"~/.asdf/installs/nodejs/*/bin",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _expand_search_dirs() -> list[str]:
|
|
73
|
+
"""Resolve _EXTRA_DIRS + _EXTRA_GLOBS to absolute, existing dir paths.
|
|
74
|
+
|
|
75
|
+
Order matters — earlier entries win in case the same binary appears
|
|
76
|
+
in multiple locations. _EXTRA_DIRS comes before glob expansions so
|
|
77
|
+
user-stable installs beat version-manager fallbacks; within glob
|
|
78
|
+
expansions, ``sorted`` puts the highest version last so we pick it.
|
|
79
|
+
"""
|
|
80
|
+
found: list[str] = []
|
|
81
|
+
for d in _EXTRA_DIRS:
|
|
82
|
+
p = os.path.expanduser(d)
|
|
83
|
+
if os.path.isdir(p):
|
|
84
|
+
found.append(p)
|
|
85
|
+
for pattern in _EXTRA_GLOBS:
|
|
86
|
+
expanded = os.path.expanduser(pattern)
|
|
87
|
+
for p in sorted(glob.glob(expanded)):
|
|
88
|
+
if os.path.isdir(p):
|
|
89
|
+
found.append(p)
|
|
90
|
+
return found
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_in_extra_dirs(binary_name: str, search_dirs: list[str]) -> str | None:
|
|
94
|
+
"""Return absolute path of ``binary_name`` if found in any search_dir."""
|
|
95
|
+
for d in search_dirs:
|
|
96
|
+
candidate = os.path.join(d, binary_name)
|
|
97
|
+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
98
|
+
return candidate
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _capture_version(binary: str, args: list[str], timeout: float) -> str:
|
|
103
|
+
try:
|
|
104
|
+
r = subprocess.run(
|
|
105
|
+
[binary, *args],
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
112
|
+
return "unknown"
|
|
113
|
+
out = (r.stdout or "") + (r.stderr or "")
|
|
114
|
+
first_line = out.strip().splitlines()[0] if out.strip() else ""
|
|
115
|
+
return first_line[:64] if first_line else "unknown"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def detect_runtimes(timeout: float = 5.0) -> list[DetectedRuntime]:
|
|
119
|
+
"""Scan PATH + well-known dirs for each known runtime; return all detected.
|
|
120
|
+
|
|
121
|
+
First binary candidate per name wins (alias resolution). Probes that fail
|
|
122
|
+
to produce a version string still register the runtime with version="unknown".
|
|
123
|
+
"""
|
|
124
|
+
extra_dirs = _expand_search_dirs()
|
|
125
|
+
found: list[DetectedRuntime] = []
|
|
126
|
+
for name, candidates, version_args in _PROBES:
|
|
127
|
+
for binary_name in candidates:
|
|
128
|
+
path = shutil.which(binary_name) or _find_in_extra_dirs(
|
|
129
|
+
binary_name, extra_dirs
|
|
130
|
+
)
|
|
131
|
+
if path:
|
|
132
|
+
version = _capture_version(path, version_args, timeout)
|
|
133
|
+
found.append(DetectedRuntime(name=name, version=version, binary=path))
|
|
134
|
+
break # first candidate wins per name
|
|
135
|
+
return found
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Install/uninstall daemon as a system service.
|
|
2
|
+
|
|
3
|
+
macOS: launchd User Agent (~/Library/LaunchAgents/chat.cloudagent.daemon.plist)
|
|
4
|
+
Linux: systemd user service (~/.config/systemd/user/cloudagent-daemon.service)
|
|
5
|
+
|
|
6
|
+
Both configured with KeepAlive/Restart=always so the daemon auto-restarts
|
|
7
|
+
on crash, and starts automatically at user login.
|
|
8
|
+
|
|
9
|
+
Per 1k #61 — Phase 1 commitment: "the daemon never self-exits unless the
|
|
10
|
+
user actively stops it." The OS supervisor (launchd / systemd) is the
|
|
11
|
+
ultimate safety net; if our async loop ever crashes, the OS brings the
|
|
12
|
+
process back within seconds.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .paths import (
|
|
23
|
+
DEFAULT_PROFILE,
|
|
24
|
+
ENV_VAR,
|
|
25
|
+
profile_launchd_label,
|
|
26
|
+
profile_log_dir,
|
|
27
|
+
profile_systemd_unit_name,
|
|
28
|
+
resolve_profile,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Default-profile identifiers — preserved as module-level constants so
|
|
32
|
+
# existing tests / external callers (`grep -rn "LAUNCHD_LABEL"`) keep
|
|
33
|
+
# working. ``profile_launchd_label("fr")`` etc. produce the per-profile
|
|
34
|
+
# variants.
|
|
35
|
+
LAUNCHD_LABEL = profile_launchd_label(DEFAULT_PROFILE)
|
|
36
|
+
SYSTEMD_NAME = profile_systemd_unit_name(DEFAULT_PROFILE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_platform() -> str:
|
|
40
|
+
"""Return ``"macos"`` or ``"linux"``; raise on unsupported OS."""
|
|
41
|
+
s = platform.system().lower()
|
|
42
|
+
if s == "darwin":
|
|
43
|
+
return "macos"
|
|
44
|
+
if s == "linux":
|
|
45
|
+
return "linux"
|
|
46
|
+
raise RuntimeError(f"Unsupported platform for service install: {s}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _binary_path() -> str:
|
|
50
|
+
"""Locate the cloudagent-daemon binary on PATH.
|
|
51
|
+
|
|
52
|
+
Both launchd and systemd require an absolute path — they don't see the
|
|
53
|
+
user's interactive shell PATH. We fail loudly here rather than write a
|
|
54
|
+
broken unit file that silently no-ops on every restart.
|
|
55
|
+
"""
|
|
56
|
+
p = shutil.which("cloudagent-daemon")
|
|
57
|
+
if p is None:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"cloudagent-daemon not found on PATH. Install first: "
|
|
60
|
+
"uv tool install cloudagent-daemon"
|
|
61
|
+
)
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------- macOS launchd ----------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _launchd_plist_path(profile: str | None = None) -> Path:
|
|
69
|
+
label = profile_launchd_label(profile)
|
|
70
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _launchd_plist_content(
|
|
74
|
+
server_url: str | None,
|
|
75
|
+
api_key: str | None,
|
|
76
|
+
insecure: bool,
|
|
77
|
+
workspace_root: str | None = None,
|
|
78
|
+
profile: str | None = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Render the plist XML.
|
|
81
|
+
|
|
82
|
+
If ``server_url`` + ``api_key`` are passed they go on the command line
|
|
83
|
+
(one-shot mode). Otherwise the plist relies on the per-profile
|
|
84
|
+
``config.json`` having been populated via ``cloudagent-daemon login``.
|
|
85
|
+
|
|
86
|
+
Task #115 (2026-05-12) — ``workspace_root`` (when supplied) lands as
|
|
87
|
+
``--workspace-root <abs path>`` so users can ``install-service
|
|
88
|
+
--workspace-root ~/projects/myapp`` in one go instead of the prior
|
|
89
|
+
uninstall→start→Ctrl-C→install dance.
|
|
90
|
+
|
|
91
|
+
M6 multi-profile (2026-05-18) — *profile* (when non-default) is
|
|
92
|
+
baked into ProgramArguments as ``--profile <name>`` and exposed to
|
|
93
|
+
the spawned process via the ``CLOUDAGENT_DAEMON_PROFILE`` env so
|
|
94
|
+
children (mcp_bridge subprocess) inherit the active profile. The
|
|
95
|
+
default profile produces a plist byte-for-byte identical to the
|
|
96
|
+
pre-multi-profile output — that is the zero-migration guarantee.
|
|
97
|
+
"""
|
|
98
|
+
profile_name = resolve_profile(profile)
|
|
99
|
+
label = profile_launchd_label(profile_name)
|
|
100
|
+
binary = _binary_path()
|
|
101
|
+
args: list[str] = [binary, "start"]
|
|
102
|
+
if server_url and api_key:
|
|
103
|
+
args += ["--server-url", server_url, "--api-key", api_key]
|
|
104
|
+
if insecure:
|
|
105
|
+
args += ["--insecure"]
|
|
106
|
+
if workspace_root:
|
|
107
|
+
args += ["--workspace-root", workspace_root]
|
|
108
|
+
if profile_name != DEFAULT_PROFILE:
|
|
109
|
+
args += ["--profile", profile_name]
|
|
110
|
+
|
|
111
|
+
args_xml = "\n".join(f" <string>{a}</string>" for a in args)
|
|
112
|
+
home = str(Path.home())
|
|
113
|
+
log_dir = str(profile_log_dir(profile_name))
|
|
114
|
+
|
|
115
|
+
profile_env = ""
|
|
116
|
+
if profile_name != DEFAULT_PROFILE:
|
|
117
|
+
profile_env = (
|
|
118
|
+
f" <key>{ENV_VAR}</key>\n"
|
|
119
|
+
f" <string>{profile_name}</string>\n"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
123
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
124
|
+
<plist version="1.0">
|
|
125
|
+
<dict>
|
|
126
|
+
<key>Label</key>
|
|
127
|
+
<string>{label}</string>
|
|
128
|
+
<key>ProgramArguments</key>
|
|
129
|
+
<array>
|
|
130
|
+
{args_xml}
|
|
131
|
+
</array>
|
|
132
|
+
<key>RunAtLoad</key>
|
|
133
|
+
<true/>
|
|
134
|
+
<key>KeepAlive</key>
|
|
135
|
+
<true/>
|
|
136
|
+
<key>StandardOutPath</key>
|
|
137
|
+
<string>{log_dir}/launchd.out.log</string>
|
|
138
|
+
<key>StandardErrorPath</key>
|
|
139
|
+
<string>{log_dir}/launchd.err.log</string>
|
|
140
|
+
<key>WorkingDirectory</key>
|
|
141
|
+
<string>{home}</string>
|
|
142
|
+
<key>EnvironmentVariables</key>
|
|
143
|
+
<dict>
|
|
144
|
+
<key>PATH</key>
|
|
145
|
+
<string>{home}/.local/bin:{home}/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
146
|
+
<key>NO_PROXY</key>
|
|
147
|
+
<string>*</string>
|
|
148
|
+
<!-- Python defaults to block-buffered stdout when not a tty (launchd
|
|
149
|
+
redirects to a file → not a tty). Setting this forces line-buffered
|
|
150
|
+
stdout/stderr so ``typer.echo("Detected runtimes:...")`` and other
|
|
151
|
+
single-line status messages land in launchd.out.log immediately
|
|
152
|
+
instead of sitting in an 8 KB buffer until the process exits. -->
|
|
153
|
+
<key>PYTHONUNBUFFERED</key>
|
|
154
|
+
<string>1</string>
|
|
155
|
+
{profile_env} </dict>
|
|
156
|
+
</dict>
|
|
157
|
+
</plist>
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def install_macos(
|
|
162
|
+
server_url: str | None,
|
|
163
|
+
api_key: str | None,
|
|
164
|
+
insecure: bool,
|
|
165
|
+
workspace_root: str | None = None,
|
|
166
|
+
profile: str | None = None,
|
|
167
|
+
) -> Path:
|
|
168
|
+
"""Install + load + start the launchd user agent for *profile*."""
|
|
169
|
+
profile_name = resolve_profile(profile)
|
|
170
|
+
label = profile_launchd_label(profile_name)
|
|
171
|
+
plist_path = _launchd_plist_path(profile_name)
|
|
172
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
log_dir = profile_log_dir(profile_name)
|
|
174
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
content = _launchd_plist_content(
|
|
177
|
+
server_url, api_key, insecure, workspace_root, profile=profile_name
|
|
178
|
+
)
|
|
179
|
+
plist_path.write_text(content)
|
|
180
|
+
plist_path.chmod(0o600)
|
|
181
|
+
|
|
182
|
+
uid = os.getuid()
|
|
183
|
+
# Bootstrap into launchd (modern API). ``check=False`` because the
|
|
184
|
+
# service may already be loaded — bootout/kickstart below will recover.
|
|
185
|
+
subprocess.run(
|
|
186
|
+
["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
187
|
+
check=False,
|
|
188
|
+
)
|
|
189
|
+
# Kickstart -k forces an immediate (re)start.
|
|
190
|
+
subprocess.run(
|
|
191
|
+
["launchctl", "kickstart", "-k", f"gui/{uid}/{label}"],
|
|
192
|
+
check=False,
|
|
193
|
+
)
|
|
194
|
+
return plist_path
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def uninstall_macos(profile: str | None = None) -> bool:
|
|
198
|
+
"""Stop + bootout + remove plist. Returns True if removed, False if not present."""
|
|
199
|
+
profile_name = resolve_profile(profile)
|
|
200
|
+
label = profile_launchd_label(profile_name)
|
|
201
|
+
plist_path = _launchd_plist_path(profile_name)
|
|
202
|
+
if not plist_path.exists():
|
|
203
|
+
return False
|
|
204
|
+
subprocess.run(
|
|
205
|
+
["launchctl", "bootout", f"gui/{os.getuid()}/{label}"],
|
|
206
|
+
check=False,
|
|
207
|
+
)
|
|
208
|
+
plist_path.unlink(missing_ok=True)
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------- Linux systemd ----------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _systemd_unit_path(profile: str | None = None) -> Path:
|
|
216
|
+
name = profile_systemd_unit_name(profile)
|
|
217
|
+
return Path.home() / ".config" / "systemd" / "user" / name
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _systemd_unit_content(
|
|
221
|
+
server_url: str | None,
|
|
222
|
+
api_key: str | None,
|
|
223
|
+
insecure: bool,
|
|
224
|
+
workspace_root: str | None = None,
|
|
225
|
+
profile: str | None = None,
|
|
226
|
+
) -> str:
|
|
227
|
+
profile_name = resolve_profile(profile)
|
|
228
|
+
binary = _binary_path()
|
|
229
|
+
cmd: list[str] = [binary, "start"]
|
|
230
|
+
if server_url and api_key:
|
|
231
|
+
cmd += ["--server-url", server_url, "--api-key", api_key]
|
|
232
|
+
if insecure:
|
|
233
|
+
cmd += ["--insecure"]
|
|
234
|
+
if workspace_root:
|
|
235
|
+
# Task #115 — mirror the launchd plist's --workspace-root surface.
|
|
236
|
+
cmd += ["--workspace-root", workspace_root]
|
|
237
|
+
if profile_name != DEFAULT_PROFILE:
|
|
238
|
+
cmd += ["--profile", profile_name]
|
|
239
|
+
|
|
240
|
+
home = str(Path.home())
|
|
241
|
+
# Mirror the macOS plist PATH so systemd-managed daemons can shutil.which()
|
|
242
|
+
# CLIs installed via uv/pipx (~/.local/bin), ~/bin user scripts, distro
|
|
243
|
+
# /usr/local/bin, and standard system dirs. systemd doesn't expand
|
|
244
|
+
# ~ in unit files — substitute the absolute home path at install time.
|
|
245
|
+
daemon_path = (
|
|
246
|
+
f"{home}/.local/bin:{home}/bin:/usr/local/bin:/usr/bin:/bin"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
profile_env_line = (
|
|
250
|
+
f"Environment={ENV_VAR}={profile_name}\n"
|
|
251
|
+
if profile_name != DEFAULT_PROFILE
|
|
252
|
+
else ""
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return f"""[Unit]
|
|
256
|
+
Description=cloudagent local daemon
|
|
257
|
+
After=network-online.target
|
|
258
|
+
Wants=network-online.target
|
|
259
|
+
|
|
260
|
+
[Service]
|
|
261
|
+
Type=simple
|
|
262
|
+
ExecStart={" ".join(cmd)}
|
|
263
|
+
Restart=always
|
|
264
|
+
RestartSec=5
|
|
265
|
+
Environment=NO_PROXY=*
|
|
266
|
+
Environment=PATH={daemon_path}
|
|
267
|
+
# Force line-buffered stdout — see launchd plist comment for rationale.
|
|
268
|
+
Environment=PYTHONUNBUFFERED=1
|
|
269
|
+
{profile_env_line}
|
|
270
|
+
[Install]
|
|
271
|
+
WantedBy=default.target
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def install_linux(
|
|
276
|
+
server_url: str | None,
|
|
277
|
+
api_key: str | None,
|
|
278
|
+
insecure: bool,
|
|
279
|
+
workspace_root: str | None = None,
|
|
280
|
+
profile: str | None = None,
|
|
281
|
+
) -> Path:
|
|
282
|
+
profile_name = resolve_profile(profile)
|
|
283
|
+
unit_name = profile_systemd_unit_name(profile_name)
|
|
284
|
+
unit_path = _systemd_unit_path(profile_name)
|
|
285
|
+
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
unit_path.write_text(
|
|
287
|
+
_systemd_unit_content(
|
|
288
|
+
server_url, api_key, insecure, workspace_root, profile=profile_name
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
292
|
+
subprocess.run(
|
|
293
|
+
["systemctl", "--user", "enable", "--now", unit_name],
|
|
294
|
+
check=False,
|
|
295
|
+
)
|
|
296
|
+
return unit_path
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def uninstall_linux(profile: str | None = None) -> bool:
|
|
300
|
+
profile_name = resolve_profile(profile)
|
|
301
|
+
unit_name = profile_systemd_unit_name(profile_name)
|
|
302
|
+
unit_path = _systemd_unit_path(profile_name)
|
|
303
|
+
if not unit_path.exists():
|
|
304
|
+
return False
|
|
305
|
+
subprocess.run(
|
|
306
|
+
["systemctl", "--user", "disable", "--now", unit_name],
|
|
307
|
+
check=False,
|
|
308
|
+
)
|
|
309
|
+
unit_path.unlink(missing_ok=True)
|
|
310
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------- Cross-platform dispatchers ----------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def install_service(
|
|
318
|
+
server_url: str | None = None,
|
|
319
|
+
api_key: str | None = None,
|
|
320
|
+
insecure: bool = False,
|
|
321
|
+
workspace_root: str | None = None,
|
|
322
|
+
profile: str | None = None,
|
|
323
|
+
) -> Path:
|
|
324
|
+
plat = detect_platform()
|
|
325
|
+
if plat == "macos":
|
|
326
|
+
return install_macos(
|
|
327
|
+
server_url, api_key, insecure, workspace_root, profile=profile
|
|
328
|
+
)
|
|
329
|
+
return install_linux(
|
|
330
|
+
server_url, api_key, insecure, workspace_root, profile=profile
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def uninstall_service(profile: str | None = None) -> bool:
|
|
335
|
+
plat = detect_platform()
|
|
336
|
+
if plat == "macos":
|
|
337
|
+
return uninstall_macos(profile=profile)
|
|
338
|
+
return uninstall_linux(profile=profile)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
__all__ = [
|
|
342
|
+
"LAUNCHD_LABEL",
|
|
343
|
+
"SYSTEMD_NAME",
|
|
344
|
+
"detect_platform",
|
|
345
|
+
"install_service",
|
|
346
|
+
"uninstall_service",
|
|
347
|
+
"install_macos",
|
|
348
|
+
"install_linux",
|
|
349
|
+
"uninstall_macos",
|
|
350
|
+
"uninstall_linux",
|
|
351
|
+
"_launchd_plist_content",
|
|
352
|
+
"_systemd_unit_content",
|
|
353
|
+
"_launchd_plist_path",
|
|
354
|
+
"_systemd_unit_path",
|
|
355
|
+
]
|