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
@@ -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