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
|
@@ -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]")
|