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/cmd_auth.py
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
"""Auth & config CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
from rich import print as rprint
|
|
12
|
+
|
|
13
|
+
from observal_cli import client, config, settings_reconciler
|
|
14
|
+
from observal_cli.branding import welcome_banner
|
|
15
|
+
from observal_cli.hooks_spec import get_desired_env, get_desired_hooks
|
|
16
|
+
from observal_cli.render import console, kv_panel, spinner, status_badge
|
|
17
|
+
|
|
18
|
+
# ── Auth subgroup ───────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
auth_app = typer.Typer(
|
|
21
|
+
name="auth",
|
|
22
|
+
help="Authentication and account commands",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
config_app = typer.Typer(help="CLI configuration")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Auth commands (registered on auth_app) ──────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@auth_app.command()
|
|
33
|
+
def login(
|
|
34
|
+
server: str = typer.Option(None, "--server", "-s", help="Server URL"),
|
|
35
|
+
email: str = typer.Option(None, "--email", "-e", help="Email"),
|
|
36
|
+
password: str = typer.Option(None, "--password", "-p", help="Password"),
|
|
37
|
+
name: str = typer.Option(None, "--name", "-n", help="Your name (used with register)"),
|
|
38
|
+
):
|
|
39
|
+
"""Connect to Observal.
|
|
40
|
+
|
|
41
|
+
On a fresh server: prompts for email, name, and password to create admin.
|
|
42
|
+
With email+password: logs in with credentials.
|
|
43
|
+
"""
|
|
44
|
+
welcome_banner()
|
|
45
|
+
server_url = server or typer.prompt("Server URL", default="http://localhost:8000")
|
|
46
|
+
server_url = server_url.rstrip("/")
|
|
47
|
+
|
|
48
|
+
# 1. Check connectivity + initialization state
|
|
49
|
+
try:
|
|
50
|
+
with spinner("Connecting..."):
|
|
51
|
+
r = httpx.get(f"{server_url}/health", timeout=10)
|
|
52
|
+
r.raise_for_status()
|
|
53
|
+
health_data = r.json()
|
|
54
|
+
except httpx.ConnectError:
|
|
55
|
+
rprint(f"[red]Connection failed.[/red] Is the server running at {server_url}?")
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
rprint(f"[red]Server error:[/red] {e!s}")
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
initialized = health_data.get("initialized", True)
|
|
62
|
+
|
|
63
|
+
# 2. Fresh server → prompt for admin credentials and initialize
|
|
64
|
+
if not initialized:
|
|
65
|
+
rprint("[green]Connected.[/green] No users yet — let's set up your admin account.\n")
|
|
66
|
+
|
|
67
|
+
admin_email = email or typer.prompt("Admin email")
|
|
68
|
+
admin_name = name or typer.prompt("Admin name", default="admin")
|
|
69
|
+
if password:
|
|
70
|
+
admin_password = password
|
|
71
|
+
else:
|
|
72
|
+
admin_password = typer.prompt("Admin password", hide_input=True)
|
|
73
|
+
confirm = typer.prompt("Confirm password", hide_input=True)
|
|
74
|
+
if admin_password != confirm:
|
|
75
|
+
rprint("[red]Passwords do not match.[/red]")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with spinner("Creating admin account..."):
|
|
80
|
+
r = httpx.post(
|
|
81
|
+
f"{server_url}/api/v1/auth/init",
|
|
82
|
+
json={"email": admin_email, "name": admin_name, "password": admin_password},
|
|
83
|
+
timeout=30,
|
|
84
|
+
)
|
|
85
|
+
r.raise_for_status()
|
|
86
|
+
data = r.json()
|
|
87
|
+
|
|
88
|
+
user = data["user"]
|
|
89
|
+
config.save(
|
|
90
|
+
{
|
|
91
|
+
"server_url": server_url,
|
|
92
|
+
"access_token": data["access_token"],
|
|
93
|
+
"refresh_token": data["refresh_token"],
|
|
94
|
+
"user_id": user.get("id", ""),
|
|
95
|
+
"user_name": user.get("name", ""),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
rprint(f"[green]Logged in as {user['name']}[/green] ({user['email']}) [admin]")
|
|
100
|
+
rprint(f"[dim]Config saved to {config.CONFIG_FILE}[/dim]\n")
|
|
101
|
+
_fetch_server_public_key(server_url)
|
|
102
|
+
_configure_claude_code(server_url, data["access_token"])
|
|
103
|
+
_configure_kiro(server_url)
|
|
104
|
+
_post_auth_onboarding()
|
|
105
|
+
|
|
106
|
+
except httpx.HTTPStatusError as e:
|
|
107
|
+
if e.response.status_code == 400 and "already initialized" in e.response.text.lower():
|
|
108
|
+
rprint("[yellow]Server was just initialized by someone else.[/yellow]")
|
|
109
|
+
rprint("Please log in with your email and password.")
|
|
110
|
+
else:
|
|
111
|
+
rprint(f"[red]Setup failed ({e.response.status_code}):[/red] {e.response.text}")
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
rprint("[green]Connected.[/green]\n")
|
|
116
|
+
|
|
117
|
+
# 3. Email+password provided via flags → password login
|
|
118
|
+
if email and password:
|
|
119
|
+
_do_password_login(server_url, email, password)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# 4. Interactive: prompt for email + password
|
|
123
|
+
login_email = email or typer.prompt("Email")
|
|
124
|
+
login_password = password or typer.prompt("Password", hide_input=True)
|
|
125
|
+
_do_password_login(server_url, login_email, login_password)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@auth_app.command()
|
|
129
|
+
def register(
|
|
130
|
+
server: str = typer.Option(None, "--server", "-s", help="Server URL"),
|
|
131
|
+
email: str = typer.Option(None, "--email", "-e", help="Email"),
|
|
132
|
+
password: str = typer.Option(None, "--password", "-p", help="Password"),
|
|
133
|
+
name: str = typer.Option(None, "--name", "-n", help="Your name"),
|
|
134
|
+
):
|
|
135
|
+
"""Create a new account with email + password."""
|
|
136
|
+
server_url = server or typer.prompt("Server URL", default="http://localhost:8000")
|
|
137
|
+
server_url = server_url.rstrip("/")
|
|
138
|
+
reg_email = email or typer.prompt("Email")
|
|
139
|
+
reg_name = name or typer.prompt("Name")
|
|
140
|
+
reg_password = password or typer.prompt("Password", hide_input=True)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
with spinner("Creating account..."):
|
|
144
|
+
r = httpx.post(
|
|
145
|
+
f"{server_url}/api/v1/auth/register",
|
|
146
|
+
json={"email": reg_email, "name": reg_name, "password": reg_password},
|
|
147
|
+
timeout=30,
|
|
148
|
+
)
|
|
149
|
+
r.raise_for_status()
|
|
150
|
+
data = r.json()
|
|
151
|
+
|
|
152
|
+
user = data["user"]
|
|
153
|
+
config.save(
|
|
154
|
+
{
|
|
155
|
+
"server_url": server_url,
|
|
156
|
+
"access_token": data["access_token"],
|
|
157
|
+
"refresh_token": data["refresh_token"],
|
|
158
|
+
"user_id": user.get("id", ""),
|
|
159
|
+
"user_name": user.get("name", ""),
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
rprint(
|
|
163
|
+
f"[green]Account created! Logged in as {user['name']}[/green] ({user['email']}) [{user.get('role', '')}]"
|
|
164
|
+
)
|
|
165
|
+
rprint(f"[dim]Config saved to {config.CONFIG_FILE}[/dim]")
|
|
166
|
+
|
|
167
|
+
_fetch_server_public_key(server_url)
|
|
168
|
+
_configure_claude_code(server_url, data["access_token"])
|
|
169
|
+
_configure_kiro(server_url)
|
|
170
|
+
_post_auth_onboarding()
|
|
171
|
+
|
|
172
|
+
except httpx.HTTPStatusError as e:
|
|
173
|
+
detail = ""
|
|
174
|
+
try:
|
|
175
|
+
detail = e.response.json().get("detail", e.response.text)
|
|
176
|
+
except Exception:
|
|
177
|
+
detail = e.response.text
|
|
178
|
+
rprint(f"[red]Registration failed:[/red] {detail}")
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
except httpx.ConnectError:
|
|
181
|
+
rprint(f"[red]Connection failed.[/red] Is the server running at {server_url}?")
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@auth_app.command()
|
|
186
|
+
def init():
|
|
187
|
+
"""[Removed] Use 'observal auth login' + 'observal pull' instead."""
|
|
188
|
+
rprint("[yellow]'observal auth init' has been removed.[/yellow]")
|
|
189
|
+
rprint()
|
|
190
|
+
rprint("Use these commands instead:")
|
|
191
|
+
rprint(" [bold]observal auth login[/bold] — connect to your server")
|
|
192
|
+
rprint(" [bold]observal pull[/bold] — pull your configuration")
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@auth_app.command()
|
|
197
|
+
def logout():
|
|
198
|
+
"""Clear saved credentials."""
|
|
199
|
+
if config.CONFIG_FILE.exists():
|
|
200
|
+
import json
|
|
201
|
+
|
|
202
|
+
raw_cfg = json.loads(config.CONFIG_FILE.read_text())
|
|
203
|
+
|
|
204
|
+
for key in ("access_token", "refresh_token", "api_key"):
|
|
205
|
+
raw_cfg.pop(key, None)
|
|
206
|
+
config.CONFIG_FILE.write_text(json.dumps(raw_cfg, indent=2))
|
|
207
|
+
|
|
208
|
+
rprint("[green]Logged out.[/green]")
|
|
209
|
+
else:
|
|
210
|
+
rprint("[dim]No config to clear.[/dim]")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@auth_app.command()
|
|
214
|
+
def whoami(
|
|
215
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"),
|
|
216
|
+
):
|
|
217
|
+
"""Show current authenticated user."""
|
|
218
|
+
with spinner("Checking..."):
|
|
219
|
+
user = client.get("/api/v1/auth/whoami")
|
|
220
|
+
if output == "json":
|
|
221
|
+
from observal_cli.render import output_json
|
|
222
|
+
|
|
223
|
+
output_json(user)
|
|
224
|
+
return
|
|
225
|
+
console.print(
|
|
226
|
+
kv_panel(
|
|
227
|
+
user["name"],
|
|
228
|
+
[
|
|
229
|
+
("Username", f"@{user['username']}" if user.get("username") else "[dim]not set[/dim]"),
|
|
230
|
+
("Email", user["email"]),
|
|
231
|
+
("Role", status_badge(user.get("role", "user"))),
|
|
232
|
+
("ID", f"[dim]{user['id']}[/dim]"),
|
|
233
|
+
],
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@auth_app.command()
|
|
239
|
+
def status():
|
|
240
|
+
"""Check server connectivity and health."""
|
|
241
|
+
cfg = config.load()
|
|
242
|
+
url = cfg.get("server_url", "not set")
|
|
243
|
+
has_token = bool(cfg.get("access_token"))
|
|
244
|
+
ok, latency = client.health()
|
|
245
|
+
|
|
246
|
+
rprint(f" Server: {url}")
|
|
247
|
+
rprint(f" Auth: {'[green]configured[/green]' if has_token else '[red]not set[/red]'}")
|
|
248
|
+
if ok:
|
|
249
|
+
color = "green" if latency < 200 else "yellow" if latency < 1000 else "red"
|
|
250
|
+
rprint(f" Health: [{color}]ok[/{color}] ({latency:.0f}ms)")
|
|
251
|
+
else:
|
|
252
|
+
rprint(" Health: [red]unreachable[/red]")
|
|
253
|
+
|
|
254
|
+
# Show local telemetry buffer summary
|
|
255
|
+
try:
|
|
256
|
+
from observal_cli.telemetry_buffer import stats as buffer_stats
|
|
257
|
+
|
|
258
|
+
buf = buffer_stats()
|
|
259
|
+
if buf["total"] > 0:
|
|
260
|
+
rprint()
|
|
261
|
+
pending = buf["pending"]
|
|
262
|
+
label = f"[yellow]{pending} pending[/yellow]" if pending else "[green]0 pending[/green]"
|
|
263
|
+
rprint(f" Buffer: {label}, {buf['failed']} failed, {buf['sent']} sent")
|
|
264
|
+
if buf["oldest_pending"]:
|
|
265
|
+
rprint(f" Oldest: {buf['oldest_pending']} UTC")
|
|
266
|
+
if pending and not ok:
|
|
267
|
+
rprint(" [dim]Run `observal ops sync` when the server is back online.[/dim]")
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def version_callback():
|
|
273
|
+
"""Show CLI version."""
|
|
274
|
+
from importlib.metadata import version as pkg_version
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
v = pkg_version("observal")
|
|
278
|
+
except Exception:
|
|
279
|
+
v = "dev"
|
|
280
|
+
rprint(f"observal [bold]{v}[/bold]")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ── Helper functions ────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _fetch_server_public_key(server_url: str):
|
|
287
|
+
"""Fetch and cache the server's ECIES public key for payload encryption.
|
|
288
|
+
|
|
289
|
+
Best-effort: silently ignored if the server doesn't expose the endpoint
|
|
290
|
+
yet (older server versions) or if connectivity fails.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
r = httpx.get(f"{server_url.rstrip('/')}/api/v1/otel/crypto/public-key", timeout=5)
|
|
294
|
+
if r.status_code == 200:
|
|
295
|
+
data = r.json()
|
|
296
|
+
pub_pem = data.get("public_key_pem")
|
|
297
|
+
if pub_pem:
|
|
298
|
+
key_dir = Path.home() / ".observal" / "keys"
|
|
299
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
(key_dir / "server_public.pem").write_text(pub_pem)
|
|
301
|
+
except Exception:
|
|
302
|
+
pass # Server may not support encryption yet
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _do_password_login(server_url: str, email: str, password: str):
|
|
306
|
+
"""Authenticate with email + password."""
|
|
307
|
+
try:
|
|
308
|
+
with spinner("Authenticating..."):
|
|
309
|
+
r = httpx.post(
|
|
310
|
+
f"{server_url}/api/v1/auth/login",
|
|
311
|
+
json={"email": email, "password": password},
|
|
312
|
+
timeout=30,
|
|
313
|
+
)
|
|
314
|
+
r.raise_for_status()
|
|
315
|
+
data = r.json()
|
|
316
|
+
|
|
317
|
+
user = data["user"]
|
|
318
|
+
config.save(
|
|
319
|
+
{
|
|
320
|
+
"server_url": server_url,
|
|
321
|
+
"access_token": data["access_token"],
|
|
322
|
+
"refresh_token": data["refresh_token"],
|
|
323
|
+
"user_id": user.get("id", ""),
|
|
324
|
+
"user_name": user.get("name", ""),
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
rprint(f"[green]Logged in as {user['name']}[/green] ({user['email']}) [{user.get('role', '')}]")
|
|
328
|
+
rprint(f"[dim]Config saved to {config.CONFIG_FILE}[/dim]")
|
|
329
|
+
|
|
330
|
+
_fetch_server_public_key(server_url)
|
|
331
|
+
_configure_claude_code(server_url, data["access_token"])
|
|
332
|
+
_configure_kiro(server_url)
|
|
333
|
+
_post_auth_onboarding()
|
|
334
|
+
|
|
335
|
+
except httpx.ConnectError:
|
|
336
|
+
rprint(f"[red]Connection failed.[/red] Is the server running at {server_url}?")
|
|
337
|
+
raise typer.Exit(1)
|
|
338
|
+
except httpx.HTTPStatusError as e:
|
|
339
|
+
detail = ""
|
|
340
|
+
try:
|
|
341
|
+
detail = e.response.json().get("detail", e.response.text)
|
|
342
|
+
except Exception:
|
|
343
|
+
detail = e.response.text
|
|
344
|
+
rprint(f"[red]Login failed:[/red] {detail}")
|
|
345
|
+
raise typer.Exit(1)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def register_config(app: typer.Typer):
|
|
349
|
+
"""Register config subcommands."""
|
|
350
|
+
|
|
351
|
+
@config_app.command(name="show")
|
|
352
|
+
def config_show():
|
|
353
|
+
"""Show current CLI configuration."""
|
|
354
|
+
cfg = config.load()
|
|
355
|
+
safe = dict(cfg)
|
|
356
|
+
if safe.get("access_token"):
|
|
357
|
+
t = safe["access_token"]
|
|
358
|
+
safe["access_token"] = t[:8] + "..." + t[-4:] if len(t) > 12 else "***"
|
|
359
|
+
if safe.get("refresh_token"):
|
|
360
|
+
t = safe["refresh_token"]
|
|
361
|
+
safe["refresh_token"] = t[:8] + "..." + t[-4:] if len(t) > 12 else "***"
|
|
362
|
+
# Clean up legacy key if present
|
|
363
|
+
safe.pop("api_key", None)
|
|
364
|
+
console.print_json(_json.dumps(safe, indent=2))
|
|
365
|
+
|
|
366
|
+
@config_app.command(name="set")
|
|
367
|
+
def config_set(
|
|
368
|
+
key: str = typer.Argument(..., help="Config key (output, color, server_url)"),
|
|
369
|
+
value: str = typer.Argument(..., help="Config value"),
|
|
370
|
+
):
|
|
371
|
+
"""Set a CLI config value."""
|
|
372
|
+
if key == "color":
|
|
373
|
+
config.save({key: value.lower() in ("true", "1", "yes")})
|
|
374
|
+
else:
|
|
375
|
+
config.save({key: value})
|
|
376
|
+
rprint(f"[green]Set {key}[/green]")
|
|
377
|
+
|
|
378
|
+
@config_app.command(name="path")
|
|
379
|
+
def config_path():
|
|
380
|
+
"""Show config file path."""
|
|
381
|
+
rprint(str(config.CONFIG_FILE))
|
|
382
|
+
|
|
383
|
+
@config_app.command(name="alias")
|
|
384
|
+
def config_alias(
|
|
385
|
+
name: str = typer.Argument(..., help="Alias name (used as @name)"),
|
|
386
|
+
target: str = typer.Argument(None, help="Target ID (omit to remove)"),
|
|
387
|
+
):
|
|
388
|
+
"""Set or remove an alias for an MCP/agent ID."""
|
|
389
|
+
aliases = config.load_aliases()
|
|
390
|
+
if target:
|
|
391
|
+
aliases[name] = target
|
|
392
|
+
config.save_aliases(aliases)
|
|
393
|
+
rprint(f"[green]@{name} -> {target}[/green]")
|
|
394
|
+
else:
|
|
395
|
+
removed = aliases.pop(name, None)
|
|
396
|
+
config.save_aliases(aliases)
|
|
397
|
+
if removed:
|
|
398
|
+
rprint(f"[green]Removed @{name}[/green]")
|
|
399
|
+
else:
|
|
400
|
+
rprint(f"[yellow]Alias @{name} not found.[/yellow]")
|
|
401
|
+
|
|
402
|
+
@config_app.command(name="aliases")
|
|
403
|
+
def config_aliases():
|
|
404
|
+
"""List all aliases."""
|
|
405
|
+
aliases = config.load_aliases()
|
|
406
|
+
if not aliases:
|
|
407
|
+
rprint("[dim]No aliases set. Use: observal config alias <name> <id>[/dim]")
|
|
408
|
+
return
|
|
409
|
+
for name, target in sorted(aliases.items()):
|
|
410
|
+
rprint(f" @{name} -> [dim]{target}[/dim]")
|
|
411
|
+
|
|
412
|
+
app.add_typer(config_app, name="config")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _find_hook_script(name: str) -> str | None:
|
|
416
|
+
"""Locate a hook script by filename."""
|
|
417
|
+
candidates = [
|
|
418
|
+
Path(__file__).parent / "hooks" / name,
|
|
419
|
+
Path(shutil.which(name) or ""),
|
|
420
|
+
]
|
|
421
|
+
for p in candidates:
|
|
422
|
+
if p.is_file():
|
|
423
|
+
return str(p.resolve())
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _post_auth_onboarding():
|
|
428
|
+
"""Detect local IDE configs and offer to scan+register components."""
|
|
429
|
+
try:
|
|
430
|
+
_ide_dirs = {
|
|
431
|
+
"Claude Code": (Path.home() / ".claude", "claude-code"),
|
|
432
|
+
"Kiro CLI": (Path.home() / ".kiro", "kiro"),
|
|
433
|
+
"Cursor": (Path.home() / ".cursor", "cursor"),
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
# Quick local scan: count components per IDE (no API calls)
|
|
437
|
+
found: list[tuple[str, str, int, int]] = [] # (label, ide_key, agents, mcps)
|
|
438
|
+
for label, (dir_path, ide_key) in _ide_dirs.items():
|
|
439
|
+
if not dir_path.is_dir():
|
|
440
|
+
continue
|
|
441
|
+
agents = mcps = 0
|
|
442
|
+
if ide_key == "claude-code":
|
|
443
|
+
from observal_cli.cmd_scan import _scan_claude_home
|
|
444
|
+
|
|
445
|
+
m, _s, _h, a = _scan_claude_home(dir_path)
|
|
446
|
+
agents, mcps = len(a), len(m)
|
|
447
|
+
elif ide_key == "kiro":
|
|
448
|
+
from observal_cli.cmd_scan import _scan_kiro_home
|
|
449
|
+
|
|
450
|
+
m, _s, _h, a = _scan_kiro_home(dir_path)
|
|
451
|
+
agents, mcps = len(a), len(m)
|
|
452
|
+
else:
|
|
453
|
+
# Cursor: just check for mcp.json
|
|
454
|
+
mcp_file = dir_path / "mcp.json"
|
|
455
|
+
if mcp_file.exists():
|
|
456
|
+
try:
|
|
457
|
+
import json as _j
|
|
458
|
+
|
|
459
|
+
data = _j.loads(mcp_file.read_text())
|
|
460
|
+
mcps = len(data.get("mcpServers", {}))
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
if agents > 0 or mcps > 0:
|
|
464
|
+
found.append((label, ide_key, agents, mcps))
|
|
465
|
+
|
|
466
|
+
if not found:
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
# Show what we found
|
|
470
|
+
rprint()
|
|
471
|
+
rprint("[bold]\N{ELECTRIC LIGHT BULB} You have local agent configs that aren't in Observal.[/bold]")
|
|
472
|
+
rprint("[dim]Upload them to track usage, share with your team, and enable telemetry.[/dim]")
|
|
473
|
+
rprint()
|
|
474
|
+
for label, _key, agents, mcps in found:
|
|
475
|
+
parts = []
|
|
476
|
+
if agents:
|
|
477
|
+
parts.append(f"{agents} agent{'s' if agents != 1 else ''}")
|
|
478
|
+
if mcps:
|
|
479
|
+
parts.append(f"{mcps} MCP{'s' if mcps != 1 else ''}")
|
|
480
|
+
rprint(f" [bold]{label}[/bold] — {', '.join(parts)} found")
|
|
481
|
+
rprint()
|
|
482
|
+
|
|
483
|
+
if not typer.confirm("Upload these to Observal?", default=True):
|
|
484
|
+
rprint("[dim]Tip: run `observal scan --home --all-ides` anytime to upload agents from your IDEs.[/dim]")
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
# Run scan for each selected IDE using the existing scan machinery
|
|
488
|
+
from observal_cli import client
|
|
489
|
+
from observal_cli.cmd_scan import _scan_claude_home, _scan_kiro_home
|
|
490
|
+
from observal_cli.render import spinner
|
|
491
|
+
|
|
492
|
+
all_mcps: list = []
|
|
493
|
+
all_skills: list = []
|
|
494
|
+
all_hooks: list = []
|
|
495
|
+
all_agents: list = []
|
|
496
|
+
|
|
497
|
+
for _label, ide_key, _a, _m in found:
|
|
498
|
+
if ide_key == "claude-code":
|
|
499
|
+
m, s, h, a = _scan_claude_home(Path.home() / ".claude")
|
|
500
|
+
all_mcps.extend(m)
|
|
501
|
+
all_skills.extend(s)
|
|
502
|
+
all_hooks.extend(h)
|
|
503
|
+
all_agents.extend(a)
|
|
504
|
+
elif ide_key == "kiro":
|
|
505
|
+
m, s, h, a = _scan_kiro_home(Path.home() / ".kiro")
|
|
506
|
+
all_mcps.extend(m)
|
|
507
|
+
all_skills.extend(s)
|
|
508
|
+
all_hooks.extend(h)
|
|
509
|
+
all_agents.extend(a)
|
|
510
|
+
|
|
511
|
+
total = len(all_mcps) + len(all_skills) + len(all_hooks) + len(all_agents)
|
|
512
|
+
if total == 0:
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
def _ide_from_source(source: str) -> str:
|
|
516
|
+
if source.startswith("kiro:"):
|
|
517
|
+
return "kiro"
|
|
518
|
+
if source.startswith("plugin:") or source.startswith("claude:"):
|
|
519
|
+
return "claude-code"
|
|
520
|
+
return "auto"
|
|
521
|
+
|
|
522
|
+
scan_payload = {
|
|
523
|
+
"ide": "multi",
|
|
524
|
+
"mcps": [
|
|
525
|
+
{
|
|
526
|
+
"name": m.name,
|
|
527
|
+
"command": m.command,
|
|
528
|
+
"args": m.args,
|
|
529
|
+
"url": m.url,
|
|
530
|
+
"description": m.description,
|
|
531
|
+
"source_plugin": m.source,
|
|
532
|
+
"source_ide": _ide_from_source(m.source),
|
|
533
|
+
}
|
|
534
|
+
for m in all_mcps
|
|
535
|
+
],
|
|
536
|
+
"skills": [
|
|
537
|
+
{
|
|
538
|
+
"name": s.name,
|
|
539
|
+
"description": s.description,
|
|
540
|
+
"source_plugin": s.source,
|
|
541
|
+
"task_type": getattr(s, "task_type", "general"),
|
|
542
|
+
"source_ide": _ide_from_source(s.source),
|
|
543
|
+
}
|
|
544
|
+
for s in all_skills
|
|
545
|
+
],
|
|
546
|
+
"hooks": [
|
|
547
|
+
{
|
|
548
|
+
"name": h.name,
|
|
549
|
+
"event": h.event,
|
|
550
|
+
"handler_type": h.handler_type,
|
|
551
|
+
"handler_config": h.handler_config,
|
|
552
|
+
"description": h.description,
|
|
553
|
+
"source_plugin": h.source,
|
|
554
|
+
"source_ide": _ide_from_source(h.source),
|
|
555
|
+
}
|
|
556
|
+
for h in all_hooks
|
|
557
|
+
],
|
|
558
|
+
"agents": [
|
|
559
|
+
{
|
|
560
|
+
"name": a.name,
|
|
561
|
+
"description": a.description,
|
|
562
|
+
"model_name": a.model_name or "",
|
|
563
|
+
"prompt": a.prompt,
|
|
564
|
+
"source_file": a.source_file,
|
|
565
|
+
"source_ide": _ide_from_source(
|
|
566
|
+
f"kiro:{a.source_file}" if a.source_file and ".kiro" in a.source_file else a.source_file or ""
|
|
567
|
+
),
|
|
568
|
+
}
|
|
569
|
+
for a in all_agents
|
|
570
|
+
],
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
with spinner(f"Registering {total} components..."):
|
|
574
|
+
try:
|
|
575
|
+
result = client.post("/api/v1/scan", scan_payload)
|
|
576
|
+
except Exception as e:
|
|
577
|
+
rprint(f"[yellow]Registration failed: {e}[/yellow]")
|
|
578
|
+
rprint("[dim]Tip: run `observal scan --home --all-ides` to retry.[/dim]")
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
summary = result.get("summary", {})
|
|
582
|
+
parts = [f"{v} {k}" for k, v in summary.items() if v]
|
|
583
|
+
if parts:
|
|
584
|
+
rprint(f"[green]Registered: {', '.join(parts)}[/green]")
|
|
585
|
+
else:
|
|
586
|
+
rprint("[dim]All components already registered.[/dim]")
|
|
587
|
+
|
|
588
|
+
except Exception as e:
|
|
589
|
+
rprint(f"[yellow]Onboarding skipped: {e}[/yellow]")
|
|
590
|
+
rprint("[dim]Tip: run `observal scan --home --all-ides` anytime to upload agents from your IDEs.[/dim]")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _configure_kiro(server_url: str):
|
|
594
|
+
"""Check for Kiro CLI and offer to configure its telemetry hooks."""
|
|
595
|
+
kiro_dir = Path.home() / ".kiro"
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
kiro_exists = kiro_dir.is_dir() or shutil.which("kiro-cli") or shutil.which("kiro")
|
|
599
|
+
if not kiro_exists:
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
if not typer.confirm(
|
|
603
|
+
"\nDetected Kiro CLI. Configure telemetry -> Observal?",
|
|
604
|
+
default=True,
|
|
605
|
+
):
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
hooks_url = f"{server_url.rstrip('/')}/api/v1/otel/hooks"
|
|
609
|
+
|
|
610
|
+
hook_py = _find_hook_script("kiro_hook.py")
|
|
611
|
+
stop_py = _find_hook_script("kiro_stop_hook.py")
|
|
612
|
+
|
|
613
|
+
def _hook_cmd(agent_name: str) -> str:
|
|
614
|
+
if hook_py:
|
|
615
|
+
return f"cat | python3 {hook_py} --url {hooks_url} --agent-name {agent_name}"
|
|
616
|
+
return f'cat | curl -sf -X POST {hooks_url} -H "Content-Type: application/json" -d @-'
|
|
617
|
+
|
|
618
|
+
def _stop_cmd(agent_name: str) -> str:
|
|
619
|
+
if stop_py:
|
|
620
|
+
return f"cat | python3 {stop_py} --url {hooks_url} --agent-name {agent_name}"
|
|
621
|
+
return f'cat | curl -sf -X POST {hooks_url} -H "Content-Type: application/json" -d @-'
|
|
622
|
+
|
|
623
|
+
changes = 0
|
|
624
|
+
|
|
625
|
+
# 1. Inject into agent JSON files (merge, preserve existing hooks)
|
|
626
|
+
# If kiro_default.json doesn't exist, create it so hooks attach to the
|
|
627
|
+
# built-in kiro_default agent instead of a separate workspace agent.
|
|
628
|
+
agents_dir = kiro_dir / "agents"
|
|
629
|
+
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
630
|
+
|
|
631
|
+
# Migrate: remove old default.json created by earlier Observal versions.
|
|
632
|
+
# It shadowed the built-in kiro_default agent.
|
|
633
|
+
old_default = agents_dir / "default.json"
|
|
634
|
+
if old_default.exists():
|
|
635
|
+
try:
|
|
636
|
+
od = _json.loads(old_default.read_text())
|
|
637
|
+
if od.get("name") == "default" and any(
|
|
638
|
+
"otel/hooks" in h.get("command", "")
|
|
639
|
+
for hs in od.get("hooks", {}).values()
|
|
640
|
+
if isinstance(hs, list)
|
|
641
|
+
for h in hs
|
|
642
|
+
):
|
|
643
|
+
old_default.unlink()
|
|
644
|
+
import subprocess
|
|
645
|
+
|
|
646
|
+
kiro_bin = shutil.which("kiro-cli") or shutil.which("kiro") or shutil.which("kiro-cli-chat")
|
|
647
|
+
if kiro_bin:
|
|
648
|
+
subprocess.run(
|
|
649
|
+
[kiro_bin, "agent", "set-default", "kiro_default"],
|
|
650
|
+
capture_output=True,
|
|
651
|
+
timeout=10,
|
|
652
|
+
)
|
|
653
|
+
changes += 1
|
|
654
|
+
except (ValueError, OSError):
|
|
655
|
+
pass
|
|
656
|
+
|
|
657
|
+
agent_files = sorted(agents_dir.glob("*.json"))
|
|
658
|
+
default_agent = agents_dir / "kiro_default.json"
|
|
659
|
+
if not default_agent.exists():
|
|
660
|
+
cmd = _hook_cmd("kiro_default")
|
|
661
|
+
stop = _stop_cmd("kiro_default")
|
|
662
|
+
default_agent.write_text(
|
|
663
|
+
_json.dumps(
|
|
664
|
+
{
|
|
665
|
+
"name": "kiro_default",
|
|
666
|
+
"hooks": {
|
|
667
|
+
"agentSpawn": [{"command": cmd}],
|
|
668
|
+
"userPromptSubmit": [{"command": cmd}],
|
|
669
|
+
"preToolUse": [{"matcher": "*", "command": cmd}],
|
|
670
|
+
"postToolUse": [{"matcher": "*", "command": cmd}],
|
|
671
|
+
"stop": [{"command": stop}],
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
indent=2,
|
|
675
|
+
)
|
|
676
|
+
+ "\n"
|
|
677
|
+
)
|
|
678
|
+
changes += 1
|
|
679
|
+
agent_files = sorted(agents_dir.glob("*.json"))
|
|
680
|
+
|
|
681
|
+
for af in agent_files:
|
|
682
|
+
try:
|
|
683
|
+
data = _json.loads(af.read_text())
|
|
684
|
+
existing = data.get("hooks", {})
|
|
685
|
+
already = any(
|
|
686
|
+
"otel/hooks" in h.get("command", "")
|
|
687
|
+
for handlers in existing.values()
|
|
688
|
+
if isinstance(handlers, list)
|
|
689
|
+
for h in handlers
|
|
690
|
+
)
|
|
691
|
+
if already:
|
|
692
|
+
continue
|
|
693
|
+
name = data.get("name") or af.stem
|
|
694
|
+
cmd = _hook_cmd(name)
|
|
695
|
+
stop = _stop_cmd(name)
|
|
696
|
+
desired = {
|
|
697
|
+
"agentSpawn": [{"command": cmd}],
|
|
698
|
+
"userPromptSubmit": [{"command": cmd}],
|
|
699
|
+
"preToolUse": [{"matcher": "*", "command": cmd}],
|
|
700
|
+
"postToolUse": [{"matcher": "*", "command": cmd}],
|
|
701
|
+
"stop": [{"command": stop}],
|
|
702
|
+
}
|
|
703
|
+
merged = dict(existing)
|
|
704
|
+
for evt, handlers in desired.items():
|
|
705
|
+
cur = merged.get(evt, [])
|
|
706
|
+
has_obs = any("otel/hooks" in h.get("command", "") for h in cur)
|
|
707
|
+
if not has_obs:
|
|
708
|
+
merged[evt] = cur + handlers
|
|
709
|
+
data["hooks"] = merged
|
|
710
|
+
af.write_text(_json.dumps(data, indent=2) + "\n")
|
|
711
|
+
changes += 1
|
|
712
|
+
except (ValueError, OSError):
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
# 2. Install global IDE-format hooks for agentless chat
|
|
716
|
+
global_hooks_dir = kiro_dir / "hooks"
|
|
717
|
+
global_hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
718
|
+
g_cmd = _hook_cmd("global")
|
|
719
|
+
g_stop = _stop_cmd("global")
|
|
720
|
+
for hook_id, event_type, cmd in [
|
|
721
|
+
("observal-prompt-submit", "promptSubmit", g_cmd),
|
|
722
|
+
("observal-pre-tool-use", "preToolUse", g_cmd),
|
|
723
|
+
("observal-post-tool-use", "postToolUse", g_cmd),
|
|
724
|
+
("observal-agent-stop", "agentStop", g_stop),
|
|
725
|
+
]:
|
|
726
|
+
hf = global_hooks_dir / f"{hook_id}.json"
|
|
727
|
+
if hf.exists():
|
|
728
|
+
try:
|
|
729
|
+
ex = _json.loads(hf.read_text())
|
|
730
|
+
if hooks_url in ex.get("then", {}).get("command", ""):
|
|
731
|
+
continue
|
|
732
|
+
except (ValueError, OSError):
|
|
733
|
+
pass
|
|
734
|
+
hf.write_text(
|
|
735
|
+
_json.dumps(
|
|
736
|
+
{
|
|
737
|
+
"id": hook_id,
|
|
738
|
+
"name": f"Observal: {event_type}",
|
|
739
|
+
"comment": "Auto-injected by Observal for telemetry collection",
|
|
740
|
+
"when": {"type": event_type},
|
|
741
|
+
"then": {"type": "runCommand", "command": cmd},
|
|
742
|
+
},
|
|
743
|
+
indent=2,
|
|
744
|
+
)
|
|
745
|
+
+ "\n"
|
|
746
|
+
)
|
|
747
|
+
changes += 1
|
|
748
|
+
|
|
749
|
+
if changes:
|
|
750
|
+
rprint(f"[green]Configured Kiro telemetry ({changes} hooks updated)[/green]")
|
|
751
|
+
else:
|
|
752
|
+
rprint("[dim]Kiro hooks already configured.[/dim]")
|
|
753
|
+
|
|
754
|
+
except Exception as e:
|
|
755
|
+
rprint(f"\n[yellow]Could not configure Kiro automatically: {e}[/yellow]")
|
|
756
|
+
rprint("Run [bold]observal scan --ide kiro --home[/bold] to set up manually.")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _configure_claude_code(server_url: str, access_token: str):
|
|
760
|
+
"""Check for Claude Code and offer to configure its telemetry.
|
|
761
|
+
|
|
762
|
+
Uses declarative reconciliation: computes desired state from hooks_spec,
|
|
763
|
+
diffs against current ~/.claude/settings.json, and applies minimal changes.
|
|
764
|
+
Non-Observal hooks and env vars are preserved untouched.
|
|
765
|
+
"""
|
|
766
|
+
claude_dir = Path.home() / ".claude"
|
|
767
|
+
|
|
768
|
+
try:
|
|
769
|
+
claude_exists = claude_dir.is_dir() or shutil.which("claude")
|
|
770
|
+
if not claude_exists:
|
|
771
|
+
return
|
|
772
|
+
|
|
773
|
+
if not typer.confirm(
|
|
774
|
+
"\nDetected Claude Code. Configure telemetry -> Observal?",
|
|
775
|
+
default=True,
|
|
776
|
+
):
|
|
777
|
+
return
|
|
778
|
+
|
|
779
|
+
# Fetch a long-lived hooks token for OTEL env vars
|
|
780
|
+
hooks_token = _fetch_hooks_token(server_url, access_token)
|
|
781
|
+
|
|
782
|
+
# Build desired state from the declarative spec
|
|
783
|
+
hooks_url = f"{server_url.rstrip('/')}/api/v1/otel/hooks"
|
|
784
|
+
hook_script = _find_hook_script("observal-hook.sh")
|
|
785
|
+
stop_script = _find_hook_script("observal-stop-hook.sh")
|
|
786
|
+
cfg = config.load()
|
|
787
|
+
user_id = cfg.get("user_id", "")
|
|
788
|
+
user_name = cfg.get("user_name", "")
|
|
789
|
+
|
|
790
|
+
desired_hooks = get_desired_hooks(hook_script, stop_script, hooks_url, user_id)
|
|
791
|
+
desired_env = get_desired_env(server_url, hooks_token, user_id, user_name)
|
|
792
|
+
|
|
793
|
+
# Reconcile: non-destructive merge preserving foreign hooks/env
|
|
794
|
+
changes = settings_reconciler.reconcile(desired_hooks, desired_env)
|
|
795
|
+
|
|
796
|
+
if changes:
|
|
797
|
+
rprint(f"Updated [dim]{settings_reconciler.CLAUDE_SETTINGS_PATH}[/dim]:")
|
|
798
|
+
for change in changes:
|
|
799
|
+
rprint(f" {change}")
|
|
800
|
+
else:
|
|
801
|
+
rprint("[dim]Claude Code settings already up to date.[/dim]")
|
|
802
|
+
|
|
803
|
+
except Exception as e:
|
|
804
|
+
rprint(f"\n[yellow]Could not configure Claude Code automatically: {e}[/yellow]")
|
|
805
|
+
rprint("See documentation for manual configuration.")
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _fetch_hooks_token(server_url: str, access_token: str) -> str:
|
|
809
|
+
"""Call /auth/hooks-token to get a long-lived token for OTEL hooks.
|
|
810
|
+
|
|
811
|
+
Falls back to the session access_token if the endpoint fails.
|
|
812
|
+
"""
|
|
813
|
+
try:
|
|
814
|
+
r = httpx.post(
|
|
815
|
+
f"{server_url.rstrip('/')}/api/v1/auth/hooks-token",
|
|
816
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
817
|
+
timeout=10,
|
|
818
|
+
)
|
|
819
|
+
if r.status_code == 200:
|
|
820
|
+
return r.json().get("access_token", access_token)
|
|
821
|
+
except Exception:
|
|
822
|
+
pass
|
|
823
|
+
return access_token
|