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.
@@ -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
+ ]