observal-cli 0.2.0__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.
Files changed (44) hide show
  1. observal_cli/README.md +150 -0
  2. observal_cli/__init__.py +0 -0
  3. observal_cli/analyzer.py +565 -0
  4. observal_cli/branding.py +19 -0
  5. observal_cli/client.py +264 -0
  6. observal_cli/cmd_agent.py +783 -0
  7. observal_cli/cmd_auth.py +823 -0
  8. observal_cli/cmd_doctor.py +674 -0
  9. observal_cli/cmd_hook.py +246 -0
  10. observal_cli/cmd_mcp.py +1044 -0
  11. observal_cli/cmd_migrate.py +764 -0
  12. observal_cli/cmd_ops.py +1250 -0
  13. observal_cli/cmd_profile.py +308 -0
  14. observal_cli/cmd_prompt.py +200 -0
  15. observal_cli/cmd_pull.py +324 -0
  16. observal_cli/cmd_sandbox.py +178 -0
  17. observal_cli/cmd_scan.py +1056 -0
  18. observal_cli/cmd_skill.py +202 -0
  19. observal_cli/cmd_uninstall.py +340 -0
  20. observal_cli/config.py +160 -0
  21. observal_cli/constants.py +151 -0
  22. observal_cli/hooks/__init__.py +0 -0
  23. observal_cli/hooks/buffer_event.py +97 -0
  24. observal_cli/hooks/flush_buffer.py +141 -0
  25. observal_cli/hooks/kiro_hook.py +210 -0
  26. observal_cli/hooks/kiro_stop_hook.py +220 -0
  27. observal_cli/hooks/observal-hook.sh +31 -0
  28. observal_cli/hooks/observal-stop-hook.sh +134 -0
  29. observal_cli/hooks/payload_crypto.py +78 -0
  30. observal_cli/hooks_spec.py +154 -0
  31. observal_cli/main.py +105 -0
  32. observal_cli/prompts.py +92 -0
  33. observal_cli/proxy.py +205 -0
  34. observal_cli/render.py +139 -0
  35. observal_cli/requirements.txt +3 -0
  36. observal_cli/sandbox_runner.py +217 -0
  37. observal_cli/settings_reconciler.py +188 -0
  38. observal_cli/shim.py +459 -0
  39. observal_cli/telemetry_buffer.py +163 -0
  40. observal_cli-0.2.0.dist-info/METADATA +528 -0
  41. observal_cli-0.2.0.dist-info/RECORD +44 -0
  42. observal_cli-0.2.0.dist-info/WHEEL +4 -0
  43. observal_cli-0.2.0.dist-info/entry_points.txt +5 -0
  44. observal_cli-0.2.0.dist-info/licenses/LICENSE +108 -0
