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.
- observal_cli/README.md +150 -0
- observal_cli/__init__.py +0 -0
- observal_cli/analyzer.py +565 -0
- observal_cli/branding.py +19 -0
- observal_cli/client.py +264 -0
- observal_cli/cmd_agent.py +783 -0
- observal_cli/cmd_auth.py +823 -0
- observal_cli/cmd_doctor.py +674 -0
- observal_cli/cmd_hook.py +246 -0
- observal_cli/cmd_mcp.py +1044 -0
- observal_cli/cmd_migrate.py +764 -0
- observal_cli/cmd_ops.py +1250 -0
- observal_cli/cmd_profile.py +308 -0
- observal_cli/cmd_prompt.py +200 -0
- observal_cli/cmd_pull.py +324 -0
- observal_cli/cmd_sandbox.py +178 -0
- observal_cli/cmd_scan.py +1056 -0
- observal_cli/cmd_skill.py +202 -0
- observal_cli/cmd_uninstall.py +340 -0
- observal_cli/config.py +160 -0
- observal_cli/constants.py +151 -0
- observal_cli/hooks/__init__.py +0 -0
- observal_cli/hooks/buffer_event.py +97 -0
- observal_cli/hooks/flush_buffer.py +141 -0
- observal_cli/hooks/kiro_hook.py +210 -0
- observal_cli/hooks/kiro_stop_hook.py +220 -0
- observal_cli/hooks/observal-hook.sh +31 -0
- observal_cli/hooks/observal-stop-hook.sh +134 -0
- observal_cli/hooks/payload_crypto.py +78 -0
- observal_cli/hooks_spec.py +154 -0
- observal_cli/main.py +105 -0
- observal_cli/prompts.py +92 -0
- observal_cli/proxy.py +205 -0
- observal_cli/render.py +139 -0
- observal_cli/requirements.txt +3 -0
- observal_cli/sandbox_runner.py +217 -0
- observal_cli/settings_reconciler.py +188 -0
- observal_cli/shim.py +459 -0
- observal_cli/telemetry_buffer.py +163 -0
- observal_cli-0.2.0.dist-info/METADATA +528 -0
- observal_cli-0.2.0.dist-info/RECORD +44 -0
- observal_cli-0.2.0.dist-info/WHEEL +4 -0
- observal_cli-0.2.0.dist-info/entry_points.txt +5 -0
- 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
|