devops-bot-sdk 1.4.145__tar.gz → 1.4.147__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/__init__.py +2 -2
  4. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/inventory.py +2 -2
  5. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/local_exec.py +76 -2
  6. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/service.py +200 -0
  7. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/setup_manager.py +14 -0
  8. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/updater.py +36 -1
  9. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/README.md +0 -0
  10. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  11. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  12. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  13. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/devops_bot_sdk.egg-info/requires.txt +0 -0
  14. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  15. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/pyproject.toml +0 -0
  16. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agentd.py +0 -0
  17. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/__init__.py +0 -0
  18. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/builder.py +0 -0
  19. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/chat.py +0 -0
  20. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/connectors.py +0 -0
  21. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/engines/__init__.py +0 -0
  22. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/engines/claude_code.py +0 -0
  23. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/engines/http.py +0 -0
  24. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/engines/mcp.py +0 -0
  25. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/engines/n8n.py +0 -0
  26. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/engines/shell.py +0 -0
  27. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/integrations.py +0 -0
  28. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/library/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/manifest.py +0 -0
  30. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/prompt_studio.py +0 -0
  31. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/prompt_template.py +0 -0
  32. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/prompt_vars.py +0 -0
  33. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/run_log.py +0 -0
  34. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/secrets.py +0 -0
  35. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/usage.py +0 -0
  36. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/agents/varstore.py +0 -0
  37. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/cli.py +0 -0
  38. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/client.py +0 -0
  39. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/collectors/__init__.py +0 -0
  40. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/collectors/files.py +0 -0
  41. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/collectors/process.py +0 -0
  42. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/collectors/screenshot.py +0 -0
  43. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/config.py +0 -0
  44. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/crucial.py +0 -0
  45. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/design_verify/__init__.py +0 -0
  46. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/design_verify/assets.py +0 -0
  47. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/design_verify/bootstrap.py +0 -0
  48. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/design_verify/browser.py +0 -0
  49. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/design_verify/compare.py +0 -0
  50. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/design_verify/loop.py +0 -0
  51. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/exceptions.py +0 -0
  52. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/git_ops.py +0 -0
  53. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/graphify.py +0 -0
  54. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/hooks/__init__.py +0 -0
  55. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/hooks/crucial_guard.py +0 -0
  56. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ipc/__init__.py +0 -0
  57. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ipc/electron_bridge.py +0 -0
  58. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ipc/handlers.py +0 -0
  59. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/models/__init__.py +0 -0
  60. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/models/envelope.py +0 -0
  61. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/models/requests.py +0 -0
  62. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/models/responses.py +0 -0
  63. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/models/snapshots.py +0 -0
  64. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/platform_compat.py +0 -0
  65. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/py.typed +0 -0
  66. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/run_auto.py +0 -0
  67. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/sse.py +0 -0
  68. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/test.py +0 -0
  69. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/test_pipeline.py +0 -0
  70. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/__init__.py +0 -0
  71. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/api.py +0 -0
  72. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/dashboard.py +0 -0
  73. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/health.py +0 -0
  74. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/ip_allowlist.py +0 -0
  75. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/jobs.py +0 -0
  76. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/models.py +0 -0
  77. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/runner.py +0 -0
  78. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/security.py +0 -0
  79. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/server.py +0 -0
  80. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/agents.html +0 -0
  81. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/app.js +0 -0
  82. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/custom-agents.html +0 -0
  83. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/default-agents.html +0 -0
  84. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/index.html +0 -0
  85. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/runs.html +0 -0
  86. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/setup.html +0 -0
  87. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/styles.css +0 -0
  88. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/sdk/ui/static/tickets.html +0 -0
  89. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/setup.cfg +0 -0
  90. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/tests/test_agent_chat_features.py +0 -0
  91. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/tests/test_agent_prompt_models.py +0 -0
  92. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/tests/test_agents_connectors_builder.py +0 -0
  93. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/tests/test_git_action.py +0 -0
  94. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/tests/test_health.py +0 -0
  95. {devops_bot_sdk-1.4.145 → devops_bot_sdk-1.4.147}/tests/test_updater_extras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.145
3
+ Version: 1.4.147
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.145
3
+ Version: 1.4.147
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.145
3
+ Version: 1.4.147
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.145" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.147" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
34
34
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
35
35
 
36
36
  from sdk.client import BackendClient
@@ -11,7 +11,6 @@ from __future__ import annotations
11
11
 
