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,246 @@
1
+ """Hook registry CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import sys
7
+
8
+ import typer
9
+ from rich import print as rprint
10
+ from rich.table import Table
11
+
12
+ from observal_cli import client, config, settings_reconciler
13
+ from observal_cli.constants import VALID_HOOK_EVENTS, VALID_HOOK_HANDLER_TYPES
14
+ from observal_cli.hooks_spec import get_desired_env, get_desired_hooks
15
+ from observal_cli.prompts import select_one
16
+ from observal_cli.render import console, kv_panel, output_json, relative_time, spinner, status_badge
17
+
18
+ hook_app = typer.Typer(help="Hook registry commands")
19
+
20
+
21
+ def register_hook(app: typer.Typer):
22
+ app.add_typer(hook_app, name="hook")
23
+
24
+
25
+ @hook_app.command(name="submit")
26
+ def hook_submit(
27
+ from_file: str | None = typer.Option(None, "--from-file", "-f", help="Create from JSON file"),
28
+ draft: bool = typer.Option(False, "--draft", help="Save as draft instead of submitting for review"),
29
+ submit_draft: str | None = typer.Option(None, "--submit", help="Submit a draft for review (hook ID)"),
30
+ ):
31
+ """Submit a new hook for review."""
32
+ if draft and submit_draft:
33
+ rprint(
34
+ "[red]Cannot use --draft and --submit together.[/red] Use --draft to save a new draft, or --submit to submit an existing draft."
35
+ )
36
+ raise typer.Exit(code=1)
37
+ if submit_draft:
38
+ resolved = config.resolve_alias(submit_draft)
39
+ with spinner("Submitting draft for review..."):
40
+ result = client.post(f"/api/v1/hooks/{resolved}/submit")
41
+ rprint(f"[green]✓ Draft submitted for review![/green] ID: [bold]{result['id']}[/bold]")
42
+ return
43
+
44
+ if from_file:
45
+ try:
46
+ with open(from_file) as f:
47
+ payload = _json.load(f)
48
+ except _json.JSONDecodeError as e:
49
+ rprint(f"[red]Invalid JSON in {from_file}:[/red] {e}")
50
+ raise typer.Exit(code=1)
51
+ except FileNotFoundError:
52
+ rprint(f"[red]File not found:[/red] {from_file}")
53
+ raise typer.Exit(code=1)
54
+ else:
55
+ payload = {
56
+ "name": typer.prompt("Hook name"),
57
+ "version": typer.prompt("Version", default="1.0.0"),
58
+ "description": typer.prompt("Description"),
59
+ "owner": typer.prompt("Owner"),
60
+ "event": select_one("Event", VALID_HOOK_EVENTS),
61
+ "handler_type": select_one("Handler type", VALID_HOOK_HANDLER_TYPES),
62
+ "handler_config": _json.loads(typer.prompt("Handler config (JSON)")),
63
+ }
64
+
65
+ if draft:
66
+ with spinner("Saving draft..."):
67
+ result = client.post("/api/v1/hooks/draft", payload)
68
+ rprint(f"[green]✓ Draft saved![/green] ID: [bold]{result['id']}[/bold]")
69
+ else:
70
+ with spinner("Submitting hook..."):
71
+ result = client.post("/api/v1/hooks/submit", payload)
72
+ rprint(f"[green]✓ Hook submitted![/green] ID: [bold]{result['id']}[/bold]")
73
+
74
+
75
+ @hook_app.command(name="list")
76
+ def hook_list(
77
+ event: str | None = typer.Option(None, "--event", "-e"),
78
+ scope: str | None = typer.Option(None, "--scope"),
79
+ search: str | None = typer.Option(None, "--search", "-s"),
80
+ output: str = typer.Option("table", "--output", "-o", help="Output: table, json, plain"),
81
+ ):
82
+ """List approved hooks."""
83
+ params = {}
84
+ if event:
85
+ params["event"] = event
86
+ if scope:
87
+ params["scope"] = scope
88
+ if search:
89
+ params["search"] = search
90
+ with spinner("Fetching hooks..."):
91
+ data = client.get("/api/v1/hooks", params=params)
92
+ if not data:
93
+ rprint("[dim]No hooks found.[/dim]")
94
+ return
95
+ config.save_last_results(data)
96
+ if output == "json":
97
+ output_json(data)
98
+ return
99
+ if output == "plain":
100
+ for item in data:
101
+ rprint(f"{item['id']} {item['name']} v{item.get('version', '?')}")
102
+ return
103
+ table = Table(title=f"Hooks ({len(data)})", show_lines=False, padding=(0, 1))
104
+ table.add_column("#", style="dim", width=3)
105
+ table.add_column("Name", style="bold cyan", no_wrap=True)
106
+ table.add_column("Version", style="green")
107
+ table.add_column("Owner", style="dim")
108
+ table.add_column("Status")
109
+ table.add_column("ID", style="dim", max_width=12)
110
+ for i, item in enumerate(data, 1):
111
+ table.add_row(
112
+ str(i),
113
+ item["name"],
114
+ item.get("version", ""),
115
+ item.get("owner", ""),
116
+ status_badge(item.get("status", "")),
117
+ str(item["id"])[:8] + "…",
118
+ )
119
+ console.print(table)
120
+
121
+
122
+ @hook_app.command(name="show")
123
+ def hook_show(
124
+ hook_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
125
+ output: str = typer.Option("table", "--output", "-o"),
126
+ ):
127
+ """Show hook details."""
128
+ resolved = config.resolve_alias(hook_id)
129
+ with spinner():
130
+ item = client.get(f"/api/v1/hooks/{resolved}")
131
+ if output == "json":
132
+ output_json(item)
133
+ return
134
+ console.print(
135
+ kv_panel(
136
+ f"{item['name']} v{item.get('version', '?')}",
137
+ [
138
+ ("Status", status_badge(item.get("status", ""))),
139
+ ("Event", item.get("event", "N/A")),
140
+ ("Handler Type", item.get("handler_type", "N/A")),
141
+ ("Owner", item.get("owner", "N/A")),
142
+ ("Description", item.get("description", "")),
143
+ ("Created", relative_time(item.get("created_at"))),
144
+ ("ID", f"[dim]{item['id']}[/dim]"),
145
+ ],
146
+ border_style="yellow",
147
+ )
148
+ )
149
+
150
+
151
+ @hook_app.command(name="install")
152
+ def hook_install(
153
+ hook_id: str = typer.Argument(..., help="Hook ID, name, row number, or @alias"),
154
+ ide: str = typer.Option(..., "--ide", "-i", help="Target IDE"),
155
+ raw: bool = typer.Option(False, "--raw", help="Output raw JSON only"),
156
+ ):
157
+ """Get install config for a hook."""
158
+ resolved = config.resolve_alias(hook_id)
159
+ with spinner(f"Generating {ide} config..."):
160
+ result = client.post(f"/api/v1/hooks/{resolved}/install", {"ide": ide, "platform": sys.platform})
161
+ snippet = result.get("config_snippet", result)
162
+ if raw:
163
+ print(_json.dumps(snippet, indent=2))
164
+ return
165
+ rprint(f"\n[bold]Config for {ide}:[/bold]\n")
166
+ console.print_json(_json.dumps(snippet, indent=2))
167
+
168
+
169
+ @hook_app.command(name="delete")
170
+ def hook_delete(
171
+ hook_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
172
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
173
+ ):
174
+ """Delete a hook."""
175
+ resolved = config.resolve_alias(hook_id)
176
+ if not yes:
177
+ with spinner():
178
+ item = client.get(f"/api/v1/hooks/{resolved}")
179
+ if not typer.confirm(f"Delete [bold]{item['name']}[/bold] ({resolved})?"):
180
+ raise typer.Abort()
181
+ with spinner("Deleting..."):
182
+ client.delete(f"/api/v1/hooks/{resolved}")
183
+ rprint(f"[green]✓ Deleted {resolved}[/green]")
184
+
185
+
186
+ def _find_hook_script(name: str) -> str | None:
187
+ """Locate hook script by name (same search logic as cmd_auth)."""
188
+ from pathlib import Path
189
+
190
+ candidates = [
191
+ Path(__file__).resolve().parent / "hooks" / name,
192
+ Path.home() / ".observal" / "hooks" / name,
193
+ ]
194
+ for p in candidates:
195
+ if p.is_file():
196
+ return str(p.resolve())
197
+ return None
198
+
199
+
200
+ @hook_app.command(name="sync")
201
+ def hook_sync(
202
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show changes without applying"),
203
+ ):
204
+ """Sync Claude Code hooks to the latest Observal spec.
205
+
206
+ Non-destructively updates ~/.claude/settings.json: adds missing hooks,
207
+ upgrades stale Observal hooks, and preserves any non-Observal hooks
208
+ you've added.
209
+ """
210
+ cfg = config.load()
211
+
212
+ server_url = cfg.get("server_url")
213
+ access_token = cfg.get("access_token")
214
+ if not server_url or not access_token:
215
+ rprint("[red]Not authenticated. Run [bold]observal auth login[/bold] first.[/red]")
216
+ raise typer.Exit(1)
217
+
218
+ hooks_url = f"{server_url.rstrip('/')}/api/v1/otel/hooks"
219
+ hook_script = _find_hook_script("observal-hook.sh")
220
+ stop_script = _find_hook_script("observal-stop-hook.sh")
221
+ user_id = cfg.get("user_id", "")
222
+
223
+ desired_hooks = get_desired_hooks(hook_script, stop_script, hooks_url, user_id)
224
+ desired_env = get_desired_env(server_url, access_token, user_id)
225
+
226
+ applied = settings_reconciler.get_applied_version()
227
+ from observal_cli.hooks_spec import HOOKS_SPEC_VERSION
228
+
229
+ if dry_run:
230
+ changes = settings_reconciler.reconcile(desired_hooks, desired_env, dry_run=True)
231
+ if changes:
232
+ rprint(f"[yellow]Dry run[/yellow] — spec v{applied} → v{HOOKS_SPEC_VERSION}:")
233
+ for change in changes:
234
+ rprint(f" {change}")
235
+ else:
236
+ rprint(f"[dim]Already at spec v{HOOKS_SPEC_VERSION}, no changes needed.[/dim]")
237
+ return
238
+
239
+ changes = settings_reconciler.reconcile(desired_hooks, desired_env)
240
+
241
+ if changes:
242
+ rprint(f"[green]✓ Synced[/green] hooks spec v{applied} → v{HOOKS_SPEC_VERSION}:")
243
+ for change in changes:
244
+ rprint(f" {change}")
245
+ else:
246
+ rprint(f"[dim]Already at spec v{HOOKS_SPEC_VERSION}, no changes needed.[/dim]")