plato-sdk-v2 2.7.6__py3-none-any.whl → 2.7.8__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.
- plato/cli/__init__.py +5 -0
- plato/cli/agent.py +1209 -0
- plato/cli/audit_ui.py +316 -0
- plato/cli/chronos.py +817 -0
- plato/cli/main.py +193 -0
- plato/cli/pm.py +1204 -0
- plato/cli/proxy.py +222 -0
- plato/cli/sandbox.py +808 -0
- plato/cli/utils.py +200 -0
- plato/cli/verify.py +690 -0
- plato/cli/world.py +250 -0
- plato/v1/cli/pm.py +4 -1
- plato/v2/__init__.py +2 -0
- plato/v2/models.py +42 -0
- plato/v2/sync/__init__.py +6 -0
- plato/v2/sync/client.py +6 -3
- plato/v2/sync/sandbox.py +1462 -0
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.8.dist-info}/METADATA +1 -1
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.8.dist-info}/RECORD +21 -9
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.8.dist-info}/entry_points.txt +1 -1
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.8.dist-info}/WHEEL +0 -0
plato/cli/agent.py
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
"""Agent CLI commands for Plato."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from plato.cli.utils import console, require_api_key
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from plato.agents.build import BuildConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _extract_schemas(pkg_path: Path, package_name: str) -> tuple[dict | None, dict | None, dict | None]:
|
|
21
|
+
"""Extract Config, BuildConfig, and SecretsConfig schemas from the agent package.
|
|
22
|
+
|
|
23
|
+
Looks for:
|
|
24
|
+
- Config class - runtime configuration (stored as config_schema)
|
|
25
|
+
- BuildConfig class - build-time template variables (stored as template_variables)
|
|
26
|
+
- SecretsConfig class - secrets/API keys (stored as secrets_schema)
|
|
27
|
+
|
|
28
|
+
Returns tuple of (config_schema, build_config_schema, secrets_schema).
|
|
29
|
+
"""
|
|
30
|
+
import inspect
|
|
31
|
+
|
|
32
|
+
# Convert package name to module name (replace - with _)
|
|
33
|
+
module_name = package_name.replace("-", "_")
|
|
34
|
+
|
|
35
|
+
# Extract short name (e.g., "claude-code" from "plato-agent-claude-code")
|
|
36
|
+
short_name = package_name
|
|
37
|
+
for prefix in ("plato-agent-", "plato-"):
|
|
38
|
+
if short_name.startswith(prefix):
|
|
39
|
+
short_name = short_name[len(prefix) :]
|
|
40
|
+
break
|
|
41
|
+
short_name_under = short_name.replace("-", "_")
|
|
42
|
+
|
|
43
|
+
# Add package src to path temporarily
|
|
44
|
+
src_path = pkg_path / "src"
|
|
45
|
+
paths_added = []
|
|
46
|
+
if src_path.exists():
|
|
47
|
+
sys.path.insert(0, str(src_path))
|
|
48
|
+
paths_added.append(str(src_path))
|
|
49
|
+
sys.path.insert(0, str(pkg_path))
|
|
50
|
+
paths_added.append(str(pkg_path))
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
from pydantic import BaseModel
|
|
54
|
+
|
|
55
|
+
# Build list of possible module locations
|
|
56
|
+
locations = [
|
|
57
|
+
module_name,
|
|
58
|
+
f"{module_name}.config",
|
|
59
|
+
f"{module_name}.agent",
|
|
60
|
+
f"plato.agent.{short_name_under}",
|
|
61
|
+
f"plato.agent.{short_name_under}.config",
|
|
62
|
+
f"plato.agent.{short_name_under}.agent",
|
|
63
|
+
f"plato_agent_{short_name_under}",
|
|
64
|
+
f"{short_name_under}_agent",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
config_schema = None
|
|
68
|
+
build_config_schema = None
|
|
69
|
+
secrets_schema = None
|
|
70
|
+
|
|
71
|
+
for loc in locations:
|
|
72
|
+
try:
|
|
73
|
+
module = __import__(loc, fromlist=["*"])
|
|
74
|
+
|
|
75
|
+
# Look for Config, BuildConfig, and SecretsConfig classes
|
|
76
|
+
for name, obj in inspect.getmembers(module, inspect.isclass):
|
|
77
|
+
if not isinstance(obj, type) or not issubclass(obj, BaseModel):
|
|
78
|
+
continue
|
|
79
|
+
if obj is BaseModel:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if name == "Config":
|
|
83
|
+
console.print(f"[green]Found Config class in {loc}[/green]")
|
|
84
|
+
config_schema = obj.model_json_schema()
|
|
85
|
+
elif name == "BuildConfig":
|
|
86
|
+
console.print(f"[green]Found BuildConfig class in {loc}[/green]")
|
|
87
|
+
build_config_schema = obj.model_json_schema()
|
|
88
|
+
elif name == "SecretsConfig":
|
|
89
|
+
console.print(f"[green]Found SecretsConfig class in {loc}[/green]")
|
|
90
|
+
secrets_schema = obj.model_json_schema()
|
|
91
|
+
|
|
92
|
+
# If we found at least one, stop searching
|
|
93
|
+
if config_schema or build_config_schema or secrets_schema:
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
except (ImportError, ModuleNotFoundError):
|
|
97
|
+
continue
|
|
98
|
+
except Exception as e:
|
|
99
|
+
console.print(f"[yellow]Warning: Error importing {loc}: {e}[/yellow]")
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
return config_schema, build_config_schema, secrets_schema
|
|
103
|
+
except ImportError:
|
|
104
|
+
# pydantic not available
|
|
105
|
+
return None, None, None
|
|
106
|
+
finally:
|
|
107
|
+
# Clean up sys.path
|
|
108
|
+
for path in paths_added:
|
|
109
|
+
if path in sys.path:
|
|
110
|
+
sys.path.remove(path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_config_schema(pkg_path: Path, package_name: str) -> dict | None:
|
|
114
|
+
"""Extract config schema from the agent package (legacy wrapper)."""
|
|
115
|
+
config_schema, _, _ = _extract_schemas(pkg_path, package_name)
|
|
116
|
+
return config_schema
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_template_variables(build_config_schema: dict | None) -> dict[str, str] | None:
|
|
120
|
+
"""Extract template variables from BuildConfig schema.
|
|
121
|
+
|
|
122
|
+
All fields in BuildConfig are considered template variables for Harbor's
|
|
123
|
+
installation templates. These are stored separately for easy querying.
|
|
124
|
+
|
|
125
|
+
Returns dict of field name -> default value (or empty string), or None if no fields.
|
|
126
|
+
"""
|
|
127
|
+
if not build_config_schema:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
properties = build_config_schema.get("properties", {})
|
|
131
|
+
if not properties:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
template_vars = {}
|
|
135
|
+
for field_name, prop in properties.items():
|
|
136
|
+
# Store the default value if present, otherwise empty string
|
|
137
|
+
default = prop.get("default")
|
|
138
|
+
if default is not None:
|
|
139
|
+
template_vars[field_name] = str(default)
|
|
140
|
+
else:
|
|
141
|
+
template_vars[field_name] = ""
|
|
142
|
+
|
|
143
|
+
return template_vars if template_vars else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
agent_app = typer.Typer(help="Manage, deploy, and run agents (uses Harbor)")
|
|
147
|
+
|
|
148
|
+
# Harbor agent name to install script path (relative to harbor/agents/installed/)
|
|
149
|
+
HARBOR_AGENTS = {
|
|
150
|
+
"claude-code": "install-claude-code.sh.j2",
|
|
151
|
+
"openhands": "install-openhands.sh.j2",
|
|
152
|
+
"codex": "install-codex.sh.j2",
|
|
153
|
+
"aider": "install-aider.sh.j2",
|
|
154
|
+
"gemini-cli": "install-gemini-cli.sh.j2",
|
|
155
|
+
"goose": "install-goose.sh.j2",
|
|
156
|
+
"swe-agent": "install-swe-agent.sh.j2",
|
|
157
|
+
"mini-swe-agent": "install-mini-swe-agent.sh.j2",
|
|
158
|
+
"cline-cli": "cline/install-cline.sh.j2",
|
|
159
|
+
"cursor-cli": "install-cursor-cli.sh.j2",
|
|
160
|
+
"opencode": "install-opencode.sh.j2",
|
|
161
|
+
"qwen-coder": "install-qwen-code.sh.j2",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Base Dockerfile template for Harbor agents
|
|
165
|
+
HARBOR_AGENT_DOCKERFILE_BASE = """FROM python:3.12-slim
|
|
166
|
+
|
|
167
|
+
# Install common dependencies
|
|
168
|
+
RUN apt-get update && apt-get install -y \\
|
|
169
|
+
curl \\
|
|
170
|
+
git \\
|
|
171
|
+
bash \\
|
|
172
|
+
build-essential \\
|
|
173
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
HARBOR_AGENT_DOCKERFILE_INSTALL = """
|
|
177
|
+
# Copy and run the install script
|
|
178
|
+
COPY install.sh /tmp/install.sh
|
|
179
|
+
RUN chmod +x /tmp/install.sh && /tmp/install.sh
|
|
180
|
+
|
|
181
|
+
WORKDIR /app
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _build_harbor_dockerfile(aux_files: list[str]) -> str:
|
|
186
|
+
"""Build Dockerfile content, optionally including COPY for aux files."""
|
|
187
|
+
dockerfile = HARBOR_AGENT_DOCKERFILE_BASE
|
|
188
|
+
|
|
189
|
+
# Add COPY for each auxiliary file
|
|
190
|
+
for filename in aux_files:
|
|
191
|
+
dockerfile += f"\n# Copy auxiliary file\nCOPY {filename} /{filename}\n"
|
|
192
|
+
|
|
193
|
+
dockerfile += HARBOR_AGENT_DOCKERFILE_INSTALL
|
|
194
|
+
return dockerfile
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Auxiliary files needed by certain agents (relative to harbor/agents/installed/)
|
|
198
|
+
HARBOR_AGENT_AUX_FILES = {
|
|
199
|
+
"openhands": ["patch_litellm.py"],
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _get_harbor_version() -> str:
|
|
204
|
+
"""Get Harbor package version."""
|
|
205
|
+
try:
|
|
206
|
+
import harbor
|
|
207
|
+
|
|
208
|
+
return getattr(harbor, "__version__", "0.0.0")
|
|
209
|
+
except ImportError:
|
|
210
|
+
return "0.0.0"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _get_harbor_install_script(agent_name: str, template_vars: dict[str, str] | None = None) -> str | None:
|
|
214
|
+
"""Get the install script content from Harbor package.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
agent_name: Name of the Harbor agent (e.g., 'claude-code', 'openhands')
|
|
218
|
+
template_vars: Template variables to render (e.g., {'version': '1.0.0'})
|
|
219
|
+
If None, uses latest version.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Rendered install script content, or None if agent not found.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
import harbor
|
|
226
|
+
|
|
227
|
+
harbor_path = Path(harbor.__file__).parent
|
|
228
|
+
script_file = HARBOR_AGENTS.get(agent_name)
|
|
229
|
+
if not script_file:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
script_path = harbor_path / "agents" / "installed" / script_file
|
|
233
|
+
if not script_path.exists():
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
# Read and render the Jinja2 template
|
|
237
|
+
from jinja2 import Environment
|
|
238
|
+
|
|
239
|
+
env = Environment()
|
|
240
|
+
template = env.from_string(script_path.read_text())
|
|
241
|
+
|
|
242
|
+
# Use provided template vars or empty dict (which means latest)
|
|
243
|
+
render_vars = template_vars or {}
|
|
244
|
+
rendered = template.render(**render_vars)
|
|
245
|
+
|
|
246
|
+
# Strip trailing whitespace from each line to fix heredoc issues
|
|
247
|
+
# (some Harbor templates have trailing spaces after EOF delimiters)
|
|
248
|
+
lines = [line.rstrip() for line in rendered.splitlines()]
|
|
249
|
+
return "\n".join(lines) + "\n"
|
|
250
|
+
except ImportError:
|
|
251
|
+
return None
|
|
252
|
+
except Exception:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _copy_harbor_aux_files(agent_name: str, dest_path: Path) -> None:
|
|
257
|
+
"""Copy auxiliary files needed by an agent to the build directory."""
|
|
258
|
+
aux_files = HARBOR_AGENT_AUX_FILES.get(agent_name, [])
|
|
259
|
+
if not aux_files:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
import harbor
|
|
264
|
+
|
|
265
|
+
harbor_path = Path(harbor.__file__).parent
|
|
266
|
+
installed_path = harbor_path / "agents" / "installed"
|
|
267
|
+
|
|
268
|
+
for filename in aux_files:
|
|
269
|
+
src = installed_path / filename
|
|
270
|
+
if src.exists():
|
|
271
|
+
shutil.copy(src, dest_path / filename)
|
|
272
|
+
except ImportError:
|
|
273
|
+
pass
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _publish_agent_image(
|
|
279
|
+
agent_name: str,
|
|
280
|
+
version: str,
|
|
281
|
+
build_path: Path,
|
|
282
|
+
description: str,
|
|
283
|
+
dry_run: bool,
|
|
284
|
+
schema_data: dict | None = None,
|
|
285
|
+
build_config: "BuildConfig | None" = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Common logic for publishing an agent Docker image to ECR."""
|
|
288
|
+
import httpx
|
|
289
|
+
|
|
290
|
+
# Check Docker is available
|
|
291
|
+
if not shutil.which("docker"):
|
|
292
|
+
console.print("[red]Error: docker not found[/red]")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
|
|
295
|
+
console.print(f"[cyan]Agent:[/cyan] {agent_name}")
|
|
296
|
+
console.print(f"[cyan]Version:[/cyan] {version}")
|
|
297
|
+
console.print()
|
|
298
|
+
|
|
299
|
+
# Build Docker image with streaming output
|
|
300
|
+
console.print("[cyan]Building Docker image...[/cyan]")
|
|
301
|
+
local_tag = f"{agent_name}:{version}"
|
|
302
|
+
|
|
303
|
+
# Build docker command with build args from config
|
|
304
|
+
docker_cmd = ["docker", "build", "--progress=plain", "-t", local_tag]
|
|
305
|
+
|
|
306
|
+
# Add --target prod if Dockerfile has multi-stage builds
|
|
307
|
+
dockerfile_path = build_path / "Dockerfile"
|
|
308
|
+
if dockerfile_path.exists():
|
|
309
|
+
dockerfile_content = dockerfile_path.read_text()
|
|
310
|
+
if "FROM" in dockerfile_content and "AS prod" in dockerfile_content:
|
|
311
|
+
docker_cmd.extend(["--target", "prod"])
|
|
312
|
+
console.print("[cyan]Using target: prod[/cyan]")
|
|
313
|
+
|
|
314
|
+
# Add build args from build config's env dict
|
|
315
|
+
if build_config and build_config.env:
|
|
316
|
+
for key, value in build_config.env.items():
|
|
317
|
+
docker_cmd.extend(["--build-arg", f"{key}={value}"])
|
|
318
|
+
|
|
319
|
+
docker_cmd.append(str(build_path))
|
|
320
|
+
|
|
321
|
+
# Use Popen to stream output in real-time
|
|
322
|
+
process = subprocess.Popen(
|
|
323
|
+
docker_cmd,
|
|
324
|
+
stdout=subprocess.PIPE,
|
|
325
|
+
stderr=subprocess.STDOUT,
|
|
326
|
+
text=True,
|
|
327
|
+
bufsize=1,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Stream build output
|
|
331
|
+
build_output = []
|
|
332
|
+
if process.stdout is None:
|
|
333
|
+
console.print("[red]Error: Failed to capture Docker build output[/red]")
|
|
334
|
+
raise typer.Exit(1)
|
|
335
|
+
for line in iter(process.stdout.readline, ""):
|
|
336
|
+
line = line.rstrip()
|
|
337
|
+
if line:
|
|
338
|
+
build_output.append(line)
|
|
339
|
+
# Show key build steps
|
|
340
|
+
if line.startswith("#") or "Step" in line or "ERROR" in line or "error" in line.lower():
|
|
341
|
+
if "ERROR" in line or "error" in line.lower():
|
|
342
|
+
console.print(f"[red]{line}[/red]")
|
|
343
|
+
else:
|
|
344
|
+
console.print(f"[dim]{line}[/dim]")
|
|
345
|
+
|
|
346
|
+
process.wait()
|
|
347
|
+
|
|
348
|
+
if process.returncode != 0:
|
|
349
|
+
console.print("\n[red]Docker build failed![/red]")
|
|
350
|
+
# Show last 30 lines of output for context
|
|
351
|
+
console.print("[yellow]Last build output:[/yellow]")
|
|
352
|
+
for line in build_output[-30:]:
|
|
353
|
+
console.print(f" {line}")
|
|
354
|
+
raise typer.Exit(1)
|
|
355
|
+
|
|
356
|
+
console.print(f"\n[green]Built image:[/green] {local_tag}")
|
|
357
|
+
|
|
358
|
+
if dry_run:
|
|
359
|
+
console.print("\n[yellow]Dry run - skipping ECR push and registration[/yellow]")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# Get API key
|
|
363
|
+
api_key = os.getenv("PLATO_API_KEY")
|
|
364
|
+
if not api_key:
|
|
365
|
+
console.print("[red]Error: PLATO_API_KEY environment variable not set[/red]")
|
|
366
|
+
raise typer.Exit(1)
|
|
367
|
+
|
|
368
|
+
# Get base URL
|
|
369
|
+
base_url = os.getenv("PLATO_BASE_URL", "https://plato.so").rstrip("/")
|
|
370
|
+
if base_url.endswith("/api"):
|
|
371
|
+
base_url = base_url[:-4]
|
|
372
|
+
api_url = f"{base_url}/api"
|
|
373
|
+
|
|
374
|
+
# Get ECR token
|
|
375
|
+
console.print("[cyan]Getting ECR credentials...[/cyan]")
|
|
376
|
+
with httpx.Client(base_url=api_url, timeout=30.0) as client:
|
|
377
|
+
response = client.post(
|
|
378
|
+
"/v2/agents/ecr-token",
|
|
379
|
+
params={"agent_name": agent_name},
|
|
380
|
+
headers={"X-API-Key": api_key},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if response.status_code == 401:
|
|
384
|
+
console.print("[red]Error: Authentication failed - check PLATO_API_KEY[/red]")
|
|
385
|
+
raise typer.Exit(1)
|
|
386
|
+
elif response.status_code != 200:
|
|
387
|
+
console.print(f"[red]Error getting ECR token: {response.status_code}[/red]")
|
|
388
|
+
console.print(response.text)
|
|
389
|
+
raise typer.Exit(1)
|
|
390
|
+
|
|
391
|
+
ecr_info = response.json()
|
|
392
|
+
|
|
393
|
+
registry = ecr_info["registry"]
|
|
394
|
+
ecr_token = ecr_info["token"]
|
|
395
|
+
image_uri = ecr_info["image_uri"]
|
|
396
|
+
|
|
397
|
+
# Docker login to ECR
|
|
398
|
+
console.print("[cyan]Logging in to ECR...[/cyan]")
|
|
399
|
+
login_result = subprocess.run(
|
|
400
|
+
["docker", "login", "--username", "AWS", "--password-stdin", registry],
|
|
401
|
+
input=ecr_token,
|
|
402
|
+
text=True,
|
|
403
|
+
capture_output=True,
|
|
404
|
+
)
|
|
405
|
+
if login_result.returncode != 0:
|
|
406
|
+
console.print(f"[red]ECR login failed:[/red] {login_result.stderr}")
|
|
407
|
+
raise typer.Exit(1)
|
|
408
|
+
|
|
409
|
+
# Tag and push
|
|
410
|
+
ecr_image = f"{image_uri}:{version}"
|
|
411
|
+
console.print(f"[cyan]Pushing to:[/cyan] {ecr_image}")
|
|
412
|
+
|
|
413
|
+
subprocess.run(["docker", "tag", local_tag, ecr_image], check=True)
|
|
414
|
+
|
|
415
|
+
push_result = subprocess.run(["docker", "push", ecr_image], capture_output=True, text=True)
|
|
416
|
+
if push_result.returncode != 0:
|
|
417
|
+
console.print(f"[red]Push failed:[/red] {push_result.stderr}")
|
|
418
|
+
raise typer.Exit(1)
|
|
419
|
+
console.print(f"[green]Pushed:[/green] {ecr_image}")
|
|
420
|
+
|
|
421
|
+
# Register agent artifact
|
|
422
|
+
console.print("[cyan]Registering agent...[/cyan]")
|
|
423
|
+
with httpx.Client(base_url=api_url, timeout=30.0) as client:
|
|
424
|
+
registration_data = {
|
|
425
|
+
"name": agent_name,
|
|
426
|
+
"version": version,
|
|
427
|
+
"image_uri": ecr_image,
|
|
428
|
+
"description": description,
|
|
429
|
+
"config_schema": schema_data,
|
|
430
|
+
}
|
|
431
|
+
response = client.post(
|
|
432
|
+
"/v2/agents/register",
|
|
433
|
+
json=registration_data,
|
|
434
|
+
headers={"X-API-Key": api_key},
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
if response.status_code == 409:
|
|
438
|
+
detail = response.json().get("detail", "Version conflict")
|
|
439
|
+
console.print(f"[red]Error: {detail}[/red]")
|
|
440
|
+
raise typer.Exit(1)
|
|
441
|
+
elif response.status_code != 200:
|
|
442
|
+
console.print(f"[red]Registration failed: {response.status_code}[/red]")
|
|
443
|
+
console.print(response.text)
|
|
444
|
+
raise typer.Exit(1)
|
|
445
|
+
|
|
446
|
+
reg_result = response.json()
|
|
447
|
+
|
|
448
|
+
console.print()
|
|
449
|
+
console.print("[bold green]Agent published successfully![/bold green]")
|
|
450
|
+
console.print(f"[cyan]Artifact ID:[/cyan] {reg_result['artifact_id']}")
|
|
451
|
+
console.print(f"[cyan]Image:[/cyan] {ecr_image}")
|
|
452
|
+
|
|
453
|
+
# Clean up local images after successful push
|
|
454
|
+
console.print("[dim]Cleaning up local images...[/dim]")
|
|
455
|
+
subprocess.run(["docker", "rmi", local_tag], capture_output=True)
|
|
456
|
+
subprocess.run(["docker", "rmi", ecr_image], capture_output=True)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _publish_package(path: str, repo: str, dry_run: bool = False):
|
|
460
|
+
"""
|
|
461
|
+
Helper function to build and publish a package to a Plato PyPI repository.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
path: Path to the package directory
|
|
465
|
+
repo: Repository name (e.g., "agents", "worlds")
|
|
466
|
+
dry_run: If True, build without uploading
|
|
467
|
+
"""
|
|
468
|
+
try:
|
|
469
|
+
import tomli
|
|
470
|
+
except ImportError:
|
|
471
|
+
console.print("[red]Error: tomli is not installed[/red]")
|
|
472
|
+
console.print("\n[yellow]Install with:[/yellow]")
|
|
473
|
+
console.print(" pip install tomli")
|
|
474
|
+
raise typer.Exit(1) from None
|
|
475
|
+
|
|
476
|
+
# Get API key (skip check for dry_run)
|
|
477
|
+
api_key = None
|
|
478
|
+
if not dry_run:
|
|
479
|
+
api_key = require_api_key()
|
|
480
|
+
|
|
481
|
+
# Get base URL (default to production)
|
|
482
|
+
base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
|
|
483
|
+
# Normalize: remove trailing slash and /api if present
|
|
484
|
+
base_url = base_url.rstrip("/")
|
|
485
|
+
if base_url.endswith("/api"):
|
|
486
|
+
base_url = base_url[:-4]
|
|
487
|
+
api_url = f"{base_url}/api"
|
|
488
|
+
|
|
489
|
+
# Resolve package path
|
|
490
|
+
pkg_path = Path(path).resolve()
|
|
491
|
+
if not pkg_path.exists():
|
|
492
|
+
console.print(f"[red]Error: Path does not exist: {pkg_path}[/red]")
|
|
493
|
+
raise typer.Exit(1)
|
|
494
|
+
|
|
495
|
+
# Load pyproject.toml
|
|
496
|
+
pyproject_file = pkg_path / "pyproject.toml"
|
|
497
|
+
if not pyproject_file.exists():
|
|
498
|
+
console.print(f"[red]Error: No pyproject.toml found at {pkg_path}[/red]")
|
|
499
|
+
raise typer.Exit(1)
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
with open(pyproject_file, "rb") as f:
|
|
503
|
+
pyproject = tomli.load(f)
|
|
504
|
+
except Exception as e:
|
|
505
|
+
console.print(f"[red]Error reading pyproject.toml: {e}[/red]")
|
|
506
|
+
raise typer.Exit(1) from e
|
|
507
|
+
|
|
508
|
+
# Extract package info
|
|
509
|
+
project = pyproject.get("project", {})
|
|
510
|
+
package_name = project.get("name")
|
|
511
|
+
version = project.get("version")
|
|
512
|
+
|
|
513
|
+
if not package_name:
|
|
514
|
+
console.print("[red]Error: No package name in pyproject.toml[/red]")
|
|
515
|
+
raise typer.Exit(1)
|
|
516
|
+
if not version:
|
|
517
|
+
console.print("[red]Error: No version in pyproject.toml[/red]")
|
|
518
|
+
raise typer.Exit(1)
|
|
519
|
+
|
|
520
|
+
console.print(f"[cyan]Package:[/cyan] {package_name}")
|
|
521
|
+
console.print(f"[cyan]Version:[/cyan] {version}")
|
|
522
|
+
console.print(f"[cyan]Repository:[/cyan] {repo}")
|
|
523
|
+
console.print(f"[cyan]Path:[/cyan] {pkg_path}")
|
|
524
|
+
console.print()
|
|
525
|
+
|
|
526
|
+
# Build package
|
|
527
|
+
console.print("[cyan]Building package...[/cyan]")
|
|
528
|
+
try:
|
|
529
|
+
result = subprocess.run(
|
|
530
|
+
["uv", "build"],
|
|
531
|
+
cwd=pkg_path,
|
|
532
|
+
capture_output=True,
|
|
533
|
+
text=True,
|
|
534
|
+
)
|
|
535
|
+
if result.returncode != 0:
|
|
536
|
+
console.print("[red]Build failed:[/red]")
|
|
537
|
+
console.print(result.stderr)
|
|
538
|
+
raise typer.Exit(1)
|
|
539
|
+
console.print("[green]Build successful[/green]")
|
|
540
|
+
except FileNotFoundError:
|
|
541
|
+
console.print("[red]Error: uv not found. Install with: pip install uv[/red]")
|
|
542
|
+
raise typer.Exit(1) from None
|
|
543
|
+
|
|
544
|
+
# Find built wheel
|
|
545
|
+
dist_dir = pkg_path / "dist"
|
|
546
|
+
if not dist_dir.exists():
|
|
547
|
+
console.print("[red]Error: dist/ directory not found after build[/red]")
|
|
548
|
+
raise typer.Exit(1)
|
|
549
|
+
|
|
550
|
+
normalized_name = package_name.replace("-", "_")
|
|
551
|
+
wheel_files = list(dist_dir.glob(f"{normalized_name}-{version}-*.whl"))
|
|
552
|
+
|
|
553
|
+
if not wheel_files:
|
|
554
|
+
# Try without version in pattern
|
|
555
|
+
wheel_files = list(dist_dir.glob("*.whl"))
|
|
556
|
+
|
|
557
|
+
if not wheel_files:
|
|
558
|
+
console.print(f"[red]Error: No wheel file found in {dist_dir}[/red]")
|
|
559
|
+
raise typer.Exit(1)
|
|
560
|
+
|
|
561
|
+
wheel_file = wheel_files[0]
|
|
562
|
+
console.print(f"[cyan]Built:[/cyan] {wheel_file.name}")
|
|
563
|
+
|
|
564
|
+
if dry_run:
|
|
565
|
+
console.print("\n[yellow]Dry run - skipping upload[/yellow]")
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
# Upload using uv publish
|
|
569
|
+
upload_url = f"{api_url}/v2/pypi/{repo}/"
|
|
570
|
+
console.print(f"\n[cyan]Uploading to {upload_url}...[/cyan]")
|
|
571
|
+
|
|
572
|
+
# api_key is guaranteed to be set (checked earlier when not dry_run)
|
|
573
|
+
assert api_key is not None, "api_key must be set when not in dry_run mode"
|
|
574
|
+
try:
|
|
575
|
+
result = subprocess.run(
|
|
576
|
+
[
|
|
577
|
+
"uv",
|
|
578
|
+
"publish",
|
|
579
|
+
"--publish-url",
|
|
580
|
+
upload_url,
|
|
581
|
+
"--username",
|
|
582
|
+
"__token__",
|
|
583
|
+
"--password",
|
|
584
|
+
api_key,
|
|
585
|
+
str(wheel_file),
|
|
586
|
+
],
|
|
587
|
+
capture_output=True,
|
|
588
|
+
text=True,
|
|
589
|
+
check=False,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
if result.returncode == 0:
|
|
593
|
+
console.print("[green]Upload successful![/green]")
|
|
594
|
+
console.print("\n[bold]Install with:[/bold]")
|
|
595
|
+
console.print(f" uv add {package_name} --index-url {api_url}/v2/pypi/{repo}/simple/")
|
|
596
|
+
else:
|
|
597
|
+
console.print("[red]Upload failed:[/red]")
|
|
598
|
+
if result.stdout:
|
|
599
|
+
console.print(result.stdout)
|
|
600
|
+
if result.stderr:
|
|
601
|
+
console.print(result.stderr)
|
|
602
|
+
raise typer.Exit(1)
|
|
603
|
+
|
|
604
|
+
except FileNotFoundError:
|
|
605
|
+
console.print("[red]Error: uv not found[/red]")
|
|
606
|
+
raise typer.Exit(1) from None
|
|
607
|
+
except Exception as e:
|
|
608
|
+
console.print(f"[red]Upload error: {e}[/red]")
|
|
609
|
+
raise typer.Exit(1) from e
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@agent_app.command(name="run")
|
|
613
|
+
def agent_run(
|
|
614
|
+
ctx: typer.Context,
|
|
615
|
+
agent: str = typer.Option(None, "--agent", "-a", help="Agent name (e.g., 'claude-code', 'openhands')"),
|
|
616
|
+
model: str = typer.Option(None, "--model", "-m", help="Model name (e.g., 'anthropic/claude-sonnet-4')"),
|
|
617
|
+
dataset: str = typer.Option(None, "--dataset", "-d", help="Dataset to run on"),
|
|
618
|
+
):
|
|
619
|
+
"""Run an agent using Harbor's runner infrastructure.
|
|
620
|
+
|
|
621
|
+
Wraps `harbor run` to execute agents on a dataset. Supports Harbor built-in agents
|
|
622
|
+
(claude-code, openhands, codex, aider, etc.) and Plato custom agents (computer-use).
|
|
623
|
+
|
|
624
|
+
Options:
|
|
625
|
+
-a, --agent: Agent name to run. See 'plato agent list' for available agents.
|
|
626
|
+
-m, --model: Model name for the agent (e.g., 'anthropic/claude-sonnet-4')
|
|
627
|
+
-d, --dataset: Dataset to run on (e.g., 'swe-bench-lite', 'terminal-bench')
|
|
628
|
+
|
|
629
|
+
Additional arguments can be passed to Harbor after '--' separator.
|
|
630
|
+
"""
|
|
631
|
+
# Check if harbor is installed
|
|
632
|
+
if not shutil.which("harbor"):
|
|
633
|
+
console.print("[red]Error: harbor CLI not found[/red]")
|
|
634
|
+
console.print("\n[yellow]Install Harbor with:[/yellow]")
|
|
635
|
+
console.print(" pip install harbor")
|
|
636
|
+
console.print(" # or")
|
|
637
|
+
console.print(" uv tool install harbor")
|
|
638
|
+
raise typer.Exit(1)
|
|
639
|
+
|
|
640
|
+
# Build command
|
|
641
|
+
cmd = ["harbor", "run"]
|
|
642
|
+
|
|
643
|
+
if agent:
|
|
644
|
+
cmd.extend(["-a", agent])
|
|
645
|
+
if model:
|
|
646
|
+
cmd.extend(["-m", model])
|
|
647
|
+
if dataset:
|
|
648
|
+
cmd.extend(["-d", dataset])
|
|
649
|
+
|
|
650
|
+
# Add any extra arguments passed after --
|
|
651
|
+
if ctx.args:
|
|
652
|
+
cmd.extend(ctx.args)
|
|
653
|
+
|
|
654
|
+
# If no arguments provided, show help
|
|
655
|
+
if len(cmd) == 2:
|
|
656
|
+
console.print("[yellow]Usage: plato agent run -a <agent> -m <model> -d <dataset>[/yellow]")
|
|
657
|
+
console.print("\n[bold]Harbor agents:[/bold]")
|
|
658
|
+
console.print(" claude-code, openhands, codex, aider, gemini-cli,")
|
|
659
|
+
console.print(" goose, swe-agent, mini-swe-agent, cline-cli,")
|
|
660
|
+
console.print(" cursor-cli, opencode, qwen-coder")
|
|
661
|
+
console.print("\n[bold]Plato agents:[/bold]")
|
|
662
|
+
console.print(" computer-use (pip install plato-agent-computer-use)")
|
|
663
|
+
console.print("\n[bold]Example:[/bold]")
|
|
664
|
+
console.print(" plato agent run -a claude-code -m anthropic/claude-sonnet-4 -d swe-bench-lite")
|
|
665
|
+
raise typer.Exit(0)
|
|
666
|
+
|
|
667
|
+
console.print(f"[cyan]Running:[/cyan] {' '.join(cmd)}")
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
result = subprocess.run(cmd)
|
|
671
|
+
raise typer.Exit(result.returncode)
|
|
672
|
+
except KeyboardInterrupt:
|
|
673
|
+
console.print("\n[yellow]Interrupted by user[/yellow]")
|
|
674
|
+
raise typer.Exit(130) from None
|
|
675
|
+
except Exception as e:
|
|
676
|
+
console.print(f"[red]Error running harbor: {e}[/red]")
|
|
677
|
+
raise typer.Exit(1) from e
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@agent_app.command(name="list")
|
|
681
|
+
def agent_list():
|
|
682
|
+
"""List all available agents.
|
|
683
|
+
|
|
684
|
+
Shows Harbor built-in agents (claude-code, openhands, etc.) and Plato custom agents
|
|
685
|
+
(computer-use) that can be used with 'plato agent run' and 'plato agent publish'.
|
|
686
|
+
"""
|
|
687
|
+
console.print("[bold]Harbor Agents:[/bold]\n")
|
|
688
|
+
|
|
689
|
+
harbor_agents = [
|
|
690
|
+
("claude-code", "Claude Code - Anthropic's CLI coding agent"),
|
|
691
|
+
("openhands", "OpenHands - All Hands AI coding agent"),
|
|
692
|
+
("codex", "Codex - OpenAI CLI coding agent"),
|
|
693
|
+
("aider", "Aider - AI pair programming tool"),
|
|
694
|
+
("gemini-cli", "Gemini CLI - Google's CLI coding agent"),
|
|
695
|
+
("goose", "Goose - Block's coding agent"),
|
|
696
|
+
("swe-agent", "SWE-agent - Princeton's software engineering agent"),
|
|
697
|
+
("mini-swe-agent", "Mini SWE-agent - Lightweight SWE-agent"),
|
|
698
|
+
("cline-cli", "Cline CLI - VS Code extension CLI"),
|
|
699
|
+
("cursor-cli", "Cursor CLI - Cursor editor CLI"),
|
|
700
|
+
("opencode", "OpenCode - Open source coding agent"),
|
|
701
|
+
("qwen-coder", "Qwen Coder - Alibaba's coding agent"),
|
|
702
|
+
]
|
|
703
|
+
|
|
704
|
+
for name, description in harbor_agents:
|
|
705
|
+
console.print(f" [cyan]{name:<15}[/cyan] {description}")
|
|
706
|
+
|
|
707
|
+
console.print("\n[bold]Plato Agents:[/bold]\n")
|
|
708
|
+
|
|
709
|
+
plato_agents = [
|
|
710
|
+
(
|
|
711
|
+
"computer-use",
|
|
712
|
+
"Browser automation agent (pip install plato-agent-computer-use)",
|
|
713
|
+
),
|
|
714
|
+
]
|
|
715
|
+
|
|
716
|
+
for name, description in plato_agents:
|
|
717
|
+
console.print(f" [cyan]{name:<15}[/cyan] {description}")
|
|
718
|
+
|
|
719
|
+
console.print("\n[bold]Usage:[/bold]")
|
|
720
|
+
console.print(" plato agent run -a <agent> -m <model> -d <dataset>")
|
|
721
|
+
console.print("\n[bold]Example:[/bold]")
|
|
722
|
+
console.print(" plato agent run -a claude-code -m anthropic/claude-sonnet-4 -d swe-bench-lite")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@agent_app.command(name="schema")
|
|
726
|
+
def agent_schema(
|
|
727
|
+
agent_name: str = typer.Argument(..., help="Agent name to get schema for"),
|
|
728
|
+
):
|
|
729
|
+
"""Get the configuration schema for a Harbor agent.
|
|
730
|
+
|
|
731
|
+
Shows the JSON schema defining configuration options for the specified agent.
|
|
732
|
+
The schema describes what fields are available when configuring the agent for runs.
|
|
733
|
+
|
|
734
|
+
Arguments:
|
|
735
|
+
agent_name: Name of the agent (e.g., 'claude-code', 'openhands')
|
|
736
|
+
"""
|
|
737
|
+
try:
|
|
738
|
+
from plato.agents import AGENT_SCHEMAS, get_agent_schema
|
|
739
|
+
except ImportError:
|
|
740
|
+
console.print("[red]Error: plato.agents module not available[/red]")
|
|
741
|
+
console.print("\n[yellow]Install with:[/yellow]")
|
|
742
|
+
console.print(" pip install 'plato-sdk-v2[agents]'")
|
|
743
|
+
raise typer.Exit(1) from None
|
|
744
|
+
|
|
745
|
+
schema = get_agent_schema(agent_name)
|
|
746
|
+
if not schema:
|
|
747
|
+
console.print(f"[red]Error: No schema found for agent '{agent_name}'[/red]")
|
|
748
|
+
console.print("\n[yellow]Available agents:[/yellow]")
|
|
749
|
+
for name in sorted(AGENT_SCHEMAS.keys()):
|
|
750
|
+
console.print(f" {name}")
|
|
751
|
+
raise typer.Exit(1)
|
|
752
|
+
|
|
753
|
+
console.print(f"[bold]Schema for {agent_name}:[/bold]\n")
|
|
754
|
+
console.print(json.dumps(schema, indent=2))
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@agent_app.command(name="publish")
|
|
758
|
+
def agent_publish(
|
|
759
|
+
target: str = typer.Argument(".", help="Path to agent directory OR Harbor agent name"),
|
|
760
|
+
all_agents: bool = typer.Option(False, "--all", "-a", help="Publish all agents in directory"),
|
|
761
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Build without pushing to ECR"),
|
|
762
|
+
):
|
|
763
|
+
"""Build and publish an agent Docker image to ECR.
|
|
764
|
+
|
|
765
|
+
Builds a Docker image for the agent and pushes it to the Plato ECR registry.
|
|
766
|
+
Version is determined automatically from pyproject.toml (custom agents) or the
|
|
767
|
+
installed Harbor package version (Harbor agents).
|
|
768
|
+
|
|
769
|
+
Arguments:
|
|
770
|
+
target: Path to custom agent directory with Dockerfile + pyproject.toml,
|
|
771
|
+
OR name of a Harbor built-in agent (e.g., 'claude-code')
|
|
772
|
+
|
|
773
|
+
Options:
|
|
774
|
+
-a, --all: Publish all agents found in the target directory
|
|
775
|
+
--dry-run: Build the Docker image without pushing to ECR
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
# Handle --all flag with directory
|
|
779
|
+
if all_agents:
|
|
780
|
+
target_path = Path(target).resolve()
|
|
781
|
+
if not target_path.is_dir():
|
|
782
|
+
console.print(f"[red]Error: '{target}' is not a directory[/red]")
|
|
783
|
+
raise typer.Exit(1)
|
|
784
|
+
|
|
785
|
+
# Find all subdirectories with pyproject.toml (custom agents)
|
|
786
|
+
agent_dirs = [d for d in target_path.iterdir() if d.is_dir() and (d / "pyproject.toml").exists()]
|
|
787
|
+
|
|
788
|
+
if not agent_dirs:
|
|
789
|
+
console.print(f"[yellow]No agents found in {target_path}[/yellow]")
|
|
790
|
+
console.print("[dim]Looking for subdirectories with pyproject.toml[/dim]")
|
|
791
|
+
raise typer.Exit(1)
|
|
792
|
+
|
|
793
|
+
console.print(f"[bold]Publishing {len(agent_dirs)} agents from {target_path}...[/bold]\n")
|
|
794
|
+
|
|
795
|
+
failed = []
|
|
796
|
+
succeeded = []
|
|
797
|
+
|
|
798
|
+
for agent_dir in sorted(agent_dirs):
|
|
799
|
+
console.print(f"\n[bold cyan]{'=' * 50}[/bold cyan]")
|
|
800
|
+
console.print(f"[bold cyan]{agent_dir.name}[/bold cyan]")
|
|
801
|
+
console.print(f"[bold cyan]{'=' * 50}[/bold cyan]\n")
|
|
802
|
+
|
|
803
|
+
try:
|
|
804
|
+
# Recursively call agent_push for each agent
|
|
805
|
+
_push_single_agent(agent_dir, dry_run)
|
|
806
|
+
succeeded.append(agent_dir.name)
|
|
807
|
+
except SystemExit:
|
|
808
|
+
failed.append(agent_dir.name)
|
|
809
|
+
except Exception as e:
|
|
810
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
811
|
+
failed.append(agent_dir.name)
|
|
812
|
+
|
|
813
|
+
console.print(f"\n[bold]{'=' * 50}[/bold]")
|
|
814
|
+
console.print(f"[green]Succeeded:[/green] {len(succeeded)}")
|
|
815
|
+
console.print(f"[red]Failed:[/red] {len(failed)}")
|
|
816
|
+
if failed:
|
|
817
|
+
console.print(f"[yellow]Failed:[/yellow] {', '.join(failed)}")
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
# Treat target as a path to agent directory
|
|
821
|
+
pkg_path = Path(target).resolve()
|
|
822
|
+
if not pkg_path.exists():
|
|
823
|
+
console.print(f"[red]Error: '{target}' is not a valid path[/red]")
|
|
824
|
+
raise typer.Exit(1)
|
|
825
|
+
|
|
826
|
+
_push_single_agent(pkg_path, dry_run)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _push_single_agent(pkg_path: Path, dry_run: bool) -> None:
|
|
830
|
+
"""Push a single custom agent from a directory."""
|
|
831
|
+
from plato.agents.build import BuildConfig
|
|
832
|
+
|
|
833
|
+
# Check for Dockerfile
|
|
834
|
+
if not (pkg_path / "Dockerfile").exists():
|
|
835
|
+
console.print(f"[red]Error: No Dockerfile found at {pkg_path}[/red]")
|
|
836
|
+
raise typer.Exit(1)
|
|
837
|
+
|
|
838
|
+
# Load pyproject.toml for version
|
|
839
|
+
pyproject_file = pkg_path / "pyproject.toml"
|
|
840
|
+
if not pyproject_file.exists():
|
|
841
|
+
console.print(f"[red]Error: No pyproject.toml found at {pkg_path}[/red]")
|
|
842
|
+
raise typer.Exit(1)
|
|
843
|
+
|
|
844
|
+
try:
|
|
845
|
+
import tomli
|
|
846
|
+
|
|
847
|
+
with open(pyproject_file, "rb") as f:
|
|
848
|
+
pyproject = tomli.load(f)
|
|
849
|
+
except Exception as e:
|
|
850
|
+
console.print(f"[red]Error reading pyproject.toml: {e}[/red]")
|
|
851
|
+
raise typer.Exit(1) from e
|
|
852
|
+
|
|
853
|
+
project = pyproject.get("project", {})
|
|
854
|
+
package_name = project.get("name", "")
|
|
855
|
+
version = project.get("version")
|
|
856
|
+
description = project.get("description", "")
|
|
857
|
+
|
|
858
|
+
if not version:
|
|
859
|
+
console.print("[red]Error: No version in pyproject.toml[/red]")
|
|
860
|
+
raise typer.Exit(1)
|
|
861
|
+
|
|
862
|
+
# Extract short name (remove common prefixes)
|
|
863
|
+
short_name = package_name
|
|
864
|
+
for suffix in ("-agent",):
|
|
865
|
+
if short_name.endswith(suffix):
|
|
866
|
+
short_name = short_name[: -len(suffix)]
|
|
867
|
+
break
|
|
868
|
+
|
|
869
|
+
# Load build config from pyproject.toml (optional, for build args)
|
|
870
|
+
build_config = None
|
|
871
|
+
try:
|
|
872
|
+
build_config = BuildConfig.from_pyproject(pkg_path)
|
|
873
|
+
if build_config.env:
|
|
874
|
+
console.print(f"[cyan]Build args:[/cyan] {list(build_config.env.keys())}")
|
|
875
|
+
except Exception:
|
|
876
|
+
pass # Build config is optional
|
|
877
|
+
|
|
878
|
+
# Load schema from entry point defined in pyproject.toml
|
|
879
|
+
schema_data = None
|
|
880
|
+
entry_points_config = pyproject.get("project", {}).get("entry-points", {}).get("plato.agents", {})
|
|
881
|
+
|
|
882
|
+
if not entry_points_config:
|
|
883
|
+
console.print("[yellow]No plato.agents entry point in pyproject.toml - agent will have no schema[/yellow]")
|
|
884
|
+
else:
|
|
885
|
+
# Get the first (and typically only) entry point
|
|
886
|
+
# Format is: agent_name = "module_name:ClassName"
|
|
887
|
+
for ep_name, ep_value in entry_points_config.items():
|
|
888
|
+
try:
|
|
889
|
+
if ":" not in ep_value:
|
|
890
|
+
console.print(f"[yellow]Invalid entry point format '{ep_value}' - expected 'module:Class'[/yellow]")
|
|
891
|
+
continue
|
|
892
|
+
|
|
893
|
+
module_name, class_name = ep_value.split(":", 1)
|
|
894
|
+
|
|
895
|
+
# Add src/ to path and import
|
|
896
|
+
import sys
|
|
897
|
+
|
|
898
|
+
src_path = pkg_path / "src"
|
|
899
|
+
if src_path.exists():
|
|
900
|
+
sys.path.insert(0, str(src_path))
|
|
901
|
+
|
|
902
|
+
try:
|
|
903
|
+
module = __import__(module_name, fromlist=[class_name])
|
|
904
|
+
agent_cls = getattr(module, class_name)
|
|
905
|
+
schema_data = agent_cls.get_schema()
|
|
906
|
+
console.print(f"[green]Loaded schema from {class_name}[/green]")
|
|
907
|
+
break
|
|
908
|
+
finally:
|
|
909
|
+
if src_path.exists() and str(src_path) in sys.path:
|
|
910
|
+
sys.path.remove(str(src_path))
|
|
911
|
+
|
|
912
|
+
except Exception as e:
|
|
913
|
+
console.print(f"[yellow]Failed to load schema from entry point: {e}[/yellow]")
|
|
914
|
+
|
|
915
|
+
if not schema_data:
|
|
916
|
+
console.print("[yellow]No schema found (agent will have no config validation)[/yellow]")
|
|
917
|
+
|
|
918
|
+
_publish_agent_image(
|
|
919
|
+
agent_name=short_name,
|
|
920
|
+
version=version,
|
|
921
|
+
build_path=pkg_path,
|
|
922
|
+
description=description or f"Custom agent: {short_name}",
|
|
923
|
+
dry_run=dry_run,
|
|
924
|
+
schema_data=schema_data,
|
|
925
|
+
build_config=build_config,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@agent_app.command(name="images")
|
|
930
|
+
def agent_images():
|
|
931
|
+
"""List all published agent images for your organization.
|
|
932
|
+
|
|
933
|
+
Queries the Plato API to show all agent Docker images that have been published
|
|
934
|
+
to your organization's ECR registry. Requires PLATO_API_KEY.
|
|
935
|
+
"""
|
|
936
|
+
import httpx
|
|
937
|
+
|
|
938
|
+
api_key = os.getenv("PLATO_API_KEY")
|
|
939
|
+
if not api_key:
|
|
940
|
+
console.print("[red]Error: PLATO_API_KEY not set[/red]")
|
|
941
|
+
raise typer.Exit(1)
|
|
942
|
+
|
|
943
|
+
base_url = os.getenv("PLATO_BASE_URL", "https://plato.so").rstrip("/")
|
|
944
|
+
if base_url.endswith("/api"):
|
|
945
|
+
base_url = base_url[:-4]
|
|
946
|
+
|
|
947
|
+
with httpx.Client(base_url=f"{base_url}/api", timeout=30.0) as client:
|
|
948
|
+
response = client.get("/v2/agents/", headers={"X-API-Key": api_key})
|
|
949
|
+
|
|
950
|
+
if response.status_code != 200:
|
|
951
|
+
console.print(f"[red]Error: {response.status_code}[/red]")
|
|
952
|
+
raise typer.Exit(1)
|
|
953
|
+
|
|
954
|
+
data = response.json()
|
|
955
|
+
|
|
956
|
+
agents = data.get("agents", [])
|
|
957
|
+
if not agents:
|
|
958
|
+
console.print("[yellow]No published agents found[/yellow]")
|
|
959
|
+
console.print("\n[dim]Publish with: plato agent publish <path-or-name>[/dim]")
|
|
960
|
+
return
|
|
961
|
+
|
|
962
|
+
console.print("[bold]Published Agent Images:[/bold]\n")
|
|
963
|
+
for agent in agents:
|
|
964
|
+
console.print(f" [cyan]{agent['name']:<20}[/cyan] v{agent['version']:<10} {agent.get('description', '')[:40]}")
|
|
965
|
+
console.print(f"\n[dim]Total: {len(agents)} agent(s)[/dim]")
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
@agent_app.command(name="versions")
|
|
969
|
+
def agent_versions(
|
|
970
|
+
agent_name: str = typer.Argument(..., help="Agent name"),
|
|
971
|
+
):
|
|
972
|
+
"""List all published versions of an agent.
|
|
973
|
+
|
|
974
|
+
Shows all available versions of the specified agent in your organization's registry.
|
|
975
|
+
|
|
976
|
+
Arguments:
|
|
977
|
+
agent_name: Name of the agent to list versions for
|
|
978
|
+
"""
|
|
979
|
+
import httpx
|
|
980
|
+
|
|
981
|
+
api_key = os.getenv("PLATO_API_KEY")
|
|
982
|
+
if not api_key:
|
|
983
|
+
console.print("[red]Error: PLATO_API_KEY not set[/red]")
|
|
984
|
+
raise typer.Exit(1)
|
|
985
|
+
|
|
986
|
+
base_url = os.getenv("PLATO_BASE_URL", "https://plato.so").rstrip("/")
|
|
987
|
+
if base_url.endswith("/api"):
|
|
988
|
+
base_url = base_url[:-4]
|
|
989
|
+
|
|
990
|
+
with httpx.Client(base_url=f"{base_url}/api", timeout=30.0) as client:
|
|
991
|
+
response = client.get(f"/v2/agents/{agent_name}/versions", headers={"X-API-Key": api_key})
|
|
992
|
+
|
|
993
|
+
if response.status_code == 404:
|
|
994
|
+
console.print(f"[red]Agent '{agent_name}' not found[/red]")
|
|
995
|
+
raise typer.Exit(1)
|
|
996
|
+
elif response.status_code != 200:
|
|
997
|
+
console.print(f"[red]Error: {response.status_code}[/red]")
|
|
998
|
+
raise typer.Exit(1)
|
|
999
|
+
|
|
1000
|
+
data = response.json()
|
|
1001
|
+
|
|
1002
|
+
versions = data.get("versions", [])
|
|
1003
|
+
if not versions:
|
|
1004
|
+
console.print(f"[yellow]No versions found for '{agent_name}'[/yellow]")
|
|
1005
|
+
return
|
|
1006
|
+
|
|
1007
|
+
console.print(f"[bold]Versions of {agent_name}:[/bold]\n")
|
|
1008
|
+
for v in versions:
|
|
1009
|
+
console.print(f" [cyan]v{v['version']:<12}[/cyan] {v['published_at'][:10]} {v['artifact_id'][:12]}...")
|
|
1010
|
+
console.print(f"\n[dim]Total: {len(versions)} version(s)[/dim]")
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
@agent_app.command(name="deploy")
|
|
1014
|
+
def agent_deploy(
|
|
1015
|
+
path: str = typer.Argument(".", help="Path to the agent package directory (default: current directory)"),
|
|
1016
|
+
):
|
|
1017
|
+
"""Deploy a Chronos agent package to AWS CodeArtifact.
|
|
1018
|
+
|
|
1019
|
+
Builds the Python package, discovers @ai agents from the codebase, and uploads
|
|
1020
|
+
to CodeArtifact via the Plato API for use in Chronos jobs.
|
|
1021
|
+
|
|
1022
|
+
Arguments:
|
|
1023
|
+
path: Path to the agent package directory with pyproject.toml (default: current directory)
|
|
1024
|
+
|
|
1025
|
+
Requires PLATO_API_KEY environment variable.
|
|
1026
|
+
"""
|
|
1027
|
+
try:
|
|
1028
|
+
import tomli
|
|
1029
|
+
except ImportError:
|
|
1030
|
+
console.print("[red]❌ tomli is not installed[/red]")
|
|
1031
|
+
console.print("\n[yellow]Install with:[/yellow]")
|
|
1032
|
+
console.print(" pip install tomli")
|
|
1033
|
+
raise typer.Exit(1) from None
|
|
1034
|
+
|
|
1035
|
+
api_key = require_api_key()
|
|
1036
|
+
# Get base URL (default to production)
|
|
1037
|
+
base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
|
|
1038
|
+
# Normalize: remove trailing slash and /api if present
|
|
1039
|
+
base_url = base_url.rstrip("/")
|
|
1040
|
+
if base_url.endswith("/api"):
|
|
1041
|
+
base_url = base_url[:-4]
|
|
1042
|
+
api_url = f"{base_url}/api"
|
|
1043
|
+
|
|
1044
|
+
# Resolve package path
|
|
1045
|
+
pkg_path = Path(path).resolve()
|
|
1046
|
+
if not pkg_path.exists():
|
|
1047
|
+
console.print(f"[red]❌ Path does not exist: {pkg_path}[/red]")
|
|
1048
|
+
raise typer.Exit(1)
|
|
1049
|
+
|
|
1050
|
+
# Load pyproject.toml
|
|
1051
|
+
pyproject_file = pkg_path / "pyproject.toml"
|
|
1052
|
+
if not pyproject_file.exists():
|
|
1053
|
+
console.print(f"[red]❌ No pyproject.toml found at {pkg_path}[/red]")
|
|
1054
|
+
raise typer.Exit(1)
|
|
1055
|
+
|
|
1056
|
+
try:
|
|
1057
|
+
with open(pyproject_file, "rb") as f:
|
|
1058
|
+
pyproject = tomli.load(f)
|
|
1059
|
+
except Exception as e:
|
|
1060
|
+
console.print(f"[red]❌ Error reading pyproject.toml: {e}[/red]")
|
|
1061
|
+
raise typer.Exit(1) from e
|
|
1062
|
+
|
|
1063
|
+
# Extract package info
|
|
1064
|
+
project = pyproject.get("project", {})
|
|
1065
|
+
package_name = project.get("name")
|
|
1066
|
+
version = project.get("version")
|
|
1067
|
+
description = project.get("description", "")
|
|
1068
|
+
|
|
1069
|
+
if not package_name:
|
|
1070
|
+
console.print("[red]❌ No package name in pyproject.toml[/red]")
|
|
1071
|
+
raise typer.Exit(1)
|
|
1072
|
+
if not version:
|
|
1073
|
+
console.print("[red]❌ No version in pyproject.toml[/red]")
|
|
1074
|
+
raise typer.Exit(1)
|
|
1075
|
+
|
|
1076
|
+
# Validate semantic version format
|
|
1077
|
+
if not re.match(r"^\d+\.\d+\.\d+$", version):
|
|
1078
|
+
console.print(f"[red]❌ Invalid version format: {version}[/red]")
|
|
1079
|
+
console.print("[yellow]Version must be semantic (X.Y.Z)[/yellow]")
|
|
1080
|
+
raise typer.Exit(1)
|
|
1081
|
+
|
|
1082
|
+
console.print(f"[cyan]Package:[/cyan] {package_name}")
|
|
1083
|
+
console.print(f"[cyan]Version:[/cyan] {version}")
|
|
1084
|
+
console.print(f"[cyan]Path:[/cyan] {pkg_path}")
|
|
1085
|
+
console.print()
|
|
1086
|
+
|
|
1087
|
+
# Build package
|
|
1088
|
+
console.print("[cyan]Building package...[/cyan]")
|
|
1089
|
+
try:
|
|
1090
|
+
result = subprocess.run(
|
|
1091
|
+
["uv", "build"],
|
|
1092
|
+
cwd=pkg_path,
|
|
1093
|
+
capture_output=True,
|
|
1094
|
+
text=True,
|
|
1095
|
+
check=True,
|
|
1096
|
+
)
|
|
1097
|
+
console.print("[green]✅ Build successful[/green]")
|
|
1098
|
+
except subprocess.CalledProcessError as e:
|
|
1099
|
+
console.print("[red]❌ Build failed:[/red]")
|
|
1100
|
+
console.print(e.stderr)
|
|
1101
|
+
raise typer.Exit(1) from e
|
|
1102
|
+
|
|
1103
|
+
# Find built files
|
|
1104
|
+
dist_dir = pkg_path / "dist"
|
|
1105
|
+
if not dist_dir.exists():
|
|
1106
|
+
console.print("[red]❌ dist/ directory not found after build[/red]")
|
|
1107
|
+
raise typer.Exit(1)
|
|
1108
|
+
|
|
1109
|
+
# Python normalizes package names: dashes become underscores in filenames
|
|
1110
|
+
normalized_name = package_name.replace("-", "_")
|
|
1111
|
+
wheel_files = list(dist_dir.glob(f"{normalized_name}-{version}-*.whl"))
|
|
1112
|
+
sdist_files = list(dist_dir.glob(f"{normalized_name}-{version}.tar.gz"))
|
|
1113
|
+
|
|
1114
|
+
if not wheel_files:
|
|
1115
|
+
console.print(f"[red]❌ No wheel file found in {dist_dir}[/red]")
|
|
1116
|
+
raise typer.Exit(1)
|
|
1117
|
+
if not sdist_files:
|
|
1118
|
+
console.print(f"[red]❌ No sdist file found in {dist_dir}[/red]")
|
|
1119
|
+
raise typer.Exit(1)
|
|
1120
|
+
|
|
1121
|
+
wheel_file = wheel_files[0]
|
|
1122
|
+
sdist_file = sdist_files[0]
|
|
1123
|
+
|
|
1124
|
+
console.print(f"[cyan]Wheel:[/cyan] {wheel_file.name}")
|
|
1125
|
+
console.print(f"[cyan]Sdist:[/cyan] {sdist_file.name}")
|
|
1126
|
+
console.print()
|
|
1127
|
+
|
|
1128
|
+
# Upload to Plato API using generated routes
|
|
1129
|
+
console.print("[cyan]Uploading to Plato API...[/cyan]")
|
|
1130
|
+
try:
|
|
1131
|
+
import httpx
|
|
1132
|
+
|
|
1133
|
+
from plato._generated.errors import raise_for_status
|
|
1134
|
+
from plato._generated.models import UploadPackageResponse
|
|
1135
|
+
|
|
1136
|
+
with httpx.Client(base_url=api_url, timeout=120.0) as client:
|
|
1137
|
+
with open(wheel_file, "rb") as whl, open(sdist_file, "rb") as sdist:
|
|
1138
|
+
response = client.post(
|
|
1139
|
+
"/v2/chronos-packages/upload",
|
|
1140
|
+
headers={"X-API-Key": api_key},
|
|
1141
|
+
data={
|
|
1142
|
+
"package_name": package_name,
|
|
1143
|
+
"version": version,
|
|
1144
|
+
"alias": package_name,
|
|
1145
|
+
"description": description,
|
|
1146
|
+
"agents": json.dumps([]), # Server will discover agents from package
|
|
1147
|
+
},
|
|
1148
|
+
files={
|
|
1149
|
+
"wheel_file": (
|
|
1150
|
+
wheel_file.name,
|
|
1151
|
+
whl,
|
|
1152
|
+
"application/octet-stream",
|
|
1153
|
+
),
|
|
1154
|
+
"sdist_file": (
|
|
1155
|
+
sdist_file.name,
|
|
1156
|
+
sdist,
|
|
1157
|
+
"application/octet-stream",
|
|
1158
|
+
),
|
|
1159
|
+
},
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
# Use generated error handling
|
|
1163
|
+
try:
|
|
1164
|
+
raise_for_status(response)
|
|
1165
|
+
result = UploadPackageResponse.model_validate(response.json())
|
|
1166
|
+
|
|
1167
|
+
console.print("[green]✅ Deployment successful![/green]")
|
|
1168
|
+
console.print()
|
|
1169
|
+
console.print(f"[cyan]Package:[/cyan] {result.package_name} v{result.version}")
|
|
1170
|
+
console.print(f"[cyan]Artifact ID:[/cyan] {result.artifact_id}")
|
|
1171
|
+
console.print()
|
|
1172
|
+
console.print(f"[dim]{result.message}[/dim]")
|
|
1173
|
+
console.print()
|
|
1174
|
+
console.print("[bold]Install with:[/bold]")
|
|
1175
|
+
console.print(f" uv add {package_name}")
|
|
1176
|
+
|
|
1177
|
+
except httpx.HTTPStatusError as e:
|
|
1178
|
+
# Handle specific status codes
|
|
1179
|
+
if e.response.status_code == 401:
|
|
1180
|
+
console.print("[red]❌ Authentication failed[/red]")
|
|
1181
|
+
console.print("[yellow]Check your PLATO_API_KEY[/yellow]")
|
|
1182
|
+
elif e.response.status_code == 403:
|
|
1183
|
+
try:
|
|
1184
|
+
detail = e.response.json().get("detail", "Package name conflict")
|
|
1185
|
+
except Exception:
|
|
1186
|
+
detail = e.response.text
|
|
1187
|
+
console.print(f"[red]❌ Forbidden: {detail}[/red]")
|
|
1188
|
+
console.print("[yellow]This package name is owned by another organization[/yellow]")
|
|
1189
|
+
elif e.response.status_code == 409:
|
|
1190
|
+
try:
|
|
1191
|
+
detail = e.response.json().get("detail", "Version conflict")
|
|
1192
|
+
except Exception:
|
|
1193
|
+
detail = e.response.text
|
|
1194
|
+
console.print(f"[red]❌ Version conflict: {detail}[/red]")
|
|
1195
|
+
console.print("[yellow]Bump the version in pyproject.toml[/yellow]")
|
|
1196
|
+
else:
|
|
1197
|
+
try:
|
|
1198
|
+
detail = e.response.json().get("detail", e.response.text)
|
|
1199
|
+
except Exception:
|
|
1200
|
+
detail = e.response.text
|
|
1201
|
+
console.print(f"[red]❌ Upload failed ({e.response.status_code}): {detail}[/red]")
|
|
1202
|
+
raise typer.Exit(1) from e
|
|
1203
|
+
|
|
1204
|
+
except httpx.HTTPError as e:
|
|
1205
|
+
console.print(f"[red]❌ Network error: {e}[/red]")
|
|
1206
|
+
raise typer.Exit(1) from e
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
console.print(f"[red]❌ Upload error: {e}[/red]")
|
|
1209
|
+
raise typer.Exit(1) from e
|