observal-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- observal_cli/README.md +150 -0
- observal_cli/__init__.py +0 -0
- observal_cli/analyzer.py +565 -0
- observal_cli/branding.py +19 -0
- observal_cli/client.py +264 -0
- observal_cli/cmd_agent.py +783 -0
- observal_cli/cmd_auth.py +823 -0
- observal_cli/cmd_doctor.py +674 -0
- observal_cli/cmd_hook.py +246 -0
- observal_cli/cmd_mcp.py +1044 -0
- observal_cli/cmd_migrate.py +764 -0
- observal_cli/cmd_ops.py +1250 -0
- observal_cli/cmd_profile.py +308 -0
- observal_cli/cmd_prompt.py +200 -0
- observal_cli/cmd_pull.py +324 -0
- observal_cli/cmd_sandbox.py +178 -0
- observal_cli/cmd_scan.py +1056 -0
- observal_cli/cmd_skill.py +202 -0
- observal_cli/cmd_uninstall.py +340 -0
- observal_cli/config.py +160 -0
- observal_cli/constants.py +151 -0
- observal_cli/hooks/__init__.py +0 -0
- observal_cli/hooks/buffer_event.py +97 -0
- observal_cli/hooks/flush_buffer.py +141 -0
- observal_cli/hooks/kiro_hook.py +210 -0
- observal_cli/hooks/kiro_stop_hook.py +220 -0
- observal_cli/hooks/observal-hook.sh +31 -0
- observal_cli/hooks/observal-stop-hook.sh +134 -0
- observal_cli/hooks/payload_crypto.py +78 -0
- observal_cli/hooks_spec.py +154 -0
- observal_cli/main.py +105 -0
- observal_cli/prompts.py +92 -0
- observal_cli/proxy.py +205 -0
- observal_cli/render.py +139 -0
- observal_cli/requirements.txt +3 -0
- observal_cli/sandbox_runner.py +217 -0
- observal_cli/settings_reconciler.py +188 -0
- observal_cli/shim.py +459 -0
- observal_cli/telemetry_buffer.py +163 -0
- observal_cli-0.2.0.dist-info/METADATA +528 -0
- observal_cli-0.2.0.dist-info/RECORD +44 -0
- observal_cli-0.2.0.dist-info/WHEEL +4 -0
- observal_cli-0.2.0.dist-info/entry_points.txt +5 -0
- observal_cli-0.2.0.dist-info/licenses/LICENSE +108 -0
observal_cli/cmd_hook.py
ADDED
|
@@ -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]")
|