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