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,833 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import signal
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+
14
+ # 1i Bug D — daemon connects directly to its WAN backend; macOS / corporate
15
+ # system-wide SOCKS / HTTP proxy settings would otherwise be inherited via
16
+ # urllib defaults and cause WSClient to fail with
17
+ # "connecting through a SOCKS proxy requires python-socks" when the user
18
+ # has macOS System Preferences → Network → Proxies enabled.
19
+ #
20
+ # Users who genuinely need to route the daemon through a corporate proxy
21
+ # can opt back in via env vars BEFORE invoking the CLI; this helper only
22
+ # applies the bypass when no override is already present.
23
+ _DEFAULT_NO_PROXY = "*"
24
+ _PROXY_VARS = (
25
+ "HTTP_PROXY",
26
+ "HTTPS_PROXY",
27
+ "ALL_PROXY",
28
+ "http_proxy",
29
+ "https_proxy",
30
+ "all_proxy",
31
+ )
32
+
33
+
34
+ def _bypass_system_proxy_by_default() -> None:
35
+ """Default the daemon to NO proxy. Idempotent — safe to call multiple times."""
36
+ os.environ.setdefault("NO_PROXY", _DEFAULT_NO_PROXY)
37
+ os.environ.setdefault("no_proxy", _DEFAULT_NO_PROXY)
38
+ for var in _PROXY_VARS:
39
+ # Only clear vars; honour an explicit user opt-in by checking
40
+ # CLOUDAGENT_USE_SYSTEM_PROXY=1 first.
41
+ if os.environ.get("CLOUDAGENT_USE_SYSTEM_PROXY") == "1":
42
+ return
43
+ if var in os.environ:
44
+ del os.environ[var]
45
+
46
+ app = typer.Typer(
47
+ name="cloudagent-daemon",
48
+ help="Local daemon for cloudagent — runs on user's machine and bridges claude subprocesses to the cloudagent backend.",
49
+ no_args_is_help=True,
50
+ )
51
+ agent_app = typer.Typer(no_args_is_help=True, help="Manage local agent workspaces.")
52
+ app.add_typer(agent_app, name="agent")
53
+
54
+
55
+ # M6 multi-profile (2026-05-18) — every stateful command takes
56
+ # ``--profile <name>``; default ``"default"`` is byte-for-byte identical
57
+ # to the pre-multi-profile behaviour. ``CLOUDAGENT_DAEMON_PROFILE`` env
58
+ # is the cross-process fallback (set in plist/unit so spawned children
59
+ # inherit the active profile without an explicit flag).
60
+ _PROFILE_HELP = (
61
+ "Profile name (default 'default'). Run multiple daemons against "
62
+ "different backends by giving each a unique profile."
63
+ )
64
+
65
+
66
+ def _resolve_cli_profile(profile: str | None) -> str:
67
+ """Validate + resolve a CLI ``--profile`` value with friendly errors."""
68
+ from .paths import resolve_profile
69
+
70
+ try:
71
+ return resolve_profile(profile)
72
+ except ValueError as exc:
73
+ typer.echo(f"Error: {exc}", err=True)
74
+ raise typer.Exit(2)
75
+
76
+
77
+ @app.command()
78
+ def login(
79
+ server: str = typer.Option(..., "--server", help="Backend server URL."),
80
+ api_key: str = typer.Option(
81
+ ...,
82
+ "--api-key",
83
+ prompt=True,
84
+ hide_input=True,
85
+ help="Machine API key (sk_machine_***).",
86
+ ),
87
+ insecure: bool = typer.Option(
88
+ False,
89
+ "--insecure",
90
+ help="Skip TLS certificate verification (for self-signed servers).",
91
+ ),
92
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
93
+ ) -> None:
94
+ """Authorize this machine and store sk_machine_*** locally."""
95
+ _bypass_system_proxy_by_default()
96
+ from .login import normalize_server_url, verify_and_capture_host_id
97
+ from .config import DaemonConfig
98
+
99
+ profile_name = _resolve_cli_profile(profile)
100
+
101
+ try:
102
+ server = normalize_server_url(server)
103
+ except ValueError as exc:
104
+ typer.echo(f"Error: {exc}", err=True)
105
+ raise typer.Exit(2)
106
+
107
+ if not api_key.startswith("sk_machine_"):
108
+ typer.echo("Error: api_key must start with sk_machine_", err=True)
109
+ raise typer.Exit(2)
110
+
111
+ if insecure:
112
+ typer.secho(
113
+ "⚠️ Insecure: TLS certificate verification disabled.",
114
+ fg=typer.colors.YELLOW,
115
+ err=True,
116
+ )
117
+
118
+ try:
119
+ host_id = asyncio.run(
120
+ verify_and_capture_host_id(server, api_key, insecure=insecure)
121
+ )
122
+ except Exception as exc:
123
+ typer.echo(f"Login failed: {exc}", err=True)
124
+ raise typer.Exit(1)
125
+
126
+ cfg = DaemonConfig(server_url=server, machine_api_key=api_key, host_id=host_id)
127
+ cfg.save_default(profile_name)
128
+ typer.echo(
129
+ f"Logged in. host_id={host_id} profile={profile_name}"
130
+ )
131
+
132
+
133
+ @app.command()
134
+ def start(
135
+ server_url: str | None = typer.Option(
136
+ None,
137
+ "--server-url",
138
+ help="Backend URL for one-shot mode (pair with --api-key; bypasses config.json).",
139
+ ),
140
+ api_key: str | None = typer.Option(
141
+ None,
142
+ "--api-key",
143
+ help="Machine API key for one-shot mode (pair with --server-url).",
144
+ ),
145
+ insecure: bool = typer.Option(
146
+ False,
147
+ "--insecure",
148
+ help="Skip TLS certificate verification (for self-signed servers).",
149
+ ),
150
+ transport: str = typer.Option(
151
+ "auto",
152
+ "--transport",
153
+ help=(
154
+ "Transport: 'sse' (SSE+POST, default), 'ws' (legacy WebSocket), "
155
+ "or 'auto' (alias for 'sse' in v0.2.0)."
156
+ ),
157
+ ),
158
+ workspace_root: Path | None = typer.Option(
159
+ None,
160
+ "--workspace-root",
161
+ "-w",
162
+ help=(
163
+ "Root directory for all agent workspaces. Default: "
164
+ "~/.cloudagent/agents/ (or agents-<profile>/ for named "
165
+ "profiles). The chosen path is persisted to state.json "
166
+ "so subsequent runs without the flag reuse it."
167
+ ),
168
+ ),
169
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
170
+ ) -> None:
171
+ """Connect to the server and run the daemon main loop.
172
+
173
+ Default: SSE+POST transport (works behind Clash/proxy/middleboxes).
174
+ Pass ``--transport ws`` to use the legacy WebSocket path.
175
+
176
+ Default config: loads ``~/.cloudagent/daemon/config.json`` (run
177
+ ``login`` first). One-shot: pass ``--server-url`` + ``--api-key``
178
+ together to run without persisting any config — slock-equivalent
179
+ UX for ephemeral / CI use.
180
+ """
181
+ _bypass_system_proxy_by_default()
182
+ from .config import DaemonConfig, DaemonState
183
+ from .login import normalize_server_url, verify_and_capture_host_id
184
+ from .main_loop import run_daemon
185
+ from .runtime_detect import detect_runtimes
186
+
187
+ profile_name = _resolve_cli_profile(profile)
188
+
189
+ logging.basicConfig(
190
+ level=logging.INFO,
191
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
192
+ )
193
+ # Wave C #3 — opt-in JSONL formatter (env-gated; default off
194
+ # preserves grep workflow over launchd.err.log).
195
+ from .jsonl_logging import configure_logging as _configure_jsonl_logging
196
+ _configure_jsonl_logging()
197
+
198
+ transport = (transport or "auto").lower()
199
+ if transport not in ("auto", "sse", "ws"):
200
+ typer.echo(
201
+ f"Error: --transport must be 'auto', 'sse', or 'ws' (got {transport!r})",
202
+ err=True,
203
+ )
204
+ raise typer.Exit(2)
205
+
206
+ if insecure:
207
+ typer.secho(
208
+ "⚠️ Insecure: TLS certificate verification disabled.",
209
+ fg=typer.colors.YELLOW,
210
+ err=True,
211
+ )
212
+
213
+ if server_url is not None and api_key is not None:
214
+ # One-shot mode: skip config persistence entirely.
215
+ if not api_key.startswith("sk_machine_"):
216
+ typer.echo(
217
+ "Error: --api-key must start with sk_machine_",
218
+ err=True,
219
+ )
220
+ raise typer.Exit(2)
221
+ try:
222
+ server_url = normalize_server_url(server_url)
223
+ except ValueError as exc:
224
+ typer.echo(f"Error: {exc}", err=True)
225
+ raise typer.Exit(2)
226
+ try:
227
+ host_id = asyncio.run(
228
+ verify_and_capture_host_id(
229
+ server_url, api_key, insecure=insecure
230
+ )
231
+ )
232
+ except Exception as exc:
233
+ typer.echo(
234
+ f"Failed to verify key with server: {exc}", err=True
235
+ )
236
+ raise typer.Exit(1)
237
+ cfg = DaemonConfig(
238
+ server_url=server_url,
239
+ machine_api_key=api_key,
240
+ host_id=host_id,
241
+ )
242
+ elif server_url is not None or api_key is not None:
243
+ typer.echo(
244
+ "Error: --server-url and --api-key must be used together",
245
+ err=True,
246
+ )
247
+ raise typer.Exit(2)
248
+ else:
249
+ try:
250
+ cfg = DaemonConfig.load_default(profile_name)
251
+ except FileNotFoundError:
252
+ from .paths import DEFAULT_PROFILE
253
+
254
+ extra = (
255
+ ""
256
+ if profile_name == DEFAULT_PROFILE
257
+ else f" --profile {profile_name}"
258
+ )
259
+ typer.echo(
260
+ f"Error: not logged in for profile {profile_name!r}. "
261
+ f"Run `cloudagent-daemon login{extra} --server ... "
262
+ f"--api-key sk_machine_...` first, or pass --server-url "
263
+ f"+ --api-key for one-shot mode.",
264
+ err=True,
265
+ )
266
+ raise typer.Exit(1)
267
+
268
+ state = (
269
+ DaemonState.load_default(profile_name)
270
+ if DaemonState.default_path(profile_name).exists()
271
+ else DaemonState()
272
+ )
273
+
274
+ # Task #113 (2026-05-12) — resolve --workspace-root, with fallback
275
+ # to whatever was persisted on the last start. If the user passes
276
+ # the flag we tilde-expand + resolve to abspath + mkdir(parents=
277
+ # True, exist_ok=True) and persist it back to state.json so the
278
+ # next launchctl restart picks it up without re-passing the flag.
279
+ # If the user doesn't pass the flag we honour the persisted
280
+ # value; if state.json doesn't have one either we leave it None
281
+ # and ``Workspace`` falls back to its compiled-in default.
282
+ if workspace_root is not None:
283
+ workspace_root = Path(
284
+ os.path.expanduser(str(workspace_root))
285
+ ).resolve()
286
+ workspace_root.mkdir(parents=True, exist_ok=True)
287
+ state.workspace_root = str(workspace_root)
288
+ state.save_default(profile_name)
289
+ elif state.workspace_root:
290
+ workspace_root = Path(state.workspace_root)
291
+
292
+ # Print detected runtimes for user feedback (slock-equivalent UX).
293
+ runtimes = detect_runtimes()
294
+ if runtimes:
295
+ typer.echo("Detected runtimes:")
296
+ for r in runtimes:
297
+ typer.echo(f" ✓ {r.name} {r.version} ({r.binary})")
298
+ else:
299
+ typer.echo("No AI coding runtimes detected on PATH.")
300
+ from .paths import DEFAULT_PROFILE as _DEFAULT_PROFILE
301
+
302
+ profile_suffix = (
303
+ ""
304
+ if profile_name == _DEFAULT_PROFILE
305
+ else f" [profile={profile_name}]"
306
+ )
307
+ typer.echo(
308
+ f"Connecting to {cfg.server_url} as host {cfg.host_id}{profile_suffix}..."
309
+ )
310
+ # Belt-and-suspenders flush — the plist/systemd unit also sets
311
+ # PYTHONUNBUFFERED=1, but if a future deploy ships without that env
312
+ # var the detect lines would otherwise sit in stdout's 8 KB block
313
+ # buffer forever (long-running process never exits to flush). Users
314
+ # tailing launchd.out.log would see stale or empty output and assume
315
+ # detection failed (incident 2026-05-11).
316
+ sys.stdout.flush()
317
+ sys.stderr.flush()
318
+
319
+ # Test/override hooks via environment variables (used by E2E tests to
320
+ # inject a fake claude binary and a short-path unix socket).
321
+ claude_binary: str = os.environ.get("CLOUDAGENT_DAEMON_CLAUDE_BINARY", "claude")
322
+ socket_path_override: Path | None = (
323
+ Path(os.environ["CLOUDAGENT_DAEMON_SOCKET_PATH"])
324
+ if "CLOUDAGENT_DAEMON_SOCKET_PATH" in os.environ
325
+ else None
326
+ )
327
+
328
+ from .paths import profile_pid_path, profile_lock_path
329
+
330
+ pid_path = profile_pid_path(profile_name)
331
+ pid_path.parent.mkdir(parents=True, exist_ok=True)
332
+
333
+ # M6 multi-profile (proposal §3.5) — best-effort exclusive lock so
334
+ # ``cloudagent-daemon start --profile fr`` rejects fast when launchd
335
+ # is already running an fr daemon. fcntl.flock is advisory + per-
336
+ # process-table; fine for this single-user, single-host scope.
337
+ lock_path = profile_lock_path(profile_name)
338
+ lock_fd: int | None = None
339
+ try:
340
+ import fcntl
341
+
342
+ lock_fd = os.open(
343
+ str(lock_path), os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600
344
+ )
345
+ try:
346
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
347
+ except OSError:
348
+ # Surface the holder's PID if recoverable.
349
+ existing_pid: str = ""
350
+ if pid_path.exists():
351
+ try:
352
+ existing_pid = pid_path.read_text().strip()
353
+ except OSError:
354
+ existing_pid = ""
355
+ os.close(lock_fd)
356
+ lock_fd = None
357
+ hint = (
358
+ f"running pid={existing_pid}"
359
+ if existing_pid
360
+ else "another process holds the lock"
361
+ )
362
+ typer.echo(
363
+ f"Error: profile {profile_name!r} already has a daemon "
364
+ f"running ({hint}). Stop it via "
365
+ f"`cloudagent-daemon stop "
366
+ f"{'' if profile_name == _DEFAULT_PROFILE else f'--profile {profile_name} '}`"
367
+ f"or `uninstall-service "
368
+ f"{'' if profile_name == _DEFAULT_PROFILE else f'--profile {profile_name}'}`"
369
+ f" before starting another.",
370
+ err=True,
371
+ )
372
+ raise typer.Exit(1)
373
+ except ImportError:
374
+ # fcntl missing (extremely rare on Unix; guards against weird
375
+ # static-builds). Skip locking rather than crash — single-host
376
+ # multi-profile is best-effort safety, not a hard invariant.
377
+ lock_fd = None
378
+
379
+ pid_path.write_text(str(os.getpid()))
380
+
381
+ # Pin profile in env so any subprocess we spawn (mcp_bridge, claude
382
+ # children) inherits it without needing an explicit --profile flag.
383
+ from .paths import ENV_VAR as _PROFILE_ENV_VAR
384
+
385
+ os.environ[_PROFILE_ENV_VAR] = profile_name
386
+
387
+ def _install_sigterm_handler(loop: asyncio.AbstractEventLoop) -> None:
388
+ """Convert SIGTERM into a graceful asyncio cancellation.
389
+
390
+ Without this, SIGTERM kills the process immediately (SIG_DFL) so the
391
+ ``finally: pid_path.unlink()`` block never runs. We install a handler
392
+ that cancels the main daemon task, letting asyncio unwind cleanly.
393
+ """
394
+ main_task = asyncio.current_task()
395
+ if main_task is None:
396
+ return # pragma: no cover — shouldn't happen inside asyncio.run
397
+ loop.remove_signal_handler(signal.SIGTERM)
398
+ loop.add_signal_handler(
399
+ signal.SIGTERM,
400
+ lambda: main_task.cancel("SIGTERM"),
401
+ )
402
+
403
+ # M6 — derive per-profile socket + state paths so non-default
404
+ # profiles get their own bridge.sock under
405
+ # ``~/.cloudagent/daemon/profiles/<name>/``. The env-var override
406
+ # still wins (used by E2E tests to inject a short-path socket).
407
+ from .paths import profile_socket_path, profile_state_path
408
+
409
+ effective_socket_path = (
410
+ socket_path_override
411
+ if socket_path_override is not None
412
+ else profile_socket_path(profile_name)
413
+ )
414
+ effective_state_path = profile_state_path(profile_name)
415
+
416
+ async def _start_with_sigterm() -> None:
417
+ _install_sigterm_handler(asyncio.get_running_loop())
418
+ await run_daemon(
419
+ cfg,
420
+ state,
421
+ claude_binary=claude_binary,
422
+ socket_path=effective_socket_path,
423
+ state_path=effective_state_path,
424
+ insecure=insecure,
425
+ transport=transport,
426
+ workspace_root=workspace_root,
427
+ profile=profile_name,
428
+ )
429
+
430
+ try:
431
+ asyncio.run(_start_with_sigterm())
432
+ except (KeyboardInterrupt, asyncio.CancelledError):
433
+ # Ctrl-C or SIGTERM (via cancel) are the expected shutdown paths.
434
+ pass
435
+ finally:
436
+ pid_path.unlink(missing_ok=True)
437
+ if lock_fd is not None:
438
+ try:
439
+ os.close(lock_fd)
440
+ except OSError:
441
+ pass
442
+
443
+
444
+ @app.command()
445
+ def status(
446
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
447
+ ) -> None:
448
+ """Show daemon connection status and bound agents."""
449
+ from .config import DaemonConfig
450
+ from .paths import profile_pid_path
451
+
452
+ profile_name = _resolve_cli_profile(profile)
453
+ pid_file = profile_pid_path(profile_name)
454
+ if not pid_file.exists():
455
+ typer.echo(f"● daemon not running for profile {profile_name!r} (no PID file)")
456
+ raise typer.Exit(1)
457
+ try:
458
+ pid = int(pid_file.read_text().strip())
459
+ except (ValueError, OSError):
460
+ typer.echo(f"● daemon stale PID file (corrupt) profile={profile_name!r}")
461
+ raise typer.Exit(1)
462
+ if not _pid_alive(pid):
463
+ typer.echo(
464
+ f"● daemon stale PID file (pid={pid} not alive) profile={profile_name!r}"
465
+ )
466
+ raise typer.Exit(1)
467
+ try:
468
+ cfg = DaemonConfig.load_default(profile_name)
469
+ except FileNotFoundError:
470
+ typer.echo(
471
+ f"● daemon running (pid={pid}, profile={profile_name!r}) but no config "
472
+ f"— login first"
473
+ )
474
+ return
475
+ typer.echo(f"● daemon running (pid={pid})")
476
+ typer.echo(f" profile: {profile_name}")
477
+ typer.echo(f" server: {cfg.server_url}")
478
+ typer.echo(f" host_id: {cfg.host_id}")
479
+
480
+
481
+ @app.command()
482
+ def doctor(
483
+ json_output: bool = typer.Option(
484
+ False,
485
+ "--json",
486
+ help="Emit the full report as a single JSON object instead of "
487
+ "the human-readable text rendering.",
488
+ ),
489
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
490
+ ) -> None:
491
+ """Run a daemon health self-check and print a status report.
492
+
493
+ Wave C #3. Reads ``~/.cloudagent/daemon/state.json``,
494
+ ``~/.cloudagent/agents/<id>/.meta/`` files, and scrapes the running
495
+ daemon's ``/metrics`` unix socket (best-effort — falls back to
496
+ on-disk-only signals when the daemon is down).
497
+
498
+ Exit codes:
499
+ * 0 — all checks passed
500
+ * 1 — at least one signal returned ALERT (daemon down, SSE
501
+ silent past 180s, outbox has stuck spawns, or > 20% wake
502
+ failure rate)
503
+ """
504
+ from dataclasses import asdict
505
+ import json as _json
506
+
507
+ from .doctor import collect_report, render_report
508
+
509
+ profile_name = _resolve_cli_profile(profile)
510
+ report = collect_report(profile=profile_name)
511
+ if json_output:
512
+ typer.echo(_json.dumps(asdict(report), default=str, indent=2))
513
+ else:
514
+ typer.echo(render_report(report))
515
+ alert = any(status == "ALERT" for _, status, _ in report.health_signals())
516
+ raise typer.Exit(1 if alert else 0)
517
+
518
+
519
+ @app.command()
520
+ def stop(
521
+ timeout: float = typer.Option(10.0, "--timeout", help="Seconds to wait for clean exit"),
522
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
523
+ ) -> None:
524
+ """Gracefully stop the daemon (SIGTERM via PID file)."""
525
+ from .paths import profile_pid_path
526
+
527
+ profile_name = _resolve_cli_profile(profile)
528
+ pid_file = profile_pid_path(profile_name)
529
+ if not pid_file.exists():
530
+ typer.echo(f"daemon not running for profile {profile_name!r} (no PID file)")
531
+ raise typer.Exit(0)
532
+ try:
533
+ pid = int(pid_file.read_text().strip())
534
+ except (ValueError, OSError):
535
+ typer.echo("stale PID file — removing")
536
+ pid_file.unlink(missing_ok=True)
537
+ raise typer.Exit(0)
538
+ if not _pid_alive(pid):
539
+ typer.echo(f"daemon already stopped (pid={pid} not alive); cleaning up PID file")
540
+ pid_file.unlink(missing_ok=True)
541
+ raise typer.Exit(0)
542
+ typer.echo(f"sending SIGTERM to pid={pid}...")
543
+ try:
544
+ os.kill(pid, signal.SIGTERM)
545
+ except ProcessLookupError:
546
+ pid_file.unlink(missing_ok=True)
547
+ typer.echo("process already gone")
548
+ raise typer.Exit(0)
549
+ # Wait up to `timeout` seconds for clean exit
550
+ deadline = time.monotonic() + timeout
551
+ while time.monotonic() < deadline:
552
+ if not _pid_alive(pid):
553
+ typer.echo("✓ daemon stopped cleanly")
554
+ return
555
+ time.sleep(0.1)
556
+ typer.echo(f"daemon did not exit within {timeout}s; sending SIGKILL")
557
+ try:
558
+ os.kill(pid, signal.SIGKILL)
559
+ except ProcessLookupError:
560
+ pass
561
+ raise typer.Exit(1)
562
+
563
+
564
+ def _pid_alive(pid: int) -> bool:
565
+ """Return True if a process with this PID exists.
566
+
567
+ Uses os.kill(pid, 0) which sends signal 0 — does nothing but checks
568
+ permission/existence."""
569
+ try:
570
+ os.kill(pid, 0)
571
+ except ProcessLookupError:
572
+ return False
573
+ except PermissionError:
574
+ # Process exists, owned by another user
575
+ return True
576
+ return True
577
+
578
+
579
+ @app.command("install-service")
580
+ def install_service_cmd(
581
+ server_url: str | None = typer.Option(
582
+ None,
583
+ "--server-url",
584
+ help="Persisted in service unit (else uses ~/.cloudagent/daemon/config.json).",
585
+ ),
586
+ api_key: str | None = typer.Option(
587
+ None,
588
+ "--api-key",
589
+ help="Persisted in service unit (else uses ~/.cloudagent/daemon/config.json).",
590
+ ),
591
+ insecure: bool = typer.Option(
592
+ False,
593
+ "--insecure",
594
+ help="Pass --insecure on each spawn (skip TLS verification).",
595
+ ),
596
+ workspace_root: Path | None = typer.Option(
597
+ None,
598
+ "--workspace-root",
599
+ "-w",
600
+ help=(
601
+ "Root directory for all agent workspaces, baked into the "
602
+ "service unit (default: ~/.cloudagent/agents/ or "
603
+ "~/.cloudagent/agents-<profile>/ for named profiles). "
604
+ "Tilde-expanded + resolved to an absolute path before "
605
+ "being written to the plist / systemd unit."
606
+ ),
607
+ ),
608
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
609
+ ) -> None:
610
+ """Install daemon as a launchd (macOS) or systemd (Linux) user service.
611
+
612
+ Service auto-starts at login, auto-restarts on crash. Use uninstall-service
613
+ to remove. View logs:
614
+
615
+ macOS (default profile): tail -F ~/.cloudagent/daemon/logs/launchd.{out,err}.log
616
+ macOS (named profile fr): tail -F ~/.cloudagent/daemon/profiles/fr/logs/launchd.{out,err}.log
617
+ Linux: journalctl --user -u cloudagent-daemon[-<profile>] -f
618
+ """
619
+ from .config import DaemonState
620
+ from .paths import (
621
+ DEFAULT_PROFILE,
622
+ profile_state_path,
623
+ profile_launchd_label,
624
+ profile_systemd_unit_name,
625
+ )
626
+ from .service import detect_platform, install_service
627
+
628
+ profile_name = _resolve_cli_profile(profile)
629
+
630
+ # Task #115 (2026-05-12) — same resolve sequence the ``start``
631
+ # command uses (tilde-expand → resolve → mkdir). We also persist
632
+ # to state.json so a later ``cloudagent-daemon start`` (no flag)
633
+ # picks up the same root.
634
+ resolved_workspace_root: str | None = None
635
+ if workspace_root is not None:
636
+ resolved = Path(
637
+ os.path.expanduser(str(workspace_root))
638
+ ).resolve()
639
+ resolved.mkdir(parents=True, exist_ok=True)
640
+ resolved_workspace_root = str(resolved)
641
+ state = (
642
+ DaemonState.load_default(profile_name)
643
+ if profile_state_path(profile_name).exists()
644
+ else DaemonState()
645
+ )
646
+ state.workspace_root = resolved_workspace_root
647
+ state.save_default(profile_name)
648
+
649
+ # Surface "this is an update" rather than silently overwriting an
650
+ # existing plist/unit — proposal §2.1 step 5 commercial-grade ask.
651
+ existing_label = profile_launchd_label(profile_name)
652
+ existing_unit = profile_systemd_unit_name(profile_name)
653
+ plat_now = None
654
+ try:
655
+ plat_now = detect_platform()
656
+ except RuntimeError:
657
+ pass
658
+ if plat_now == "macos":
659
+ existing_path = (
660
+ Path.home() / "Library" / "LaunchAgents" / f"{existing_label}.plist"
661
+ )
662
+ if existing_path.exists():
663
+ typer.echo(
664
+ f"(updating existing profile {profile_name!r} — plist will be replaced)"
665
+ )
666
+ elif plat_now == "linux":
667
+ existing_path = Path.home() / ".config" / "systemd" / "user" / existing_unit
668
+ if existing_path.exists():
669
+ typer.echo(
670
+ f"(updating existing profile {profile_name!r} — unit file will be replaced)"
671
+ )
672
+
673
+ try:
674
+ plat = detect_platform()
675
+ path = install_service(
676
+ server_url=server_url,
677
+ api_key=api_key,
678
+ insecure=insecure,
679
+ workspace_root=resolved_workspace_root,
680
+ profile=profile_name,
681
+ )
682
+ except RuntimeError as exc:
683
+ typer.echo(f"Error: {exc}", err=True)
684
+ raise typer.Exit(1)
685
+ typer.echo(f"✓ Installed {plat} service unit: {path}")
686
+ if profile_name != DEFAULT_PROFILE:
687
+ typer.echo(f" profile: {profile_name}")
688
+ typer.echo("Daemon will auto-restart on crash and start at login.")
689
+
690
+
691
+ @app.command("uninstall-service")
692
+ def uninstall_service_cmd(
693
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
694
+ ) -> None:
695
+ """Stop + remove the launchd / systemd service unit for *profile*."""
696
+ from .service import detect_platform, uninstall_service
697
+
698
+ profile_name = _resolve_cli_profile(profile)
699
+
700
+ try:
701
+ plat = detect_platform()
702
+ removed = uninstall_service(profile=profile_name)
703
+ except RuntimeError as exc:
704
+ typer.echo(f"Error: {exc}", err=True)
705
+ raise typer.Exit(1)
706
+ if removed:
707
+ typer.echo(f"✓ Uninstalled {plat} service (profile={profile_name!r})")
708
+ else:
709
+ typer.echo(
710
+ f"({plat} service for profile {profile_name!r} was not installed)",
711
+ err=True,
712
+ )
713
+ raise typer.Exit(1)
714
+
715
+
716
+ @app.command("list-profiles")
717
+ def list_profiles_cmd() -> None:
718
+ """List installed daemon profiles with their server URLs and live status."""
719
+ from .config import DaemonConfig
720
+ from .paths import (
721
+ list_installed_profiles,
722
+ profile_pid_path,
723
+ )
724
+
725
+ profiles = list_installed_profiles()
726
+ if not profiles:
727
+ typer.echo("No profiles installed. Run `cloudagent-daemon login ...` first.")
728
+ return
729
+
730
+ for name in profiles:
731
+ try:
732
+ cfg = DaemonConfig.load_default(name)
733
+ server = cfg.server_url
734
+ host_id = cfg.host_id
735
+ except (FileNotFoundError, ValueError, OSError) as exc:
736
+ server = f"<unreadable: {exc.__class__.__name__}>"
737
+ host_id = "?"
738
+ pid_file = profile_pid_path(name)
739
+ running = "—"
740
+ pid_str = ""
741
+ if pid_file.exists():
742
+ try:
743
+ pid = int(pid_file.read_text().strip())
744
+ except (ValueError, OSError):
745
+ running = "stale-pid"
746
+ else:
747
+ running = "running" if _pid_alive(pid) else "stopped"
748
+ pid_str = f" pid={pid}"
749
+ typer.echo(f"● {name}")
750
+ typer.echo(f" server: {server}")
751
+ typer.echo(f" host_id: {host_id}")
752
+ typer.echo(f" status: {running}{pid_str}")
753
+
754
+
755
+ @app.command("mcp-bridge", hidden=True)
756
+ def mcp_bridge(
757
+ agent_id: str = typer.Option(..., "--agent-id", help="Agent ID this bridge serves."),
758
+ token_file: Path | None = typer.Option(
759
+ None,
760
+ "--token-file",
761
+ help=(
762
+ "Path to per-spawn token file (Phase 1: optional; tolerated "
763
+ "when missing — see 1i Bug B). Will be enforced once the "
764
+ "spec §6.6 per-spawn token scheme is fully wired."
765
+ ),
766
+ ),
767
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
768
+ ) -> None:
769
+ """[internal] stdio MCP server invoked by claude subprocess."""
770
+ from .mcp_bridge import run_bridge
771
+
772
+ profile_name = _resolve_cli_profile(profile)
773
+ asyncio.run(
774
+ run_bridge(agent_id=agent_id, token_file=token_file, profile=profile_name)
775
+ )
776
+
777
+
778
+ @agent_app.command("register")
779
+ def agent_register(
780
+ agent_id: str = typer.Option(..., "--agent-id", help="Agent ID already bound on the server."),
781
+ role: str = typer.Option("staff", "--role", help="Agent role (staff or chief)."),
782
+ is_chief: bool = typer.Option(False, "--chief", help="Mark agent as chief."),
783
+ name: str | None = typer.Option(None, "--name", help="Human-readable agent name."),
784
+ workspace: str | None = typer.Option(None, "--workspace-slug", help="Workspace slug."),
785
+ reports_to: str | None = typer.Option(None, "--reports-to", help="Superior agent ID."),
786
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
787
+ ) -> None:
788
+ """Create local workspace for an agent already bound on server."""
789
+ from .workspace import Workspace
790
+
791
+ from .paths import DEFAULT_PROFILE
792
+
793
+ profile_name = _resolve_cli_profile(profile)
794
+ identity = {
795
+ "agent_id": agent_id,
796
+ "role": role,
797
+ "is_chief": is_chief,
798
+ "agent_name": name or agent_id,
799
+ "workspace_slug": workspace or "default",
800
+ "reports_to": reports_to or ("self" if is_chief else "(unset)"),
801
+ }
802
+ # Backward-compat: default profile uses the old single-arg ctor so
803
+ # tests / downstream code that monkeypatches ``Workspace`` with a
804
+ # narrower stub keep working byte-for-byte.
805
+ ws_obj = (
806
+ Workspace()
807
+ if profile_name == DEFAULT_PROFILE
808
+ else Workspace(profile=profile_name)
809
+ )
810
+ ws = ws_obj.ensure(agent_id, identity)
811
+ suffix = "" if profile_name == DEFAULT_PROFILE else f" (profile={profile_name!r})"
812
+ typer.echo(f"✓ Registered {agent_id} at {ws.path}{suffix}")
813
+
814
+
815
+ @agent_app.command("unregister")
816
+ def agent_unregister(
817
+ agent_id: str = typer.Option(..., "--agent-id", help="Agent ID to remove locally."),
818
+ profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
819
+ ) -> None:
820
+ """Mark local workspace as archived (30-day retention)."""
821
+ from .workspace import Workspace
822
+
823
+ from .paths import DEFAULT_PROFILE
824
+
825
+ profile_name = _resolve_cli_profile(profile)
826
+ ws_obj = (
827
+ Workspace()
828
+ if profile_name == DEFAULT_PROFILE
829
+ else Workspace(profile=profile_name)
830
+ )
831
+ ws_obj.archive(agent_id)
832
+ suffix = "" if profile_name == DEFAULT_PROFILE else f" (profile={profile_name!r})"
833
+ typer.echo(f"✓ Archived {agent_id}{suffix}")