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,783 @@
1
+ """Agent CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ import yaml
11
+ from rich import print as rprint
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.tree import Tree
15
+
16
+ from observal_cli import client, config
17
+ from observal_cli.constants import AGENT_NAME_REGEX, VALID_IDES
18
+ from observal_cli.prompts import fuzzy_select, select_many, select_one
19
+ from observal_cli.render import (
20
+ console,
21
+ ide_tags,
22
+ kv_panel,
23
+ output_json,
24
+ relative_time,
25
+ spinner,
26
+ status_badge,
27
+ )
28
+
29
+ # ── Agent authoring constants ──────────────────────────────
30
+ YAML_FILE = "observal-agent.yaml"
31
+ VALID_COMPONENT_TYPES = {"mcp", "skill", "hook", "prompt", "sandbox"}
32
+
33
+ # Common model choices for the interactive wizard
34
+ _MODEL_CHOICES = [
35
+ "claude-sonnet-4",
36
+ "claude-opus-4",
37
+ "claude-haiku-4-5",
38
+ "gemini-2.5-pro",
39
+ "gpt-4o",
40
+ "gpt-4.1",
41
+ ]
42
+
43
+
44
+ def _slugify(raw: str) -> str:
45
+ """Convert a raw name to a valid agent slug."""
46
+ s = raw.strip().lower()
47
+ s = re.sub(r"[^a-z0-9_-]+", "-", s)
48
+ s = re.sub(r"-{2,}", "-", s)
49
+ return s.strip("-")
50
+
51
+
52
+ def _validate_name(name: str) -> str | None:
53
+ """Return error message if name is invalid, else None."""
54
+ if not name:
55
+ return "Agent name is required."
56
+ if len(name) > 64:
57
+ return "Agent name must be at most 64 characters."
58
+ if not AGENT_NAME_REGEX.match(name):
59
+ return "Must start with a letter/digit and contain only lowercase letters, digits, hyphens, underscores."
60
+ return None
61
+
62
+
63
+ def _fetch_registry_items(component_type: str) -> list[dict]:
64
+ """Fetch approved items from a registry endpoint. Returns [] on failure."""
65
+ plural = {"mcp": "mcps", "skill": "skills", "hook": "hooks", "prompt": "prompts", "sandbox": "sandboxes"}
66
+ try:
67
+ return client.get(f"/api/v1/{plural[component_type]}")
68
+ except (Exception, SystemExit):
69
+ return []
70
+
71
+
72
+ # ── Agent authoring helpers ────────────────────────────────
73
+ def _load_agent_yaml(directory: Path) -> dict:
74
+ """Load and return the agent YAML from *directory*. Exits if missing."""
75
+ path = directory / YAML_FILE
76
+ if not path.exists():
77
+ rprint(f"[red]Error:[/red] {YAML_FILE} not found in {directory}")
78
+ raise typer.Exit(code=1)
79
+ with open(path) as f:
80
+ return yaml.safe_load(f)
81
+
82
+
83
+ def _save_agent_yaml(directory: Path, data: dict) -> None:
84
+ """Write *data* as YAML to *directory*/observal-agent.yaml."""
85
+ path = directory / YAML_FILE
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ with open(path, "w") as f:
88
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
89
+
90
+
91
+ agent_app = typer.Typer(help="Agent registry commands")
92
+
93
+
94
+ @agent_app.command(name="create")
95
+ def agent_create(
96
+ from_file: str | None = typer.Option(None, "--from-file", "-f", help="Create from JSON file"),
97
+ ):
98
+ """Create a new agent (interactive wizard or from file)."""
99
+ if from_file:
100
+ import json
101
+
102
+ with open(from_file) as f:
103
+ payload = json.load(f)
104
+ with spinner("Creating agent..."):
105
+ result = client.post("/api/v1/agents", payload)
106
+ status = result.get("status", "pending")
107
+ rprint(f"[green]✓ Agent submitted for review![/green] ID: [bold]{result['id']}[/bold]")
108
+ rprint(f"[yellow]Status: {status} — an admin must approve it before it becomes visible.[/yellow]")
109
+ return
110
+
111
+ rprint("\n[bold cyan]Agent Builder[/bold cyan]\n")
112
+
113
+ # ── Phase 1: Basics ─────────────────────────────────────
114
+ rprint("[bold]1. Basics[/bold]")
115
+ raw_name = typer.prompt(" Agent name")
116
+ name = _slugify(raw_name)
117
+ if name != raw_name:
118
+ rprint(f" [dim]→ Slugified to:[/dim] [bold]{name}[/bold]")
119
+ err = _validate_name(name)
120
+ if err:
121
+ rprint(f" [red]Error:[/red] {err}")
122
+ raise typer.Exit(1)
123
+
124
+ description = typer.prompt(" Description")
125
+ version = typer.prompt(" Version", default="1.0.0")
126
+ model_name = select_one(" Model", _MODEL_CHOICES, default="claude-sonnet-4")
127
+
128
+ # ── Phase 2: Components ──────────────────────────────────
129
+ rprint("\n[bold]2. Components[/bold]")
130
+ components: list[dict] = []
131
+
132
+ with spinner("Fetching registry..."):
133
+ registry_data: dict[str, list[dict]] = {}
134
+ for ctype in ("mcp", "skill", "hook", "prompt", "sandbox"):
135
+ registry_data[ctype] = _fetch_registry_items(ctype)
136
+
137
+ for ctype in ("mcp", "skill", "hook", "prompt", "sandbox"):
138
+ items = registry_data[ctype]
139
+ if not items:
140
+ rprint(f" [dim]No {ctype}s available — skipping.[/dim]")
141
+ continue
142
+
143
+ choices = [f"{item['name']} [dim]({str(item['id'])[:8]})[/dim]" for item in items]
144
+ selected = select_many(f" Select {ctype}s", choices, defaults=[])
145
+
146
+ for sel in selected:
147
+ # Match back to item by prefix (name part before the dim ID)
148
+ sel_name = sel.split(" [dim]")[0].strip()
149
+ match = next((item for item in items if item["name"] == sel_name), None)
150
+ if match:
151
+ components.append({"component_type": ctype, "component_id": str(match["id"])})
152
+
153
+ # ── Phase 3: IDEs ────────────────────────────────────────
154
+ rprint("\n[bold]3. Supported IDEs[/bold]")
155
+ supported_ides = select_many(" IDEs", list(VALID_IDES), defaults=list(VALID_IDES))
156
+
157
+ # ── Phase 4: Goal Template ───────────────────────────────
158
+ rprint("\n[bold]4. Goal Template[/bold]")
159
+ goal_desc = typer.prompt(" Goal description", default=description)
160
+ sections = []
161
+ while True:
162
+ sec_name = typer.prompt(" Section name (or 'done' to finish)")
163
+ if sec_name.lower() == "done":
164
+ break
165
+ sec_desc = typer.prompt(f" Description for '{sec_name}'", default="")
166
+ sections.append({"name": sec_name, "description": sec_desc})
167
+
168
+ if not sections:
169
+ sections = [{"name": "default", "description": goal_desc}]
170
+ rprint(" [dim]Using default section.[/dim]")
171
+
172
+ # ── Phase 5: Optional Details ────────────────────────────
173
+ rprint("\n[bold]5. Optional Details[/bold]")
174
+ # Try to get owner from whoami
175
+ default_owner = ""
176
+ try:
177
+ whoami = client.get("/api/v1/auth/whoami")
178
+ default_owner = whoami.get("name") or whoami.get("email", "")
179
+ except (Exception, SystemExit):
180
+ pass
181
+ owner = typer.prompt(" Owner / Team", default=default_owner or "")
182
+ prompt_text = typer.prompt(" System prompt (optional)", default="")
183
+ max_tokens = typer.prompt(" Max tokens", default="4096")
184
+ temperature = typer.prompt(" Temperature", default="0.2")
185
+ model_cfg = {"max_tokens": int(max_tokens), "temperature": float(temperature)}
186
+
187
+ # ── Phase 6: Review & Confirm ────────────────────────────
188
+ component_summary = (
189
+ ", ".join(
190
+ f"{sum(1 for c in components if c['component_type'] == t)} {t}s"
191
+ for t in ("mcp", "skill", "hook", "prompt", "sandbox")
192
+ if any(c["component_type"] == t for c in components)
193
+ )
194
+ or "none"
195
+ )
196
+
197
+ review = (
198
+ f"[bold]{name}[/bold] v{version} | Model: [cyan]{model_name}[/cyan]\n"
199
+ f"Components: {component_summary}\n"
200
+ f"IDEs: {', '.join(supported_ides)}\n"
201
+ f"Goal: {len(sections)} section(s)"
202
+ )
203
+ console.print(Panel(review, title="Review", border_style="green"))
204
+
205
+ if not typer.confirm("\nSubmit this agent for review?", default=True):
206
+ rprint("[yellow]Aborted.[/yellow]")
207
+ raise typer.Exit(0)
208
+
209
+ with spinner("Creating agent..."):
210
+ result = client.post(
211
+ "/api/v1/agents",
212
+ {
213
+ "name": name,
214
+ "version": version,
215
+ "description": description,
216
+ "owner": owner,
217
+ "prompt": prompt_text,
218
+ "model_name": model_name,
219
+ "model_config_json": model_cfg,
220
+ "supported_ides": supported_ides,
221
+ "components": components,
222
+ "goal_template": {"description": goal_desc, "sections": sections},
223
+ },
224
+ )
225
+ status = result.get("status", "pending")
226
+ rprint(f"\n[green]✓ Agent submitted for review![/green] ID: [bold]{result['id']}[/bold]")
227
+ rprint(f"[yellow]Status: {status} — an admin must approve it before it becomes visible.[/yellow]")
228
+
229
+
230
+ @agent_app.command(name="bulk-create")
231
+ def agent_bulk_create(
232
+ file_path: str = typer.Option(..., "--from-file", help="JSON file with agent definitions"),
233
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview without creating"),
234
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
235
+ ):
236
+ """Bulk-create agents from a JSON file."""
237
+ import json
238
+
239
+ path = Path(file_path)
240
+ if not path.exists():
241
+ rprint(f"[red]Error:[/red] File not found: {file_path}")
242
+ raise typer.Exit(code=1)
243
+
244
+ try:
245
+ with open(path) as f:
246
+ raw = json.load(f)
247
+ except json.JSONDecodeError as exc:
248
+ rprint(f"[red]Error:[/red] Invalid JSON: {exc}")
249
+ raise typer.Exit(code=1)
250
+
251
+ # Accept {"agents": [...]} or bare [...]
252
+ if isinstance(raw, list):
253
+ agents = raw
254
+ elif isinstance(raw, dict) and "agents" in raw:
255
+ agents = raw["agents"]
256
+ else:
257
+ rprint('[red]Error:[/red] JSON must be {"agents": [...]} or a bare array.')
258
+ raise typer.Exit(code=1)
259
+
260
+ if not agents:
261
+ rprint("[yellow]No agents found in file.[/yellow]")
262
+ raise typer.Exit(code=1)
263
+
264
+ # ── Preview table ────────────────────────────────────────
265
+ preview = Table(title=f"Agents to create ({len(agents)})", show_lines=False, padding=(0, 1))
266
+ preview.add_column("#", style="dim", width=3)
267
+ preview.add_column("Name", style="bold cyan", no_wrap=True)
268
+ preview.add_column("Version", style="green")
269
+ preview.add_column("Components")
270
+ preview.add_column("Model")
271
+ for i, ag in enumerate(agents, 1):
272
+ comp_count = str(len(ag.get("components", [])))
273
+ preview.add_row(
274
+ str(i),
275
+ ag.get("name", "unnamed"),
276
+ ag.get("version", "1.0.0"),
277
+ comp_count,
278
+ ag.get("model_name", "claude-sonnet-4"),
279
+ )
280
+ console.print(preview)
281
+
282
+ # ── Dry-run mode ─────────────────────────────────────────
283
+ if dry_run:
284
+ with spinner("Running dry-run..."):
285
+ result = client.post("/api/v1/bulk/agents", {"agents": agents, "dry_run": True})
286
+
287
+ results_table = Table(title="Dry-run results", show_lines=False, padding=(0, 1))
288
+ results_table.add_column("#", style="dim", width=3)
289
+ results_table.add_column("Name", style="bold cyan", no_wrap=True)
290
+ results_table.add_column("Status")
291
+ results_table.add_column("Error", style="red")
292
+ for i, item in enumerate(result.get("results", []), 1):
293
+ status = item.get("status", "")
294
+ badge = (
295
+ "[green]created[/green]"
296
+ if status == "created"
297
+ else ("[yellow]skipped[/yellow]" if status == "skipped" else f"[red]{status}[/red]")
298
+ )
299
+ results_table.add_row(str(i), item.get("name", ""), badge, item.get("error", "") or "")
300
+ console.print(results_table)
301
+
302
+ rprint(
303
+ f"\n[bold]Summary:[/bold] {result.get('created', 0)} would be created, "
304
+ f"{result.get('skipped', 0)} skipped, {result.get('errors', 0)} errors"
305
+ )
306
+ return
307
+
308
+ # ── Confirmation ─────────────────────────────────────────
309
+ if not yes and not typer.confirm(f"\nCreate {len(agents)} agents?", default=False):
310
+ rprint("[yellow]Aborted.[/yellow]")
311
+ raise typer.Exit(0)
312
+
313
+ # ── Create ───────────────────────────────────────────────
314
+ with spinner("Creating agents..."):
315
+ result = client.post("/api/v1/bulk/agents", {"agents": agents, "dry_run": False})
316
+
317
+ results_table = Table(title="Bulk create results", show_lines=False, padding=(0, 1))
318
+ results_table.add_column("#", style="dim", width=3)
319
+ results_table.add_column("Name", style="bold cyan", no_wrap=True)
320
+ results_table.add_column("Status")
321
+ results_table.add_column("Agent ID", style="dim")
322
+ results_table.add_column("Error", style="red")
323
+ for i, item in enumerate(result.get("results", []), 1):
324
+ status = item.get("status", "")
325
+ badge = (
326
+ "[green]created[/green]"
327
+ if status == "created"
328
+ else ("[yellow]skipped[/yellow]" if status == "skipped" else f"[red]{status}[/red]")
329
+ )
330
+ agent_id = str(item["agent_id"])[:8] + "…" if item.get("agent_id") else ""
331
+ results_table.add_row(str(i), item.get("name", ""), badge, agent_id, item.get("error", "") or "")
332
+ console.print(results_table)
333
+
334
+ rprint(
335
+ f"\n[green]✓ Bulk create complete![/green] "
336
+ f"{result.get('created', 0)} created, {result.get('skipped', 0)} skipped, "
337
+ f"{result.get('errors', 0)} errors"
338
+ )
339
+
340
+
341
+ @agent_app.command(name="list")
342
+ def agent_list(
343
+ search: str | None = typer.Option(None, "--search", "-s"),
344
+ interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive search mode"),
345
+ limit: int = typer.Option(50, "--limit", "-n", min=1, max=200, help="Page size (1-200)"),
346
+ page: int = typer.Option(1, "--page", "-p", min=1, help="Page number (1-indexed)"),
347
+ show_id: bool = typer.Option(False, "--id", help="Include the agent ID column"),
348
+ full_id: bool = typer.Option(False, "--full-id", help="Show full UUID (implies --id)"),
349
+ output: str = typer.Option("table", "--output", "-o", help="Output: table, json, plain"),
350
+ ):
351
+ """List active agents (paginated)."""
352
+ params: dict = {"limit": limit, "offset": (page - 1) * limit}
353
+ if search:
354
+ params["search"] = search
355
+
356
+ with spinner("Fetching agents..."):
357
+ data, headers = client.get_with_headers("/api/v1/agents", params=params)
358
+
359
+ if interactive and data:
360
+
361
+ def _display(item: dict) -> str:
362
+ email = item.get("created_by_email", "")
363
+ suffix = f" by {email}" if email else ""
364
+ return f"{item['name']} v{item.get('version', '?')} {item.get('model_name', '')}{suffix}"
365
+
366
+ selected = fuzzy_select(data, _display, label="Select agent")
367
+ if selected:
368
+ agent_show(selected["id"])
369
+ return
370
+
371
+ total = int(headers.get("x-total-count", str(len(data))))
372
+ total_pages = max(1, (total + limit - 1) // limit)
373
+
374
+ if not data:
375
+ if total == 0:
376
+ rprint("[dim]No agents found.[/dim]")
377
+ else:
378
+ rprint(f"[yellow]Page {page} is empty. Total agents: {total} (last page: {total_pages})[/yellow]")
379
+ return
380
+
381
+ # Cache IDs for numeric shorthand
382
+ config.save_last_results(data)
383
+
384
+ if output == "json":
385
+ output_json(data)
386
+ return
387
+
388
+ if output == "plain":
389
+ for item in data:
390
+ rprint(f"{item['name']} v{item.get('version', '?')} {item.get('model_name', '')}")
391
+ return
392
+
393
+ include_id = show_id or full_id
394
+ table = Table(
395
+ title=f"Agents (page {page} of {total_pages} · {len(data)} of {total})",
396
+ show_lines=False,
397
+ padding=(0, 1),
398
+ )
399
+ table.add_column("#", style="dim", width=3)
400
+ table.add_column("Name", style="bold cyan", no_wrap=True)
401
+ table.add_column("Version", style="green")
402
+ table.add_column("Model")
403
+ table.add_column("Created By", style="dim")
404
+ if include_id:
405
+ table.add_column("ID", style="dim", no_wrap=full_id)
406
+ for i, item in enumerate(data, 1):
407
+ creator = item.get("created_by_username") or item.get("created_by_email", "")
408
+ row = [str(i), item["name"], item.get("version", ""), item.get("model_name", ""), creator]
409
+ if include_id:
410
+ row.append(str(item["id"]) if full_id else str(item["id"])[:8] + "…")
411
+ table.add_row(*row)
412
+ console.print(table)
413
+
414
+ # Pagination footer
415
+ if total_pages > 1:
416
+ if page < total_pages:
417
+ rprint(
418
+ f"[dim]Next:[/dim] [bold]observal agent list --page {page + 1}[/bold]"
419
+ + (f" --limit {limit}" if limit != 50 else "")
420
+ )
421
+ else:
422
+ rprint("[dim]End of results.[/dim]")
423
+
424
+
425
+ @agent_app.command(name="show")
426
+ def agent_show(
427
+ agent_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
428
+ output: str = typer.Option("table", "--output", "-o"),
429
+ ):
430
+ """Show full agent details."""
431
+ resolved = config.resolve_alias(agent_id)
432
+ with spinner():
433
+ item = client.get(f"/api/v1/agents/{resolved}")
434
+
435
+ if output == "json":
436
+ output_json(item)
437
+ return
438
+
439
+ console.print(
440
+ kv_panel(
441
+ f"{item['name']} v{item.get('version', '?')}",
442
+ [
443
+ ("Status", status_badge(item.get("status", ""))),
444
+ ("Model", f"[bold]{item.get('model_name', 'N/A')}[/bold]"),
445
+ ("Owner", item.get("owner", "N/A")),
446
+ ("Created By", item.get("created_by_username") or item.get("created_by_email", "")),
447
+ ("Description", item.get("description", "")),
448
+ ("IDEs", ide_tags(item.get("supported_ides", []))),
449
+ ("Created", relative_time(item.get("created_at"))),
450
+ ("ID", f"[dim]{item['id']}[/dim]"),
451
+ ],
452
+ border_style="magenta",
453
+ )
454
+ )
455
+
456
+ # MCP links
457
+ if item.get("mcp_links"):
458
+ rprint("\n[bold]Linked MCP Servers:[/bold]")
459
+ for link in item["mcp_links"]:
460
+ rprint(f" [cyan]•[/cyan] {link.get('mcp_name', '')} [dim]({link.get('mcp_listing_id', '')})[/dim]")
461
+
462
+ # Goal template as tree
463
+ if item.get("goal_template"):
464
+ gt = item["goal_template"]
465
+ tree = Tree(f"[bold]Goal:[/bold] {gt.get('description', '')}")
466
+ for sec in gt.get("sections", []):
467
+ label = sec["name"]
468
+ if sec.get("grounding_required"):
469
+ label += " [yellow](grounding required)[/yellow]"
470
+ node = tree.add(label)
471
+ if sec.get("description"):
472
+ node.add(f"[dim]{sec['description']}[/dim]")
473
+ console.print(tree)
474
+
475
+
476
+ @agent_app.command(name="install")
477
+ def agent_install(
478
+ agent_id: str = typer.Argument(..., help="Agent ID, name, row number, or @alias"),
479
+ ide: str = typer.Option(..., "--ide", "-i", help="Target IDE"),
480
+ raw: bool = typer.Option(False, "--raw", help="Output raw JSON only"),
481
+ ):
482
+ """Get install config for an agent."""
483
+ resolved = config.resolve_alias(agent_id)
484
+ with spinner(f"Generating {ide} config..."):
485
+ result = client.post(f"/api/v1/agents/{resolved}/install", {"ide": ide})
486
+
487
+ snippet = result.get("config_snippet", {})
488
+ if raw:
489
+ print(_json.dumps(snippet, indent=2))
490
+ return
491
+
492
+ rprint(f"\n[bold]Config for {ide}:[/bold]\n")
493
+
494
+ # Kiro agent file: single JSON to drop in
495
+ agent_file = snippet.get("agent_file")
496
+ if agent_file:
497
+ rprint(f"[bold]Save to:[/bold] {agent_file['path']}")
498
+ rprint()
499
+ console.print_json(_json.dumps(agent_file["content"], indent=2))
500
+ rprint(
501
+ f"\n[dim]Or pipe:[/dim] observal agent install {agent_id} --ide {ide} --raw | jq .agent_file.content > {agent_file['path']}"
502
+ )
503
+ return
504
+
505
+ # Rules file
506
+ rules = snippet.get("rules_file")
507
+ if rules:
508
+ rprint(f"[bold]Rules file:[/bold] {rules.get('path', '')}")
509
+ content = rules.get("content", "")
510
+ rprint(f"[dim]{content[:200]}{'...' if len(content) > 200 else ''}[/dim]\n")
511
+
512
+ # Skill files
513
+ skill_files = snippet.get("skill_files", [])
514
+ if skill_files:
515
+ rprint(f"[bold]Skill files ({len(skill_files)}):[/bold]")
516
+ for sf in skill_files:
517
+ rprint(f" [green]{sf['path']}[/green]")
518
+ rprint()
519
+
520
+ # MCP config
521
+ mcp_cfg = snippet.get("mcp_config")
522
+ if mcp_cfg:
523
+ path = mcp_cfg.get("path") if isinstance(mcp_cfg, dict) and "path" in mcp_cfg else None
524
+ content = mcp_cfg.get("content", mcp_cfg) if isinstance(mcp_cfg, dict) and "content" in mcp_cfg else mcp_cfg
525
+ if path:
526
+ rprint(f"[bold]MCP config:[/bold] {path}")
527
+ else:
528
+ rprint("[bold]MCP config:[/bold]")
529
+ console.print_json(_json.dumps(content, indent=2))
530
+ return
531
+
532
+ # Fallback
533
+ console.print_json(_json.dumps(snippet, indent=2))
534
+
535
+
536
+ @agent_app.command(name="delete")
537
+ def agent_delete(
538
+ agent_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
539
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
540
+ ):
541
+ """Archive an agent (soft delete)."""
542
+ resolved = config.resolve_alias(agent_id)
543
+ if not yes:
544
+ with spinner():
545
+ item = client.get(f"/api/v1/agents/{resolved}")
546
+ if not typer.confirm(f"Archive [bold]{item['name']}[/bold] ({resolved})?"):
547
+ raise typer.Abort()
548
+ with spinner("Archiving..."):
549
+ client.patch(f"/api/v1/agents/{resolved}/archive")
550
+ rprint("[green]✓ Agent archived[/green]")
551
+
552
+
553
+ @agent_app.command(name="unarchive")
554
+ def agent_unarchive(
555
+ agent_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
556
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
557
+ ):
558
+ """Restore an archived agent back to active status."""
559
+ resolved = config.resolve_alias(agent_id)
560
+ if not yes:
561
+ with spinner():
562
+ item = client.get(f"/api/v1/agents/{resolved}")
563
+ if not typer.confirm(f"Unarchive [bold]{item['name']}[/bold] ({resolved})?"):
564
+ raise typer.Abort()
565
+ with spinner("Restoring..."):
566
+ client.patch(f"/api/v1/agents/{resolved}/unarchive")
567
+ rprint("[green]✓ Agent restored[/green]")
568
+
569
+
570
+ # ═══════════════════════════════════════════════════════════════
571
+ # Agent authoring commands (local YAML workflow)
572
+ # ═══════════════════════════════════════════════════════════════
573
+
574
+
575
+ @agent_app.command(name="init")
576
+ def agent_init(
577
+ directory: str = typer.Option(".", "--dir", "-d", help="Directory to scaffold in"),
578
+ beta: bool = typer.Option(False, "--beta", help="Start at version 0.1.0 (beta)"),
579
+ ):
580
+ """Scaffold an observal-agent.yaml definition file."""
581
+ dir_path = Path(directory)
582
+ yaml_path = dir_path / YAML_FILE
583
+
584
+ if yaml_path.exists() and not typer.confirm(f"{YAML_FILE} already exists in {dir_path}. Overwrite?"):
585
+ rprint("[yellow]Aborted.[/yellow]")
586
+ raise typer.Exit(code=1)
587
+
588
+ raw_name = typer.prompt("Agent name")
589
+ name = _slugify(raw_name)
590
+ if name != raw_name:
591
+ rprint(f" [dim]→ Slugified to:[/dim] [bold]{name}[/bold]")
592
+ err = _validate_name(name)
593
+ if err:
594
+ rprint(f"[red]Error:[/red] {err}")
595
+ raise typer.Exit(1)
596
+
597
+ default_version = "0.1.0" if beta else "1.0.0"
598
+ version = typer.prompt("Version", default=default_version)
599
+ description = typer.prompt("Description")
600
+ owner = typer.prompt("Owner / Team")
601
+ model_name = typer.prompt("Model name", default="claude-sonnet-4")
602
+ prompt_text = typer.prompt("System prompt")
603
+
604
+ data = {
605
+ "name": name,
606
+ "version": version,
607
+ "description": description,
608
+ "owner": owner,
609
+ "model_name": model_name,
610
+ "prompt": prompt_text,
611
+ "supported_ides": list(VALID_IDES),
612
+ "components": [],
613
+ "goal_template": {
614
+ "description": f"Goals for {name}",
615
+ "sections": [
616
+ {"name": "default", "description": "Default goal section"},
617
+ ],
618
+ },
619
+ }
620
+
621
+ _save_agent_yaml(dir_path, data)
622
+ rprint(f"[green]✓ Created {yaml_path}[/green]")
623
+
624
+
625
+ @agent_app.command(name="add")
626
+ def agent_add(
627
+ component_type: str = typer.Argument(..., help="Component type: mcp, skill, hook, prompt, sandbox"),
628
+ component_id: str = typer.Argument(..., help="Component ID (UUID)"),
629
+ directory: str = typer.Option(".", "--dir", "-d", help="Directory containing observal-agent.yaml"),
630
+ ):
631
+ """Add a component reference to observal-agent.yaml."""
632
+ if component_type not in VALID_COMPONENT_TYPES:
633
+ rprint(
634
+ f"[red]Error:[/red] Invalid component type '{component_type}'. "
635
+ f"Must be one of: {', '.join(sorted(VALID_COMPONENT_TYPES))}"
636
+ )
637
+ raise typer.Exit(code=1)
638
+
639
+ dir_path = Path(directory)
640
+ data = _load_agent_yaml(dir_path)
641
+
642
+ components = data.get("components", [])
643
+ for comp in components:
644
+ if comp.get("component_type") == component_type and comp.get("component_id") == component_id:
645
+ rprint(f"[yellow]Component {component_type}:{component_id} already exists.[/yellow]")
646
+ raise typer.Exit(code=1)
647
+
648
+ components.append({"component_type": component_type, "component_id": component_id})
649
+ data["components"] = components
650
+ _save_agent_yaml(dir_path, data)
651
+ rprint(f"[green]✓ Added {component_type}:{component_id}[/green]")
652
+
653
+
654
+ @agent_app.command(name="build")
655
+ def agent_build(
656
+ directory: str = typer.Option(".", "--dir", "-d", help="Directory containing observal-agent.yaml"),
657
+ ):
658
+ """Validate agent definition against the server (dry-run)."""
659
+ dir_path = Path(directory)
660
+ data = _load_agent_yaml(dir_path)
661
+
662
+ rprint(f"[bold]Agent:[/bold] {data.get('name', 'unnamed')} v{data.get('version', '?')}")
663
+ rprint(f"[bold]Model:[/bold] {data.get('model_name', 'N/A')}")
664
+ rprint()
665
+
666
+ components = data.get("components", [])
667
+ if not components:
668
+ rprint("[dim]No components to validate.[/dim]")
669
+ return
670
+
671
+ table = Table(title="Component Validation", show_lines=False)
672
+ table.add_column("Type", style="bold")
673
+ table.add_column("ID", style="dim")
674
+ table.add_column("Status")
675
+
676
+ errors: list[str] = []
677
+ for comp in components:
678
+ ctype = comp["component_type"]
679
+ cid = comp["component_id"]
680
+ # API convention: plural resource name
681
+ plural = {"mcp": "mcps", "skill": "skills", "hook": "hooks", "prompt": "prompts", "sandbox": "sandboxes"}
682
+ endpoint = f"/api/v1/{plural[ctype]}/{cid}"
683
+ try:
684
+ with spinner(f"Checking {ctype} {cid[:8]}..."):
685
+ client.get(endpoint)
686
+ table.add_row(ctype, cid, "[green]✓ valid[/green]")
687
+ except (Exception, SystemExit):
688
+ table.add_row(ctype, cid, "[red]✗ not found[/red]")
689
+ errors.append(f"{ctype}:{cid}")
690
+
691
+ console.print(table)
692
+
693
+ if errors:
694
+ rprint(f"\n[red]{len(errors)} component(s) failed validation:[/red]")
695
+ for e in errors:
696
+ rprint(f" [red]•[/red] {e}")
697
+ raise typer.Exit(code=1)
698
+ else:
699
+ rprint("\n[green]✓ All components valid.[/green]")
700
+
701
+
702
+ @agent_app.command(name="publish")
703
+ def agent_publish(
704
+ directory: str = typer.Option(".", "--dir", "-d", help="Directory containing observal-agent.yaml"),
705
+ update: bool = typer.Option(False, "--update", "-u", help="Update existing agent instead of creating"),
706
+ draft: bool = typer.Option(False, "--draft", help="Save as draft instead of submitting for review"),
707
+ submit: str | None = typer.Option(None, "--submit", help="Submit a draft agent for review (agent ID)"),
708
+ ):
709
+ """Publish the agent definition to the server."""
710
+ if draft and submit:
711
+ rprint(
712
+ "[red]Cannot use --draft and --submit together.[/red] Use --draft to save a new draft, or --submit to submit an existing draft."
713
+ )
714
+ raise typer.Exit(code=1)
715
+ if submit:
716
+ resolved = config.resolve_alias(submit)
717
+ with spinner("Submitting draft for review..."):
718
+ result = client.post(f"/api/v1/agents/{resolved}/submit")
719
+ rprint(f"[green]✓ Draft submitted for review![/green] ID: [bold]{result['id']}[/bold]")
720
+ return
721
+
722
+ dir_path = Path(directory)
723
+ data = _load_agent_yaml(dir_path)
724
+
725
+ payload = {
726
+ "name": data["name"],
727
+ "version": data.get("version", "1.0.0"),
728
+ "description": data.get("description", ""),
729
+ "owner": data.get("owner", ""),
730
+ "model_name": data.get("model_name", "claude-sonnet-4"),
731
+ "prompt": data.get("prompt", ""),
732
+ "supported_ides": data.get("supported_ides", []),
733
+ "components": data.get("components", []),
734
+ "goal_template": data.get("goal_template", {}),
735
+ }
736
+
737
+ if draft:
738
+ with spinner("Saving draft..."):
739
+ result = client.post("/api/v1/agents/draft", payload)
740
+ rprint(f"[green]✓ Draft saved![/green] ID: [bold]{result['id']}[/bold]")
741
+ return
742
+
743
+ if update:
744
+ # Find existing agent by name
745
+ with spinner("Looking up existing agent..."):
746
+ results = client.get("/api/v1/agents", params={"search": data["name"]})
747
+ match = next((a for a in results if a["name"] == data["name"]), None)
748
+ if not match:
749
+ rprint(f"[red]Error:[/red] No existing agent found with name '{data['name']}'")
750
+ raise typer.Exit(code=1)
751
+ agent_id = match["id"]
752
+
753
+ # Version bump selection (interactive only)
754
+ import sys
755
+
756
+ if sys.stdin.isatty():
757
+ current_version = match.get("version", "1.0.0")
758
+ try:
759
+ suggestions = client.get(f"/api/v1/agents/{agent_id}/version-suggestions")
760
+ sug = suggestions.get("suggestions", {})
761
+ bump_choices = [
762
+ f"patch {current_version} → {sug.get('patch', '?')} (bug fix)",
763
+ f"minor {current_version} → {sug.get('minor', '?')} (improvement)",
764
+ f"major {current_version} → {sug.get('major', '?')} (revamp)",
765
+ "keep (use version from YAML)",
766
+ ]
767
+ choice = select_one("Version bump type", bump_choices, default=bump_choices[0])
768
+ bump_type = choice.split()[0]
769
+ if bump_type != "keep":
770
+ payload["version_bump_type"] = bump_type
771
+ payload.pop("version", None)
772
+ except (Exception, SystemExit):
773
+ pass
774
+
775
+ with spinner("Updating agent..."):
776
+ result = client.put(f"/api/v1/agents/{agent_id}", payload)
777
+ rprint(f"[green]✓ Agent updated![/green] ID: [bold]{result['id']}[/bold] v{result.get('version', '?')}")
778
+ else:
779
+ with spinner("Submitting agent for review..."):
780
+ result = client.post("/api/v1/agents", payload)
781
+ status = result.get("status", "pending")
782
+ rprint(f"[green]✓ Agent submitted for review![/green] ID: [bold]{result['id']}[/bold]")
783
+ rprint(f"[yellow]Status: {status} — an admin must approve it before it becomes visible.[/yellow]")