observal_cli/client.py ADDED
@@ -0,0 +1,264 @@
1
+ import logging
2
+ import time
3
+ from urllib.parse import urlparse, urlunparse
4
+
5
+ import httpx
6
+ import typer
7
+ from rich import print as rprint
8
+ from rich.console import Console
9
+
10
+ from observal_cli import config
11
+
12
+ console = Console(stderr=True)
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _client() -> tuple[str, dict]:
17
+ cfg = config.get_or_exit()
18
+ return cfg["server_url"].rstrip("/"), {"Authorization": f"Bearer {cfg['access_token']}"}
19
+
20
+
21
+ def _handle_error(e: httpx.HTTPStatusError, path: str = ""):
22
+ """Handle HTTP errors with actionable messages."""
23
+ ct = e.response.headers.get("content-type", "")
24
+ if "application/json" in ct:
25
+ try:
26
+ detail = e.response.json().get("detail", e.response.text)
27
+ except (ValueError, UnicodeDecodeError):
28
+ detail = e.response.text
29
+ else:
30
+ detail = e.response.text
31
+ code = e.response.status_code
32
+
33
+ path_info = f" ({path})" if path else ""
34
+
35
+ if code == 401:
36
+ rprint(f"[red]Authentication failed{path_info}.[/red]")
37
+ rprint("[dim] Run [bold]observal auth login[/bold] to re-authenticate.[/dim]")
38
+ elif code == 403:
39
+ rprint(f"[red]Permission denied{path_info}.[/red]")
40
+ rprint("[dim] This action requires a higher role (admin or super_admin).[/dim]")
41
+ elif code == 404:
42
+ rprint(f"[red]Not found{path_info}.[/red]")
43
+ # Extract component type from API path (e.g. /api/v1/hooks/abc -> hook)
44
+ parts = path.strip("/").split("/")
45
+ type_plural = parts[2] if len(parts) > 2 else "mcps"
46
+ if type_plural.endswith("xes"):
47
+ type_singular = type_plural[:-2] # sandboxes -> sandbox
48
+ elif type_plural.endswith("s"):
49
+ type_singular = type_plural[:-1] # mcps -> mcp, skills -> skill
50
+ else:
51
+ type_singular = type_plural
52
+ # 'agent' is a top-level subcommand, not nested under 'registry'
53
+ browse_cmd = "observal agent list" if type_singular == "agent" else f"observal registry {type_singular} list"
54
+ rprint(f"[dim] Check that the resource ID is correct, or use [bold]{browse_cmd}[/bold] to browse.[/dim]")
55
+ elif code == 429:
56
+ rprint(f"[red]Rate limited{path_info}.[/red]")
57
+ retry_after = e.response.headers.get("Retry-After", "a few seconds")
58
+ rprint(f"[dim] Try again in {retry_after}.[/dim]")
59
+ elif code >= 500:
60
+ rprint(f"[red]Server error {code}{path_info}.[/red]")
61
+ rprint("[dim] Check server logs or run [bold]observal doctor[/bold] for diagnostics.[/dim]")
62
+ else:
63
+ rprint(f"[red]Error {code}{path_info}:[/red] {detail}")
64
+
65
+ raise typer.Exit(code=1)
66
+
67
+
68
+ def _handle_connect():
69
+ """Handle connection errors."""
70
+ cfg = config.load()
71
+ server_url = cfg.get("server_url", "not set")
72
+ rprint("[red]Connection failed.[/red] Cannot reach the Observal server.")
73
+ rprint(f"[dim] Server URL: {server_url}[/dim]")
74
+ rprint("[dim] Is the server running? Try [bold]observal doctor[/bold] to diagnose.[/dim]")
75
+ raise typer.Exit(code=1)
76
+
77
+
78
+ def _handle_timeout(path: str = ""):
79
+ """Handle request timeout."""
80
+ timeout = config.get_timeout()
81
+ path_info = f" ({path})" if path else ""
82
+ rprint(f"[red]Request timed out{path_info}.[/red]")
83
+ rprint(f"[dim] Timeout: {timeout}s. Increase with [bold]OBSERVAL_TIMEOUT[/bold] env var or config.[/dim]")
84
+ rprint("[dim] Check server health with [bold]observal doctor[/bold].[/dim]")
85
+ raise typer.Exit(code=1)
86
+
87
+
88
+ def _try_refresh_token() -> bool:
89
+ """Attempt to refresh the access token using the stored refresh token.
90
+
91
+ Returns True if the refresh succeeded and config was updated.
92
+ """
93
+ cfg = config.load()
94
+ refresh_token = cfg.get("refresh_token")
95
+ server_url = cfg.get("server_url", "").rstrip("/")
96
+ if not refresh_token or not server_url:
97
+ return False
98
+
99
+ try:
100
+ r = httpx.post(
101
+ f"{server_url}/api/v1/auth/token/refresh",
102
+ json={"refresh_token": refresh_token},
103
+ timeout=10,
104
+ )
105
+ if r.status_code != 200:
106
+ return False
107
+ data = r.json()
108
+ config.save(
109
+ {
110
+ "access_token": data["access_token"],
111
+ "refresh_token": data["refresh_token"],
112
+ }
113
+ )
114
+ return True
115
+ except Exception:
116
+ return False
117
+
118
+
119
+ _MAX_RETRIES = 3
120
+ _RETRY_STATUSES = {429, 503, 504}
121
+
122
+
123
+ def _request_with_retry(
124
+ method: str,
125
+ url: str,
126
+ headers: dict,
127
+ *,
128
+ params: dict | None = None,
129
+ json: dict | None = None,
130
+ ) -> httpx.Response:
131
+ """Execute an HTTP request with retries on 429/503/504 and Retry-After support.
132
+
133
+ On 401, attempts a token refresh and retries once.
134
+ """
135
+ timeout = config.get_timeout()
136
+ func = getattr(httpx, method)
137
+
138
+ kwargs: dict = {"headers": headers, "timeout": timeout}
139
+ if params is not None:
140
+ kwargs["params"] = params
141
+ if json is not None:
142
+ kwargs["json"] = json
143
+
144
+ for attempt in range(_MAX_RETRIES):
145
+ r = func(url, **kwargs)
146
+
147
+ # Auto-refresh on 401
148
+ if r.status_code == 401 and attempt == 0 and _try_refresh_token():
149
+ # Update headers with new token and retry
150
+ cfg = config.load()
151
+ headers["Authorization"] = f"Bearer {cfg['access_token']}"
152
+ kwargs["headers"] = headers
153
+ continue
154
+
155
+ if r.status_code not in _RETRY_STATUSES or attempt == _MAX_RETRIES - 1:
156
+ r.raise_for_status()
157
+ return r
158
+ # Honor Retry-After header if present
159
+ retry_after = r.headers.get("Retry-After")
160
+ delay = float(retry_after) if retry_after else 0.5 * (2**attempt)
161
+ safe_url = urlunparse(urlparse(url)._replace(netloc=urlparse(url).hostname or ""))
162
+ logger.debug(f"Retrying {method.upper()} {safe_url} (attempt {attempt + 1}, delay {delay:.1f}s)")
163
+ time.sleep(delay)
164
+ return r # unreachable but satisfies type checker
165
+
166
+
167
+ def get(path: str, params: dict | None = None) -> dict:
168
+ base, headers = _client()
169
+ try:
170
+ r = _request_with_retry("get", f"{base}{path}", headers, params=params)
171
+ return r.json()
172
+ except httpx.HTTPStatusError as e:
173
+ _handle_error(e, path)
174
+ except httpx.ReadTimeout:
175
+ _handle_timeout(path)
176
+ except httpx.ConnectError:
177
+ _handle_connect()
178
+
179
+
180
+ def get_with_headers(path: str, params: dict | None = None) -> tuple[dict, dict[str, str]]:
181
+ """Like ``get()``, but also returns the response headers (lowercased keys).
182
+
183
+ Useful for paginated endpoints that return the page count via headers like
184
+ ``X-Total-Count``.
185
+ """
186
+ base, headers = _client()
187
+ try:
188
+ r = _request_with_retry("get", f"{base}{path}", headers, params=params)
189
+ # Normalize header keys to lowercase for case-insensitive lookup
190
+ resp_headers = {k.lower(): v for k, v in r.headers.items()}
191
+ return r.json(), resp_headers
192
+ except httpx.HTTPStatusError as e:
193
+ _handle_error(e, path)
194
+ except httpx.ReadTimeout:
195
+ _handle_timeout(path)
196
+ except httpx.ConnectError:
197
+ _handle_connect()
198
+
199
+
200
+ def post(path: str, json_data: dict | None = None) -> dict:
201
+ base, headers = _client()
202
+ try:
203
+ r = _request_with_retry("post", f"{base}{path}", headers, json=json_data)
204
+ return r.json()
205
+ except httpx.HTTPStatusError as e:
206
+ _handle_error(e, path)
207
+ except httpx.ReadTimeout:
208
+ _handle_timeout(path)
209
+ except httpx.ConnectError:
210
+ _handle_connect()
211
+
212
+
213
+ def put(path: str, json_data: dict | None = None) -> dict:
214
+ base, headers = _client()
215
+ try:
216
+ r = _request_with_retry("put", f"{base}{path}", headers, json=json_data)
217
+ return r.json()
218
+ except httpx.HTTPStatusError as e:
219
+ _handle_error(e, path)
220
+ except httpx.ReadTimeout:
221
+ _handle_timeout(path)
222
+ except httpx.ConnectError:
223
+ _handle_connect()
224
+
225
+
226
+ def patch(path: str, json_data: dict | None = None) -> dict:
227
+ base, headers = _client()
228
+ try:
229
+ r = _request_with_retry("patch", f"{base}{path}", headers, json=json_data)
230
+ return r.json()
231
+ except httpx.HTTPStatusError as e:
232
+ _handle_error(e, path)
233
+ except httpx.ReadTimeout:
234
+ _handle_timeout(path)
235
+ except httpx.ConnectError:
236
+ _handle_connect()
237
+
238
+
239
+ def delete(path: str) -> dict:
240
+ base, headers = _client()
241
+ try:
242
+ r = _request_with_retry("delete", f"{base}{path}", headers)
243
+ return r.json()
244
+ except httpx.HTTPStatusError as e:
245
+ _handle_error(e, path)
246
+ except httpx.ReadTimeout:
247
+ _handle_timeout(path)
248
+ except httpx.ConnectError:
249
+ _handle_connect()
250
+
251
+
252
+ def health() -> tuple[bool, float]:
253
+ """Check server health. Returns (ok, latency_ms)."""
254
+ cfg = config.load()
255
+ url = cfg.get("server_url", "").rstrip("/")
256
+ if not url:
257
+ return False, 0
258
+ try:
259
+ t0 = time.monotonic()
260
+ r = httpx.get(f"{url}/health", timeout=5)
261
+ latency = (time.monotonic() - t0) * 1000
262
+ return r.status_code == 200, latency
263
+ except Exception:
264
+ return False, 0