12
12
  import json
13
13
  import logging
14
- import shutil
15
14
  import subprocess
16
15
  from pathlib import Path
17
16
 
@@ -70,7 +69,8 @@ def _connectors() -> list[dict]:
70
69
 
71
70
  def _mcp_servers() -> list[dict]:
72
71
  """Installed MCP servers / Claude plugins, parsed from ``claude mcp list``."""
73
- cli = shutil.which("claude")
72
+ from sdk.local_exec import claude_cli_path
73
+ cli = claude_cli_path()
74
74
  if not cli:
75
75
  return []
76
76
  try:
@@ -22,6 +22,7 @@ import json
22
22
  import os
23
23
  import re
24
24
  import shutil
25
+ import subprocess
25
26
  import sys
26
27
  from datetime import datetime, timedelta
27
28
  from pathlib import Path
@@ -240,7 +241,21 @@ EventHandler = Callable[[dict], Awaitable[None]]
240
241
 
241
242
 
242
243
  def claude_cli_path() -> str | None:
243
- """Absolute path to the `claude` CLI if installed on PATH, else None."""
244
+ """Absolute path to the `claude` CLI, or None if it isn't on PATH.
245
+
246
+ Resolution order:
247
+ 1. ``AGENTOS_CLAUDE_CLI`` env var — explicit path override (escape hatch for
248
+ an install in a non-standard location).
249
+ 2. ``PATH`` (``shutil.which``) — cross-platform, no guessing.
250
+
251
+ The service must therefore RUN with a PATH that includes the CLI. The install
252
+ step bakes that in (see ``sdk.ui.service`` — systemd ``Environment=PATH`` /
253
+ launchd ``EnvironmentVariables``; Windows tasks inherit the user's PATH), so
254
+ ``engine=claude_code`` works without anyone editing a unit file.
255
+ """
256
+ override = os.environ.get("AGENTOS_CLAUDE_CLI", "").strip()
257
+ if override and Path(override).exists():
258
+ return override
244
259
  return shutil.which("claude")
245
260
 
246
261
 
@@ -249,6 +264,54 @@ def is_available() -> bool:
249
264
  return claude_cli_path() is not None
250
265
 
251
266
 
267
+ def claude_status(timeout: float = 15.0) -> dict:
268
+ """Readiness of the local Claude Code CLI: installed AND logged in.
269
+
270
+ Runs ``claude auth status`` (which prints structured JSON) so the panel /
271
+ health surface can show a non-technical operator one clear signal — "connected
272
+ as you@org" vs "not logged in" — instead of a raw CLI error surfacing only when
273
+ a job runs. All failures degrade to ``logged_in: False`` with an ``error`` note;
274
+ this never raises.
275
+
276
+ Returns::
277
+
278
+ {"installed": bool, "logged_in": bool, "path": str|None,
279
+ "auth_method": str|None, "email": str|None, "org": str|None,
280
+ "error": str|None}
281
+ """
282
+ cli = claude_cli_path()
283
+ if not cli:
284
+ return {"installed": False, "logged_in": False, "path": None,
285
+ "auth_method": None, "email": None, "org": None,
286
+ "error": "Claude Code CLI not found. Install it or set AGENTOS_CLAUDE_CLI."}
287
+ try:
288
+ proc = subprocess.run([cli, "auth", "status"], capture_output=True,
289
+ text=True, timeout=timeout)
290
+ except Exception as exc: # noqa: BLE001 — timeout / OSError; never raise to caller
291
+ return {"installed": True, "logged_in": False, "path": cli, "auth_method": None,
292
+ "email": None, "org": None, "error": f"`claude auth status` failed: {exc}"}
293
+
294
+ out = (proc.stdout or "").strip()
295
+ try:
296
+ data = json.loads(out)
297
+ except Exception: # noqa: BLE001 — older CLIs may print human text, not JSON
298
+ logged = "logged in" in out.lower()
299
+ return {"installed": True, "logged_in": logged, "path": cli, "auth_method": None,
300
+ "email": None, "org": None,
301
+ "error": None if logged else ((proc.stderr or out or "not logged in").strip()[:200])}
302
+
303
+ logged = bool(data.get("loggedIn"))
304
+ return {
305
+ "installed": True,
306
+ "logged_in": logged,
307
+ "path": cli,
308
+ "auth_method": data.get("authMethod"),
309
+ "email": data.get("email"),
310
+ "org": data.get("orgName") or data.get("orgId"),
311
+ "error": None if logged else "Not logged in — run `claude` on the VPS to sign in.",
312
+ }
313
+
314
+
252
315
  # Cache the discovered MCP allow-entries for the process lifetime — the set of
