plato-sdk-v2 2.7.6__py3-none-any.whl → 2.7.7__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/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