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,674 @@
1
+ """observal doctor: diagnose IDE settings that conflict with Observal telemetry."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich import print as rprint
10
+
11
+ from observal_cli import config, settings_reconciler
12
+ from observal_cli.hooks_spec import get_desired_env, get_desired_hooks
13
+
14
+ doctor_app = typer.Typer(help="Diagnose IDE settings for Observal compatibility")
15
+
16
+
17
+ # ── IDE config locations ─────────────────────────────────
18
+
19
+ IDE_CONFIGS = {
20
+ "claude-code": {
21
+ "user_settings": [
22
+ Path.home() / ".claude" / "settings.json",
23
+ ],
24
+ "project_settings": [
25
+ Path(".claude") / "settings.json",
26
+ Path(".claude") / "settings.local.json",
27
+ ],
28
+ "mcp": [
29
+ Path(".mcp.json"),
30
+ ],
31
+ },
32
+ "kiro": {
33
+ "user_settings": [
34
+ Path.home() / ".kiro" / "settings" / "cli.json",
35
+ Path.home() / ".kiro" / "settings.json",
36
+ ],
37
+ "project_settings": [
38
+ Path(".kiro") / "settings.json",
39
+ Path(".kiro") / "settings" / "cli.json",
40
+ ],
41
+ "mcp": [],
42
+ },
43
+ "cursor": {
44
+ "user_settings": [
45
+ Path.home() / ".cursor" / "mcp.json",
46
+ ],
47
+ "project_settings": [
48
+ Path(".cursor") / "mcp.json",
49
+ ],
50
+ "mcp": [],
51
+ },
52
+ "gemini-cli": {
53
+ "user_settings": [
54
+ Path.home() / ".gemini" / "settings.json",
55
+ ],
56
+ "project_settings": [
57
+ Path(".gemini") / "settings.json",
58
+ ],
59
+ "mcp": [],
60
+ },
61
+ }
62
+
63
+
64
+ # ── Check functions ──────────────────────────────────────
65
+
66
+
67
+ def _load_json(path: Path) -> dict | None:
68
+ try:
69
+ return json.loads(path.read_text())
70
+ except Exception:
71
+ return None
72
+
73
+
74
+ def _check_claude_code(path: Path, data: dict, issues: list, warnings: list):
75
+ """Check Claude Code settings for Observal conflicts."""
76
+ # Hooks disabled entirely
77
+ if data.get("disableAllHooks"):
78
+ issues.append(f"{path}: `disableAllHooks` is true. Observal hook telemetry will not fire.")
79
+
80
+ # allowedHttpHookUrls blocks our endpoint
81
+ allowed_urls = data.get("allowedHttpHookUrls")
82
+ if isinstance(allowed_urls, list) and len(allowed_urls) > 0:
83
+ has_observal = any("localhost:8000" in u or "observal" in u.lower() for u in allowed_urls)
84
+ if not has_observal:
85
+ issues.append(
86
+ f"{path}: `allowedHttpHookUrls` is set but does not include Observal's URL. "
87
+ "Add `http://localhost:8000/*` to allow hook telemetry."
88
+ )
89
+
90
+ # httpHookAllowedEnvVars blocks OBSERVAL_API_KEY
91
+ allowed_env = data.get("httpHookAllowedEnvVars")
92
+ if isinstance(allowed_env, list) and "OBSERVAL_API_KEY" not in allowed_env:
93
+ issues.append(
94
+ f"{path}: `httpHookAllowedEnvVars` does not include `OBSERVAL_API_KEY`. "
95
+ "Observal hooks need this env var for authentication."
96
+ )
97
+
98
+ # allowManagedHooksOnly blocks project/user hooks
99
+ if data.get("allowManagedHooksOnly"):
100
+ issues.append(
101
+ f"{path}: `allowManagedHooksOnly` is true. "
102
+ "Only managed hooks will run. Observal hooks installed at project/user level will be blocked."
103
+ )
104
+
105
+ # Permissions denying our tools
106
+ perms = data.get("permissions", {})
107
+ deny_list = perms.get("deny", [])
108
+ for rule in deny_list:
109
+ if isinstance(rule, str) and ("observal" in rule.lower() or rule == "WebFetch"):
110
+ warnings.append(f"{path}: deny rule `{rule}` may block Observal telemetry.")
111
+
112
+ # MCP servers: check if observal-shim is being bypassed
113
+ # (project .mcp.json or user mcpServers)
114
+
115
+ # Sandbox settings that block network
116
+ sandbox = data.get("sandbox", {})
117
+ network = sandbox.get("network", {})
118
+ allowed_domains = network.get("allowedDomains", [])
119
+ if isinstance(allowed_domains, list) and len(allowed_domains) > 0:
120
+ has_localhost = any("localhost" in d for d in allowed_domains)
121
+ if not has_localhost:
122
+ warnings.append(
123
+ f"{path}: sandbox `network.allowedDomains` does not include `localhost`. "
124
+ "Observal telemetry POSTs to localhost:8000."
125
+ )
126
+
127
+ # env vars that override Observal
128
+ env = data.get("env", {})
129
+ if env.get("OBSERVAL_KEY") or env.get("OBSERVAL_SERVER"):
130
+ warnings.append(f"{path}: env overrides for OBSERVAL_KEY/OBSERVAL_SERVER found. Verify they are correct.")
131
+
132
+
133
+ def _check_kiro(path: Path, data: dict, issues: list, warnings: list):
134
+ """Check Kiro CLI/IDE settings for Observal conflicts."""
135
+ # Telemetry disabled
136
+ if data.get("telemetry.enabled") is False or data.get("telemetry", {}).get("enabled") is False:
137
+ warnings.append(
138
+ f"{path}: Kiro telemetry is disabled. This does not affect Observal, but may indicate a preference against data collection."
139
+ )
140
+
141
+ # MCP init timeout too low
142
+ mcp_timeout = data.get("mcp.initTimeout") or data.get("mcp", {}).get("initTimeout")
143
+ if mcp_timeout is not None and mcp_timeout < 10:
144
+ warnings.append(
145
+ f"{path}: `mcp.initTimeout` is {mcp_timeout}s. "
146
+ "observal-shim adds a small overhead to MCP startup. Consider 10s+."
147
+ )
148
+
149
+ # Auto-compaction may lose telemetry context
150
+ if data.get("chat.disableAutoCompaction") is False or data.get("chat", {}).get("disableAutoCompaction") is False:
151
+ pass # default, fine
152
+
153
+
154
+ def _check_kiro_installation(issues: list, warnings: list):
155
+ """Check Kiro CLI installation and agent hook configuration."""
156
+ # Check kiro-cli binary
157
+ if os.system("which kiro-cli > /dev/null 2>&1") != 0:
158
+ warnings.append("`kiro-cli` not found in PATH. Install with: curl -fsSL https://cli.kiro.dev/install | bash")
159
+ else:
160
+ # Check if kiro-cli is authenticated
161
+ if os.system("kiro-cli whoami > /dev/null 2>&1") != 0:
162
+ warnings.append("`kiro-cli` is installed but not authenticated. Run `kiro-cli login`.")
163
+
164
+ # Check for Kiro agents directory
165
+ agents_dir = Path.home() / ".kiro" / "agents"
166
+ if agents_dir.exists():
167
+ agent_files = list(agents_dir.glob("*.json"))
168
+ if agent_files:
169
+ # Check if any agents have Observal hooks configured
170
+ has_observal_hooks = False
171
+ for af in agent_files:
172
+ agent_data = _load_json(af)
173
+ if agent_data and "hooks" in agent_data:
174
+ hooks = agent_data["hooks"]
175
+ for _event, hook_list in hooks.items():
176
+ for h in hook_list if isinstance(hook_list, list) else []:
177
+ cmd = h.get("command", "")
178
+ if "observal" in cmd or "telemetry/hooks" in cmd:
179
+ has_observal_hooks = True
180
+ break
181
+ if not has_observal_hooks:
182
+ warnings.append(
183
+ "No Kiro agents have Observal telemetry hooks. "
184
+ "Run `observal scan --ide kiro --home` to inject hooks."
185
+ )
186
+
187
+ # Check MCP config for observal-shim
188
+ mcp_path = Path.home() / ".kiro" / "settings" / "mcp.json"
189
+ if mcp_path.exists():
190
+ mcp_data = _load_json(mcp_path)
191
+ if mcp_data:
192
+ servers = mcp_data.get("mcpServers", {})
193
+ unwrapped = [
194
+ n
195
+ for n, c in servers.items()
196
+ if isinstance(c, dict)
197
+ and "observal-shim" not in c.get("command", "")
198
+ and "observal-proxy" not in c.get("command", "")
199
+ and "url" not in c # HTTP transport doesn't need shim
200
+ ]
201
+ if unwrapped:
202
+ warnings.append(
203
+ f"Kiro MCP servers not wrapped with observal-shim: {', '.join(unwrapped)}. "
204
+ "Run `observal scan --ide kiro` to wrap them."
205
+ )
206
+
207
+
208
+ def _check_cursor(path: Path, data: dict, issues: list, warnings: list):
209
+ """Check Cursor MCP config for Observal conflicts."""
210
+ servers = data.get("mcpServers", {})
211
+ for name, srv_cfg in servers.items():
212
+ cmd = srv_cfg.get("command", "")
213
+ args = srv_cfg.get("args", [])
214
+ full_cmd = f"{cmd} {' '.join(str(a) for a in args)}"
215
+ # Check if MCP is wrapped with observal-shim
216
+ if "observal-shim" not in full_cmd and "observal-proxy" not in full_cmd:
217
+ warnings.append(
218
+ f"{path}: MCP server `{name}` is not wrapped with observal-shim. "
219
+ "Install via `observal install <id> --ide cursor` to enable telemetry."
220
+ )
221
+
222
+
223
+ def _check_gemini(path: Path, data: dict, issues: list, warnings: list):
224
+ """Check Gemini CLI settings for Observal conflicts."""
225
+ servers = data.get("mcpServers", {})
226
+ for name, srv_cfg in servers.items():
227
+ cmd = srv_cfg.get("command", "")
228
+ args = srv_cfg.get("args", [])
229
+ full_cmd = f"{cmd} {' '.join(str(a) for a in args)}"
230
+ if "observal-shim" not in full_cmd and "observal-proxy" not in full_cmd:
231
+ warnings.append(
232
+ f"{path}: MCP server `{name}` is not wrapped with observal-shim. "
233
+ "Install via `observal install <id> --ide gemini-cli` to enable telemetry."
234
+ )
235
+
236
+
237
+ def _check_mcp_json(path: Path, data: dict, issues: list, warnings: list):
238
+ """Check .mcp.json for unwrapped servers."""
239
+ servers = data.get("mcpServers", {})
240
+ for name, srv_cfg in servers.items():
241
+ cmd = srv_cfg.get("command", "")
242
+ args = srv_cfg.get("args", [])
243
+ full_cmd = f"{cmd} {' '.join(str(a) for a in args)}"
244
+ if "observal-shim" not in full_cmd and "observal-proxy" not in full_cmd:
245
+ warnings.append(
246
+ f"{path}: MCP server `{name}` is not wrapped with observal-shim/proxy. "
247
+ "Telemetry will not be collected for this server."
248
+ )
249
+
250
+
251
+ # ── Observal config checks ──────────────────────────────
252
+
253
+
254
+ def _check_observal_config(issues: list, warnings: list):
255
+ """Check Observal's own config."""
256
+ config_path = Path.home() / ".observal" / "config.json"
257
+ if not config_path.exists():
258
+ issues.append("~/.observal/config.json not found. Run `observal auth login` first.")
259
+ return
260
+
261
+ data = _load_json(config_path)
262
+ if data is None:
263
+ issues.append("~/.observal/config.json is not valid JSON.")
264
+ return
265
+
266
+ if not data.get("access_token"):
267
+ issues.append("No access token in ~/.observal/config.json. Run `observal auth login`.")
268
+
269
+ if not data.get("server_url"):
270
+ issues.append("No server_url in ~/.observal/config.json. Run `observal auth login`.")
271
+
272
+ # Check server is reachable
273
+ server_url = data.get("server_url", "")
274
+ if server_url:
275
+ try:
276
+ import httpx
277
+
278
+ resp = httpx.get(f"{server_url}/health", timeout=5)
279
+ if resp.status_code != 200:
280
+ issues.append(f"Observal server at {server_url} returned status {resp.status_code}.")
281
+ except Exception as e:
282
+ issues.append(f"Cannot reach Observal server at {server_url}: {e}")
283
+
284
+
285
+ # ── Environment checks ───────────────────────────────────
286
+
287
+
288
+ def _check_environment(issues: list, warnings: list):
289
+ """Check environment variables."""
290
+ if os.environ.get("OBSERVAL_KEY"):
291
+ pass # good
292
+ elif not (Path.home() / ".observal" / "config.json").exists():
293
+ warnings.append("OBSERVAL_KEY env var not set and no config file found.")
294
+
295
+ # Check if Docker is available (for sandbox runner)
296
+ if os.system("docker info > /dev/null 2>&1") != 0:
297
+ warnings.append("Docker is not running. `observal-sandbox-run` requires Docker.")
298
+
299
+ # Check entry points
300
+ for ep in ["observal-shim", "observal-proxy", "observal-sandbox-run"]:
301
+ if not shutil.which(ep):
302
+ warnings.append(f"`{ep}` not found in PATH. Run `uv tool install --editable .` from the Observal repo.")
303
+
304
+
305
+ # ── Main doctor command ──────────────────────────────────
306
+
307
+
308
+ @doctor_app.callback(invoke_without_command=True)
309
+ def doctor(
310
+ ctx: typer.Context,
311
+ ide: str = typer.Option(None, help="Check specific IDE only (claude-code, kiro, cursor, gemini-cli)"),
312
+ fix: bool = typer.Option(False, help="Show suggested fixes"),
313
+ ):
314
+ """Diagnose IDE and Observal settings for compatibility issues."""
315
+ if ctx.invoked_subcommand is not None:
316
+ return # Let the subcommand handle it
317
+ issues: list[str] = []
318
+ warnings: list[str] = []
319
+
320
+ rprint("[bold]Observal Doctor[/bold]\n")
321
+
322
+ # 1. Check Observal itself
323
+ rprint("[cyan]Checking Observal config...[/cyan]")
324
+ _check_observal_config(issues, warnings)
325
+
326
+ # 2. Check environment
327
+ rprint("[cyan]Checking environment...[/cyan]")
328
+ _check_environment(issues, warnings)
329
+
330
+ # 3. Kiro-specific installation checks
331
+ if not ide or ide in ("kiro", "kiro-cli"):
332
+ rprint("[cyan]Checking Kiro installation...[/cyan]")
333
+ _check_kiro_installation(issues, warnings)
334
+
335
+ # 4. Check IDE configs
336
+ ides_to_check = [ide] if ide else list(IDE_CONFIGS.keys())
337
+
338
+ for ide_name in ides_to_check:
339
+ if ide_name not in IDE_CONFIGS:
340
+ rprint(f"[yellow]Unknown IDE: {ide_name}[/yellow]")
341
+ continue
342
+
343
+ config = IDE_CONFIGS[ide_name]
344
+ rprint(f"[cyan]Checking {ide_name}...[/cyan]")
345
+
346
+ check_fn = {
347
+ "claude-code": _check_claude_code,
348
+ "kiro": _check_kiro,
349
+ "cursor": _check_cursor,
350
+ "gemini-cli": _check_gemini,
351
+ }.get(ide_name)
352
+
353
+ found_any = False
354
+ for path_list_key in ["user_settings", "project_settings"]:
355
+ for path in config[path_list_key]:
356
+ if path.exists():
357
+ found_any = True
358
+ data = _load_json(path)
359
+ if data is None:
360
+ issues.append(f"{path}: file exists but is not valid JSON.")
361
+ elif check_fn:
362
+ check_fn(path, data, issues, warnings)
363
+
364
+ for path in config.get("mcp", []):
365
+ if path.exists():
366
+ found_any = True
367
+ data = _load_json(path)
368
+ if data is not None:
369
+ _check_mcp_json(path, data, issues, warnings)
370
+
371
+ if not found_any:
372
+ rprint(f" [dim]No config files found for {ide_name}[/dim]")
373
+
374
+ # 4. Report
375
+ rprint("")
376
+ if not issues and not warnings:
377
+ rprint("[bold green]All clear![/bold green] No issues found.")
378
+ raise typer.Exit(0)
379
+
380
+ if issues:
381
+ rprint(f"[bold red]{len(issues)} issue(s):[/bold red]")
382
+ for i, issue in enumerate(issues, 1):
383
+ rprint(f" [red]{i}.[/red] {issue}")
384
+
385
+ if warnings:
386
+ rprint(f"\n[bold yellow]{len(warnings)} warning(s):[/bold yellow]")
387
+ for i, warning in enumerate(warnings, 1):
388
+ rprint(f" [yellow]{i}.[/yellow] {warning}")
389
+
390
+ if fix and issues:
391
+ rprint("\n[bold]Suggested fixes:[/bold]")
392
+ for issue in issues:
393
+ if "disableAllHooks" in issue:
394
+ rprint(" Set `disableAllHooks: false` in your Claude Code settings.json")
395
+ elif "allowedHttpHookUrls" in issue:
396
+ rprint(' Add `"http://localhost:8000/*"` to `allowedHttpHookUrls`')
397
+ elif "OBSERVAL_API_KEY" in issue and "httpHookAllowedEnvVars" in issue:
398
+ rprint(' Add `"OBSERVAL_API_KEY"` to `httpHookAllowedEnvVars`')
399
+ elif "allowManagedHooksOnly" in issue:
400
+ rprint(" Set `allowManagedHooksOnly: false` or add Observal hooks to managed config")
401
+ elif "observal auth login" in issue:
402
+ rprint(" Run: observal auth login")
403
+ elif "Cannot reach" in issue:
404
+ rprint(" Start the server: cd docker && docker compose up -d")
405
+ elif "kiro-cli" in issue and "not found" in issue:
406
+ rprint(" Install: curl -fsSL https://cli.kiro.dev/install | bash")
407
+ elif "kiro-cli" in issue and "not authenticated" in issue:
408
+ rprint(" Run: kiro-cli login")
409
+ elif "Observal telemetry hooks" in issue:
410
+ rprint(" Run: observal scan --ide kiro --home")
411
+ elif "observal-shim" in issue and "Kiro" in issue:
412
+ rprint(" Run: observal scan --ide kiro")
413
+
414
+ raise typer.Exit(1 if issues else 0)
415
+
416
+
417
+ # ── SLI: reinstall hooks ──────────────────────────────────
418
+
419
+ # Kiro camelCase event mapping and all supported events
420
+ _KIRO_EVENT_MAP = {
421
+ "SessionStart": "agentSpawn",
422
+ "UserPromptSubmit": "userPromptSubmit",
423
+ "PreToolUse": "preToolUse",
424
+ "PostToolUse": "postToolUse",
425
+ "Stop": "stop",
426
+ }
427
+
428
+ # All Claude Code events that should have hooks
429
+ _ALL_EVENTS = [
430
+ "SessionStart",
431
+ "UserPromptSubmit",
432
+ "PreToolUse",
433
+ "PostToolUse",
434
+ "PostToolUseFailure",
435
+ "SubagentStart",
436
+ "SubagentStop",
437
+ "Stop",
438
+ "StopFailure",
439
+ "Notification",
440
+ "TaskCreated",
441
+ "TaskCompleted",
442
+ "PreCompact",
443
+ "PostCompact",
444
+ "WorktreeCreate",
445
+ "WorktreeRemove",
446
+ "Elicitation",
447
+ "ElicitationResult",
448
+ ]
449
+
450
+
451
+ def _find_hook_script(name: str) -> str | None:
452
+ """Locate a hook script by filename."""
453
+ candidates = [
454
+ Path(__file__).parent / "hooks" / name,
455
+ Path(shutil.which(name) or ""),
456
+ ]
457
+ for p in candidates:
458
+ if p.is_file():
459
+ return str(p.resolve())
460
+ return None
461
+
462
+
463
+ def _install_claude_code_hooks(server_url: str, api_key: str) -> list[str]:
464
+ """Reconcile Claude Code hooks into ~/.claude/settings.json."""
465
+ hooks_url = f"{server_url.rstrip('/')}/api/v1/otel/hooks"
466
+ hook_script = _find_hook_script("observal-hook.sh")
467
+ stop_script = _find_hook_script("observal-stop-hook.sh")
468
+ cfg = config.load()
469
+ user_id = cfg.get("user_id", "")
470
+
471
+ desired_hooks = get_desired_hooks(hook_script, stop_script, hooks_url, user_id)
472
+ desired_env = get_desired_env(server_url, api_key, user_id)
473
+
474
+ return settings_reconciler.reconcile(desired_hooks, desired_env)
475
+
476
+
477
+ def _install_kiro_hooks(server_url: str) -> tuple[list[str], bool]:
478
+ """Install Observal hooks into all Kiro agent configs.
479
+
480
+ Returns (messages, changed) where changed is True if any file was modified.
481
+ """
482
+ agents_dir = Path.home() / ".kiro" / "agents"
483
+ changes: list[str] = []
484
+ changed = False
485
+
486
+ agents_dir.mkdir(parents=True, exist_ok=True)
487
+
488
+ agent_files = list(agents_dir.glob("*.json"))
489
+
490
+ hooks_url = f"{server_url.rstrip('/')}/api/v1/otel/hooks"
491
+
492
+ # Locate the Kiro hook scripts
493
+ hook_py = Path(__file__).parent / "hooks" / "kiro_hook.py"
494
+ stop_py = Path(__file__).parent / "hooks" / "kiro_stop_hook.py"
495
+
496
+ if not hook_py.is_file() or not stop_py.is_file():
497
+ return ["[red]Cannot find kiro_hook.py / kiro_stop_hook.py — reinstall Observal CLI[/red]"], False
498
+
499
+ hook_py_str = str(hook_py.resolve())
500
+ stop_py_str = str(stop_py.resolve())
501
+
502
+ # Migrate: remove old default.json created by earlier Observal versions.
503
+ old_default = agents_dir / "default.json"
504
+ if old_default.exists():
505
+ try:
506
+ od = json.loads(old_default.read_text())
507
+ if od.get("name") == "default" and any(
508
+ "otel/hooks" in h.get("command", "")
509
+ for hs in od.get("hooks", {}).values()
510
+ if isinstance(hs, list)
511
+ for h in hs
512
+ ):
513
+ old_default.unlink()
514
+ kiro_bin = shutil.which("kiro-cli") or shutil.which("kiro") or shutil.which("kiro-cli-chat")
515
+ if kiro_bin:
516
+ import subprocess
517
+
518
+ subprocess.run(
519
+ [kiro_bin, "agent", "set-default", "kiro_default"],
520
+ capture_output=True,
521
+ timeout=10,
522
+ )
523
+ changes.append("- default: removed (migrated to kiro_default)")
524
+ changed = True
525
+ except (ValueError, OSError):
526
+ pass
527
+ agent_files = list(agents_dir.glob("*.json"))
528
+
529
+ # Create kiro_default agent config if it doesn't exist, so hooks attach to
530
+ # the built-in kiro_default agent instead of a separate workspace agent.
531
+ default_agent = agents_dir / "kiro_default.json"
532
+ if not default_agent.exists():
533
+ cmd = "cat | python3 " + hook_py_str + " --url " + hooks_url + " --agent-name kiro_default"
534
+ stop_cmd = "cat | python3 " + stop_py_str + " --url " + hooks_url + " --agent-name kiro_default"
535
+ default_agent.write_text(
536
+ json.dumps(
537
+ {
538
+ "name": "kiro_default",
539
+ "hooks": {
540
+ "agentSpawn": [{"command": cmd}],
541
+ "userPromptSubmit": [{"command": cmd}],
542
+ "preToolUse": [{"matcher": "*", "command": cmd}],
543
+ "postToolUse": [{"matcher": "*", "command": cmd}],
544
+ "stop": [{"command": stop_cmd}],
545
+ },
546
+ },
547
+ indent=2,
548
+ )
549
+ + "\n"
550
+ )
551
+ changes.append("+ kiro_default: created with Observal hooks")
552
+ changed = True
553
+ agent_files = list(agents_dir.glob("*.json"))
554
+
555
+ for af in agent_files:
556
+ agent_name = af.stem
557
+ try:
558
+ data = json.loads(af.read_text())
559
+ except (json.JSONDecodeError, OSError):
560
+ changes.append(f"[yellow]⚠ {agent_name}: could not parse, skipped[/yellow]")
561
+ continue
562
+
563
+ # Build per-agent hook command (kiro_hook.py handles all metadata natively)
564
+ generic_cmd = "cat | python3 " + hook_py_str + " --url " + hooks_url + " --agent-name " + agent_name
565
+ stop_cmd = "cat | python3 " + stop_py_str + " --url " + hooks_url + " --agent-name " + agent_name
566
+
567
+ desired_kiro_hooks: dict[str, list[dict]] = {}
568
+ for event in _ALL_EVENTS:
569
+ kiro_event = _KIRO_EVENT_MAP.get(event)
570
+ if not kiro_event:
571
+ continue
572
+ if kiro_event == "stop":
573
+ desired_kiro_hooks[kiro_event] = [{"command": stop_cmd}]
574
+ else:
575
+ entry: dict = {"command": generic_cmd}
576
+ if kiro_event in ("preToolUse", "postToolUse"):
577
+ entry["matcher"] = "*"
578
+ desired_kiro_hooks[kiro_event] = [entry]
579
+
580
+ current_hooks = data.get("hooks", {})
581
+ updated = False
582
+
583
+ for kiro_event, desired_entries in desired_kiro_hooks.items():
584
+ existing = current_hooks.get(kiro_event, [])
585
+ # Check if Observal hook already present
586
+ has_observal = any(
587
+ "observal" in h.get("command", "") or "otel/hooks" in h.get("command", "") for h in existing
588
+ )
589
+ if not has_observal:
590
+ # Append our hooks, keep existing ones
591
+ current_hooks[kiro_event] = existing + desired_entries
592
+ updated = True
593
+
594
+ if updated:
595
+ data["hooks"] = current_hooks
596
+ af.write_text(json.dumps(data, indent=2) + "\n")
597
+ changes.append(f"+ {agent_name}: added Observal hooks")
598
+ changed = True
599
+ else:
600
+ changes.append(f"[dim] {agent_name}: already has Observal hooks[/dim]")
601
+
602
+ return changes, changed
603
+
604
+
605
+ @doctor_app.command(name="sli")
606
+ def doctor_sli(
607
+ ide: str = typer.Option(
608
+ None,
609
+ "--ide",
610
+ "-i",
611
+ help="Target IDE only (claude-code, kiro). Default: both.",
612
+ ),
613
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show changes without applying"),
614
+ ):
615
+ """Re-install Observal telemetry hooks into Claude Code and/or Kiro.
616
+
617
+ Repairs missing or outdated hooks non-destructively — your existing
618
+ hooks and settings are preserved.
619
+ """
620
+ cfg = config.load()
621
+ server_url = cfg.get("server_url")
622
+ api_key = cfg.get("api_key", "")
623
+
624
+ if not server_url:
625
+ rprint("[red]Not configured. Run [bold]observal auth login[/bold] first.[/red]")
626
+ raise typer.Exit(1)
627
+
628
+ targets = [ide] if ide else ["claude-code", "kiro"]
629
+ any_changes = False
630
+
631
+ for target in targets:
632
+ if target == "claude-code":
633
+ claude_dir = Path.home() / ".claude"
634
+ if not claude_dir.is_dir() and not shutil.which("claude"):
635
+ rprint("[dim]Claude Code not detected — skipping[/dim]")
636
+ continue
637
+
638
+ rprint("[cyan]Claude Code[/cyan]")
639
+ if dry_run:
640
+ hooks_url = f"{server_url.rstrip('/')}/api/v1/otel/hooks"
641
+ hook_script = _find_hook_script("observal-hook.sh")
642
+ stop_script = _find_hook_script("observal-stop-hook.sh")
643
+ user_id = cfg.get("user_id", "")
644
+ desired_hooks = get_desired_hooks(hook_script, stop_script, hooks_url, user_id)
645
+ desired_env = get_desired_env(server_url, api_key, user_id)
646
+ changes = settings_reconciler.reconcile(desired_hooks, desired_env, dry_run=True)
647
+ else:
648
+ changes = _install_claude_code_hooks(server_url, api_key)
649
+
650
+ if changes:
651
+ any_changes = True
652
+ for c in changes:
653
+ rprint(f" {c}")
654
+ else:
655
+ rprint(" [dim]Already up to date[/dim]")
656
+
657
+ elif target in ("kiro", "kiro-cli"):
658
+ rprint("[cyan]Kiro[/cyan]")
659
+ if dry_run:
660
+ rprint(" [yellow]Dry run not supported for Kiro — use without --dry-run[/yellow]")
661
+ continue
662
+
663
+ messages, kiro_changed = _install_kiro_hooks(server_url)
664
+ if kiro_changed:
665
+ any_changes = True
666
+ for c in messages:
667
+ rprint(f" {c}")
668
+ else:
669
+ rprint(f"[yellow]Unknown IDE: {target}. Use 'claude-code' or 'kiro'.[/yellow]")
670
+
671
+ if any_changes:
672
+ rprint("\n[green]✓ Hooks installed.[/green] Restart your IDE session to pick up changes.")
673
+ elif not dry_run:
674
+ rprint("\n[dim]All hooks already up to date.[/dim]")