253
316
  # configured servers doesn't change mid-run, and `claude mcp list` does a network
254
317
  # health check (~seconds) we don't want to repeat per ticket.
@@ -396,7 +459,9 @@ async def run_claude_local(
396
459
  cli = claude_cli_path()
397
460
  if not cli:
398
461
  return {
399
- "ok": False, "result": "", "error": "claude CLI not found on PATH",
462
+ "ok": False, "result": "",
463
+ "error": ("Claude Code CLI not found. Install it, or if it is installed "
464
+ "in a non-standard location set AGENTOS_CLAUDE_CLI to its full path."),
400
465
  "input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
401
466
  "cost_usd": 0.0, "files_changed": [],
402
467
  }
@@ -461,6 +526,15 @@ async def run_claude_local(
461
526
  # the user's global git config. Tokens stay in the subprocess env only.
462
527
  env = dict(os.environ)
463
528
 
529
+ # Make sure the CLI's own directory is on the subprocess PATH: node/gh and the
530
+ # CLI's sibling tools usually live alongside it, and under a minimal service
531
+ # PATH they'd otherwise be missing. Prepend the resolved binary's dir only —
532
+ # no guessing beyond that — so anything the operator exported still wins.
533
+ _cli_dir = str(Path(cli).resolve().parent)
534
+ _cur_path = env.get("PATH", "")
535
+ if _cli_dir not in _cur_path.split(os.pathsep):
536
+ env["PATH"] = _cli_dir + (os.pathsep + _cur_path if _cur_path else "")
537
+
464
538
  # A custom agent's persisted API keys (sdk.agents.secrets) are overlaid here
465
539
  # so they reach the subprocess (and any MCP servers it spawns) WITHOUT ever
466
540
  # touching the ambient os.environ of this process — same "subprocess env only"
@@ -17,6 +17,7 @@ Everything is best-effort and returns ``(ok, message)`` so the caller (the
17
17
  from __future__ import annotations
18
18
 
19
19
  import os
20
+ import re
20
21
  import shutil
21
22
  import subprocess
22
23
  import sys
@@ -51,6 +52,30 @@ def _systemd_available() -> bool:
51
52
  return bool(shutil.which("systemctl")) and Path("/run/systemd/system").exists()
52
53
 
53
54
 
55
+ def _service_path_env() -> str:
56
+ """PATH for the service environment.
57
+
58
+ A service (systemd/launchd) runs with a minimal PATH that omits ~/.local/bin,
59
+ nvm/Homebrew, etc. — exactly where the ``claude`` CLI and its node/git/gh
60
+ runtime live. Prepend the resolved CLI's own directory and ~/.local/bin to the
61
+ standard system dirs so ``engine=claude_code`` works with no manual PATH edits.
62
+ """
63
+ base = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"]
64
+ extra = [str(Path.home() / ".local" / "bin")]
65
+ try:
66
+ from sdk.local_exec import claude_cli_path
67
+ cli = claude_cli_path()
68
+ if cli:
69
+ extra.append(str(Path(cli).resolve().parent))
70
+ except Exception: # noqa: BLE001 — discovery must never break install
71
+ pass
72
+ ordered: list[str] = []
73
+ for d in extra + base:
74
+ if d and d not in ordered:
75
+ ordered.append(d)
76
+ return ":".join(ordered)
77
+
78
+
54
79
  def _unit_text(host: str, port: int, home: str) -> str:
55
80
  exec_line = f"{_exe()} --host {host} --port {port} --no-browser"
56
81
  return (
@@ -64,6 +89,7 @@ def _unit_text(host: str, port: int, home: str) -> str:
64
89
  "Restart=always\n"
65
90
  "RestartSec=5\n"
66
91
  f"Environment=HOME={home}\n"
92
+ f"Environment=PATH={_service_path_env()}\n"
67
93
  f"WorkingDirectory={home}\n\n"
68
94
  "[Install]\n"
69
95
  f"WantedBy={{target}}\n"
@@ -145,6 +171,9 @@ def _install_launchd(host: str, port: int) -> tuple[bool, str]:
145
171
  f' <string>{exe}</string><string>--host</string><string>{host}</string>\n'
146
172
  f' <string>--port</string><string>{port}</string><string>--no-browser</string>\n'
147
173
  ' </array>\n'
174
+ ' <key>EnvironmentVariables</key><dict>\n'
175
+ f' <key>PATH</key><string>{_service_path_env()}</string>\n'
176
+ ' </dict>\n'
148
177
  ' <key>RunAtLoad</key><true/>\n'
149
178
  ' <key>KeepAlive</key><true/>\n'
150
179
  '</dict></plist>\n'
@@ -257,3 +286,174 @@ def restart() -> tuple[bool, str]:
257
286
  return (rc == 0), (f"restarted systemd service '{SERVICE_NAME}'." if rc == 0
258
287
  else out or f"systemctl restart {SERVICE_NAME} failed")
259
288
  return False, "no supported service manager found — restart the panel manually (`agentos-ui`)."
289
+
290
+
291
+ def _systemd_unit_candidates() -> list[Path]:
292
+ """Both places a systemd unit can live, system scope first."""
293
+ return [
294
+ Path("/etc/systemd/system") / f"{SERVICE_NAME}.service",
295
+ Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service",
296
+ ]
297
+
298
+
299
+ def _existing_systemd_unit() -> Path | None:
300
+ """The installed unit file, checking BOTH system and user scope regardless of
301
+ the current privilege — so a non-root ``agentos-auto update`` still finds a
302
+ system service installed via ``sudo agentos-ui install`` (and vice-versa)."""
303
+ for p in _systemd_unit_candidates():
304
+ try:
305
+ if p.exists():
306
+ return p
307
+ except OSError:
308
+ continue
309
+ return None
310
+
311
+
312
+ def _service_definition_path() -> Path:
313
+ """On-disk service definition — an EXISTING unit if there is one (either scope),
314
+ else the path we would CREATE at the current privilege."""
315
+ if IS_WINDOWS: # schtasks has no file; handled separately
316
+ return Path()
317
+ if IS_MAC:
318
+ return _mac_plist_path()
319
+ existing = _existing_systemd_unit()
320
+ if existing:
321
+ return existing
322
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
323
+ base = Path("/etc/systemd/system") if is_root else Path.home() / ".config" / "systemd" / "user"
324
+ return base / f"{SERVICE_NAME}.service"
325
+
326
+
327
+ def _needs_root_for_existing() -> bool:
328
+ """True when a SYSTEM unit is installed but we're not root — so we must not
329
+ silently create a conflicting *user* unit; the caller should ask for sudo."""
330
+ if IS_WINDOWS or IS_MAC:
331
+ return False
332
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
333
+ existing = _existing_systemd_unit()
334
+ return (existing is not None
335
+ and existing.parent == Path("/etc/systemd/system")
336
+ and not is_root)
337
+
338
+
339
+ def is_installed() -> bool:
340
+ """True when a managed agentos-ui service/unit exists (any platform, any scope).
341
+
342
+ Best-effort and never raises — used by the updater to decide whether an upgrade
343
+ should create, regenerate, or restart the service.
344
+ """
345
+ try:
346
+ if IS_WINDOWS:
347
+ rc, _ = _run(["schtasks", "/Query", "/TN", WIN_TASK])
348
+ return rc == 0
349
+ if IS_MAC:
350
+ return _mac_plist_path().exists()
351
+ return _existing_systemd_unit() is not None
352
+ except Exception: # noqa: BLE001
353
+ return False
354
+
355
+
356
+ def _is_active() -> bool:
357
+ """True when the managed service is currently running (read-only query)."""
358
+ try:
359
+ if IS_WINDOWS:
360
+ _, out = _run(["schtasks", "/Query", "/TN", WIN_TASK, "/FO", "LIST", "/V"])
361
+ return "Running" in out
362
+ if IS_MAC:
363
+ rc, out = _run(["launchctl", "list", _MAC_LABEL])
364
+ return rc == 0 and bool(out.strip())
365
+ existing = _existing_systemd_unit()
366
+ user = existing is not None and existing.parent.name == "user"
367
+ sysctl = ["systemctl", "--user"] if user else ["systemctl"]
368
+ _, out = _run([*sysctl, "is-active", SERVICE_NAME])
369
+ return out.strip() == "active"
370
+ except Exception: # noqa: BLE001
371
+ return False
372
+
373
+
374
+ def _installed_host_port() -> tuple[str, int]:
375
+ """Read the host/port the service was installed with, so a refresh preserves
376
+ them instead of resetting to the install() defaults. Falls back to 0.0.0.0:8765.
377
+ """
378
+ host, port = "0.0.0.0", 8765
379
+ text = ""
380
+ try:
381
+ if IS_WINDOWS:
382
+ _, text = _run(["schtasks", "/Query", "/TN", WIN_TASK, "/V", "/FO", "LIST"])
383
+ else:
384
+ p = _service_definition_path()
385
+ text = p.read_text() if p and p.exists() else ""
386
+ except Exception: # noqa: BLE001
387
+ text = ""
388
+ clean = re.sub(r"<[^>]+>", " ", text) # flatten plist <string> tags to spaces
389
+ m = re.search(r"--host\s+(\S+)", clean)
390
+ if m:
391
+ host = m.group(1).strip().strip('"')
392
+ m = re.search(r"--port\s+(\d+)", clean)
393
+ if m:
394
+ port = int(m.group(1))
395
+ return host, port
396
+
397
+
398
+ def refresh() -> tuple[bool, str]:
399
+ """Regenerate the service definition and restart it after an SDK upgrade.
400
+
401
+ ``agentos-auto update`` only pip-upgrades the package; the on-disk unit and the
402
+ running process are untouched. So a change to the generated unit (e.g. the
403
+ baked-in PATH that lets the service find the ``claude`` CLI) or to the code
404
+ never takes effect until this runs. Regenerating rewrites the unit with the
405
+ upgraded SDK's definition (preserving the installed host/port), and the restart
406
+ both applies the new unit and loads the new code.
407
+
408
+ No-op returning ``(False, msg)`` when no managed service is installed, so a
409
+ manually-launched panel is left alone.
410
+ """
411
+ if not is_installed():
412
+ return False, "no managed service installed — nothing to refresh."
413
+ if _needs_root_for_existing():
414
+ return False, ("the panel runs as a system service — re-run with sudo "
415
+ "(`sudo agentos-ui install`) to apply the update.")
416
+ host, port = _installed_host_port()
417
+ ok, msg = install(host, port) # rewrites unit/plist/task + daemon-reload
418
+ if not ok:
419
+ return False, f"could not regenerate service: {msg}"
420
+ # `enable --now` / launchctl load won't restart an ALREADY-running unit, so
421
+ # force a restart to apply the regenerated definition and load the new code.
422
+ rok, rmsg = restart()
423
+ return (True, f"service refreshed ({host}:{port}); {rmsg}" if rok
424
+ else f"service definition regenerated ({host}:{port}) but restart needs attention: {rmsg}")
425
+
426
+
427
+ def _service_disabled() -> bool:
428
+ return os.environ.get("AGENTOS_NO_SERVICE", "").strip().lower() in ("1", "true", "yes", "on")
429
+
430
+
431
+ def ensure(restart_running: bool = False) -> tuple[bool, str]:
432
+ """Make sure the panel service EXISTS and is running the current SDK.
433
+
434
+ This is what lets ``agentos-auto update`` yield a working 24/7 service without a
435
+ separate ``agentos-ui install`` step:
436
+
437
+ * not installed → create it (``install``)
438
+ * installed + code changed → ``refresh`` (regenerate unit + restart)
439
+ (pass ``restart_running=True`` after a real upgrade)
440
+ * installed + unchanged + running → leave it alone
441
+ * installed + unchanged + stopped → start it
442
+
443
+ Best-effort; opt out entirely with ``AGENTOS_NO_SERVICE=1`` (e.g. a box that
444
+ runs the panel manually or only uses the SDK as a library).
445
+ """
446
+ if _service_disabled():
447
+ return False, "AGENTOS_NO_SERVICE set — leaving the service unmanaged."
448
+ if not is_installed():
449
+ ok, msg = install() # create at the current privilege
450
+ return ok, (f"service created — {msg}" if ok else f"could not create service: {msg}")
451
+ if _needs_root_for_existing():
452
+ return False, ("a system service is installed — run `sudo agentos-ui install` "
453
+ "to apply updates and manage it.")
454
+ if restart_running:
455
+ return refresh()
456
+ if _is_active():
457
+ return True, "service already running."
458
+ rok, rmsg = restart() # installed but down → bring it back up
459
+ return rok, (f"service was down — started; {rmsg}" if rok else rmsg)
@@ -64,6 +64,19 @@ def sdk_version() -> str:
64
64
  return "unknown"
65
65
 
66
66
 
67
+ def claude_readiness() -> dict:
68
+ """Claude Code CLI readiness (installed + logged in) for the /status surface.
69
+
70
+ Guarded: any failure degrades to a not-ready dict rather than breaking /status.
71
+ """
72
+ try:
73
+ from sdk.local_exec import claude_status
74
+ return claude_status()
75
+ except Exception as exc: # noqa: BLE001
76
+ return {"installed": False, "logged_in": False, "path": None,
77
+ "auth_method": None, "email": None, "org": None, "error": str(exc)}
78
+
79
+
67
80
  def environment_info() -> dict:
68
81
  """Env snapshot for the /status endpoint and setup wizard."""
69
82
  vp = running_venv()
@@ -78,6 +91,7 @@ def environment_info() -> dict:
78
91
  "expected_agentsystem_root": str(root),
79
92
  "agentsystem_exists": root.exists(),
80
93
  "sdk_version": sdk_version(),
94
+ "claude": claude_readiness(),
81
95
  }
82
96
 
83
97
 
@@ -273,6 +273,37 @@ def self_update() -> bool:
273
273
  return False
274
274
 
275
275
 
276
+ def _ensure_service(quiet: bool = False, *, restart: bool = False) -> None:
277
+ """Ensure the panel service exists and runs the current SDK, after an update check.
278
+
279
+ Without this, ``agentos-auto update`` pip-upgrades the code but leaves the old
280
+ systemd unit / launchd plist and the old running process in place — so unit
281
+ changes (e.g. the baked-in PATH that lets the service find ``claude``) and new
282
+ code silently never take effect, and a box that never ran ``agentos-ui install``
283
+ has no service at all. ``service.ensure`` creates it when missing, refreshes it
284
+ after a real upgrade (``restart=True``), and starts it if it's down — so a plain
285
+ ``agentos-auto update`` reliably leaves ``systemctl status agentos-ui`` running.
286
+
287
+ Best-effort and never raises. Silent on a box without the ``[ui]`` extra (no
288
+ panel to manage) and opt-out via ``AGENTOS_NO_SERVICE=1``.
289
+
290
+ Safe here because ``auto_update`` runs from the short-lived ``agentos-auto``
291
+ process, not inside the long-running panel — restarting the panel can't kill
292
+ the updater mid-run.
293
+ """
294
+ try:
295
+ from sdk.ui import service
296
+ except Exception: # noqa: BLE001 — no [ui] extra installed → no panel service
297
+ return
298
+ try:
299
+ ok, msg = service.ensure(restart_running=restart)
300
+ if not quiet and msg:
301
+ print(f"↻ {msg}" if ok else f"(service: {msg})")
302
+ except Exception as exc: # noqa: BLE001 — an update must never fail on the service step
303
+ if not quiet:
304
+ print(f"(service check failed: {exc})")
305
+
306
+
276
307
  def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
277
308
  """Check for a newer version and upgrade if one exists.
278
309
 
@@ -292,13 +323,16 @@ def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
292
323
  if not is_github:
293
324
  latest = latest_pypi_version()
294
325
  if not latest or _vtuple(latest) <= _vtuple(cur):
326
+ _ensure_service(quiet, restart=False) # already current — just self-heal the service
295
327
  return (False, cur, latest)
296
328
  if not quiet:
297
329
  print(f"updating {_DIST} {cur} → {latest} …")
298
330
  ok = self_update()
299
331
  if not quiet and ok:
300
332
  print("✓ updated.")
301
- # silent on failure — continue on the existing version without noise
333
+ # Ensure the service exists + runs the new code (restart only on a real
334
+ # upgrade); also self-heals a missing/down service when already current.
335
+ _ensure_service(quiet, restart=ok)
302
336
  return (ok, cur, latest if ok else None)
303
337
 
304
338
  # GitHub path
@@ -313,6 +347,7 @@ def auto_update(quiet: bool = False) -> tuple[bool, str, str | None]:
313
347
  elif ok:
314
348
  print(f"✓ already up to date (v{cur}).")
315
349
  # silent on failure — continue on the existing version without noise
350
+ _ensure_service(quiet, restart=updated)
316
351
  return (updated, cur, after if updated else None)
317
352
 
318
353