plato-sdk-v2 2.1.17__py3-none-any.whl → 2.2.2__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.
@@ -157,7 +157,8 @@ class InstructionGenerator:
157
157
  def _copy_instructions_yaml(self):
158
158
  """Bundle the instructions.yaml config with the package."""
159
159
  # We'll generate a cleaned version of the config
160
- config_data = {
160
+ env_vars: dict[str, dict[str, str | int]] = {}
161
+ config_data: dict[str, str | dict] = {
161
162
  "type": "instruction",
162
163
  "env_prefix": self.config.env_prefix,
163
164
  "title": self.config.title,
@@ -166,7 +167,7 @@ class InstructionGenerator:
166
167
  "services": {
167
168
  name: {"port": svc.port, "description": svc.description} for name, svc in self.config.services.items()
168
169
  },
169
- "env_vars": {},
170
+ "env_vars": env_vars,
170
171
  "instructions": self.config.instructions,
171
172
  }
172
173
 
@@ -178,7 +179,7 @@ class InstructionGenerator:
178
179
  var_data["default"] = var.default
179
180
  if var.env_key:
180
181
  var_data["env_key"] = var.env_key
181
- config_data["env_vars"][name] = var_data
182
+ env_vars[name] = var_data
182
183
 
183
184
  with open(self.output / "instructions.yaml", "w") as f:
184
185
  yaml.dump(config_data, f, default_flow_style=False, sort_keys=False)
@@ -9,7 +9,7 @@ Spec: https://harborframework.com/docs/trajectory-format
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from datetime import datetime
12
+ from datetime import datetime, timezone
13
13
  from typing import Any, Literal
14
14
 
15
15
  from pydantic import BaseModel, Field
@@ -115,7 +115,7 @@ class Step(BaseModel):
115
115
  """Create a user step."""
116
116
  return cls(
117
117
  step_id=step_id,
118
- timestamp=datetime.utcnow().isoformat() + "Z",
118
+ timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
119
119
  source="user",
120
120
  message=message,
121
121
  **kwargs,
@@ -136,7 +136,7 @@ class Step(BaseModel):
136
136
  """Create an agent step."""
137
137
  return cls(
138
138
  step_id=step_id,
139
- timestamp=datetime.utcnow().isoformat() + "Z",
139
+ timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
140
140
  source="agent",
141
141
  message=message,
142
142
  model_name=model_name,
@@ -152,7 +152,7 @@ class Step(BaseModel):
152
152
  """Create a system step."""
153
153
  return cls(
154
154
  step_id=step_id,
155
- timestamp=datetime.utcnow().isoformat() + "Z",
155
+ timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
156
156
  source="system",
157
157
  message=message,
158
158
  **kwargs,
@@ -4,7 +4,7 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from typing import Annotated, Any, Dict, List
7
+ from typing import Annotated, Any
8
8
 
9
9
  from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
10
10
 
plato/sims/cli.py CHANGED
@@ -241,9 +241,11 @@ def _get_function_info(
241
241
  }
242
242
 
243
243
  # Extract parameters
244
+ params_list = result["params"]
245
+ assert isinstance(params_list, list) # type narrowing for ty
244
246
  for param_name, param in sig.parameters.items():
245
247
  if param_name == "client":
246
- result["params"].append("client.httpx")
248
+ params_list.append("client.httpx")
247
249
  continue
248
250
 
249
251
  # Get type from hints or annotation
@@ -251,10 +253,10 @@ def _get_function_info(
251
253
  type_str = _format_type_annotation(param_type)
252
254
 
253
255
  if param.default is inspect.Parameter.empty:
254
- result["params"].append(f"{param_name}: {type_str}")
256
+ params_list.append(f"{param_name}: {type_str}")
255
257
  else:
256
258
  default_repr = repr(param.default) if param.default is not None else "None"
257
- result["params"].append(f"{param_name}: {type_str} = {default_repr}")
259
+ params_list.append(f"{param_name}: {type_str} = {default_repr}")
258
260
 
259
261
  # Check if this is the body parameter
260
262
  if param_name == "body" and param_type is not inspect.Parameter.empty:
@@ -1162,7 +1164,8 @@ def cmd_publish(
1162
1164
  generator_version_file.write_text(f"{generator_version}\n")
1163
1165
  print(f" Updated {generator_version_file}")
1164
1166
 
1165
- # Load spec
1167
+ # Load spec (spec_file is guaranteed non-None after check above)
1168
+ assert spec_file is not None
1166
1169
  with open(spec_file) as f:
1167
1170
  if spec_file.suffix == ".json":
1168
1171
  spec = json.load(f)
plato/sims/registry.py CHANGED
@@ -186,7 +186,6 @@ class SimsRegistry:
186
186
  import importlib.resources
187
187
 
188
188
  services = getattr(mod, "SERVICES", {})
189
- env_prefix = getattr(mod, "ENV_PREFIX", "")
190
189
 
191
190
  # Try to load full config from bundled instructions.yaml
192
191
  env_vars_config: dict[str, dict[str, Any]] = {}
plato/v1/cli/agent.py CHANGED
@@ -7,11 +7,15 @@ import shutil
7
7
  import subprocess
8
8
  import sys
9
9
  from pathlib import Path
10
+ from typing import TYPE_CHECKING
10
11
 
11
12
  import typer
12
13
 
13
14
  from plato.v1.cli.utils import console, require_api_key
14
15
 
16
+ if TYPE_CHECKING:
17
+ from plato.agents.build import BuildConfig
18
+
15
19
 
16
20
  def _extract_schemas(pkg_path: Path, package_name: str) -> tuple[dict | None, dict | None, dict | None]:
17
21
  """Extract Config, BuildConfig, and SecretsConfig schemas from the agent package.
@@ -325,6 +329,9 @@ def _publish_agent_image(
325
329
 
326
330
  # Stream build output
327
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)
328
335
  for line in iter(process.stdout.readline, ""):
329
336
  line = line.rstrip()
330
337
  if line:
@@ -562,6 +569,8 @@ def _publish_package(path: str, repo: str, dry_run: bool = False):
562
569
  upload_url = f"{api_url}/v2/pypi/{repo}/"
563
570
  console.print(f"\n[cyan]Uploading to {upload_url}...[/cyan]")
564
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"
565
574
  try:
566
575
  result = subprocess.run(
567
576
  [
@@ -577,6 +586,7 @@ def _publish_package(path: str, repo: str, dry_run: bool = False):
577
586
  ],
578
587
  capture_output=True,
579
588
  text=True,
589
+ check=False,
580
590
  )
581
591
 
582
592
  if result.returncode == 0:
plato/v1/cli/main.py CHANGED
@@ -11,6 +11,7 @@ from dotenv import load_dotenv
11
11
  from plato.v1.cli.agent import agent_app
12
12
  from plato.v1.cli.pm import pm_app
13
13
  from plato.v1.cli.sandbox import sandbox_app
14
+ from plato.v1.cli.sim import sim_app
14
15
  from plato.v1.cli.utils import console
15
16
  from plato.v1.cli.world import world_app
16
17
 
@@ -69,6 +70,7 @@ app = typer.Typer(help="[bold blue]Plato CLI[/bold blue] - Manage Plato environm
69
70
  # Register sub-apps
70
71
  app.add_typer(sandbox_app, name="sandbox")
71
72
  app.add_typer(pm_app, name="pm")
73
+ app.add_typer(sim_app, name="sim")
72
74
  app.add_typer(agent_app, name="agent")
73
75
  app.add_typer(world_app, name="world")
74
76
 
plato/v1/cli/pm.py CHANGED
@@ -7,18 +7,11 @@ import re
7
7
  import shutil
8
8
  import tempfile
9
9
  from pathlib import Path
10
- from typing import TYPE_CHECKING
11
10
 
12
11
  import httpx
13
12
  import typer
14
13
  from rich.table import Table
15
14
 
16
- # UUID pattern for detecting artifact IDs in sim:artifact notation
17
- UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
18
-
19
- if TYPE_CHECKING:
20
- pass
21
-
22
15
  from plato._generated.api.v1.env import get_simulator_by_name, get_simulators
23
16
  from plato._generated.api.v1.organization import get_organization_members
24
17
  from plato._generated.api.v1.simulator import (
@@ -46,9 +39,17 @@ from plato.v1.cli.utils import (
46
39
  require_sandbox_field,
47
40
  require_sandbox_state,
48
41
  )
42
+ from plato.v1.cli.verify import pm_verify_app
49
43
  from plato.v2.async_.client import AsyncPlato
50
44
  from plato.v2.types import Env
51
45
 
46
+ # =============================================================================
47
+ # CONSTANTS
48
+ # =============================================================================
49
+
50
+ # UUID pattern for detecting artifact IDs in sim:artifact notation
51
+ UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
52
+
52
53
  # =============================================================================
53
54
  # APP STRUCTURE
54
55
  # =============================================================================
@@ -61,6 +62,7 @@ submit_app = typer.Typer(help="Submit simulator artifacts for review")
61
62
  pm_app.add_typer(list_app, name="list")
62
63
  pm_app.add_typer(review_app, name="review")
63
64
  pm_app.add_typer(submit_app, name="submit")
65
+ pm_app.add_typer(pm_verify_app, name="verify")
64
66
 
65
67
 
66
68
  # =============================================================================
@@ -343,6 +345,9 @@ def review_base(
343
345
  try:
344
346
  http_client = plato._http
345
347
 
348
+ # simulator_name is guaranteed set by parse_simulator_artifact (or we exit)
349
+ assert simulator_name is not None, "simulator_name must be set"
350
+
346
351
  # Get simulator by name
347
352
  sim = await get_simulator_by_name.asyncio(
348
353
  client=http_client,
@@ -356,7 +361,7 @@ def review_base(
356
361
  console.print(f"[cyan]Current status:[/cyan] {current_status}")
357
362
 
358
363
  # Use provided artifact ID or fall back to base_artifact_id from server config
359
- artifact_id = artifact_id_input if artifact_id_input else current_config.get("base_artifact_id")
364
+ artifact_id: str | None = artifact_id_input if artifact_id_input else current_config.get("base_artifact_id")
360
365
  if not artifact_id:
361
366
  console.print("[red]❌ No artifact ID provided.[/red]")
362
367
  console.print(
@@ -408,44 +413,57 @@ def review_base(
408
413
  if public_url:
409
414
  await page.goto(public_url)
410
415
 
411
- # If skip_review, check state and exit without interactive loop
412
- if skip_review:
413
- console.print("\n[cyan]Checking environment state after login...[/cyan]")
414
- try:
415
- state_response = await sessions_state.asyncio(
416
- client=http_client,
417
- session_id=session.session_id,
418
- merge_mutations=True,
419
- x_api_key=api_key,
420
- )
421
- if state_response and state_response.results:
422
- has_mutations = False
423
- for jid, result in state_response.results.items():
424
- state_data = result.state if hasattr(result, "state") else result
425
- if isinstance(state_data, dict):
426
- mutations = state_data.pop("mutations", [])
427
- console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
428
- console.print("\n[bold]State:[/bold]")
429
- console.print(json.dumps(state_data, indent=2, default=str))
430
- if mutations:
431
- has_mutations = True
432
- console.print(f"\n[bold red]Mutations ({len(mutations)}):[/bold red]")
433
- console.print(json.dumps(mutations, indent=2, default=str))
434
- else:
435
- console.print("\n[green]No mutations recorded[/green]")
436
-
437
- if has_mutations:
438
- console.print("\n[bold red]⚠️ WARNING: Login flow created mutations![/bold red]")
439
- console.print("[yellow]The login flow should NOT modify database state.[/yellow]")
416
+ # ALWAYS check state after login to verify no mutations
417
+ console.print("\n[cyan]Checking environment state after login...[/cyan]")
418
+ has_mutations = False
419
+ has_errors = False
420
+ try:
421
+ state_response = await sessions_state.asyncio(
422
+ client=http_client,
423
+ session_id=session.session_id,
424
+ merge_mutations=True,
425
+ x_api_key=api_key,
426
+ )
427
+ if state_response and state_response.results:
428
+ for jid, result in state_response.results.items():
429
+ state_data = result.state if hasattr(result, "state") else result
430
+ console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
431
+
432
+ if isinstance(state_data, dict):
433
+ # Check for error in state response
434
+ if "error" in state_data:
435
+ has_errors = True
436
+ console.print("\n[bold red]❌ State API Error:[/bold red]")
437
+ console.print(f"[red]{state_data['error']}[/red]")
438
+ continue
439
+
440
+ mutations = state_data.pop("mutations", [])
441
+ console.print("\n[bold]State:[/bold]")
442
+ console.print(json.dumps(state_data, indent=2, default=str))
443
+ if mutations:
444
+ has_mutations = True
445
+ console.print(f"\n[bold red]Mutations ({len(mutations)}):[/bold red]")
446
+ console.print(json.dumps(mutations, indent=2, default=str))
447
+ else:
448
+ console.print("\n[green]No mutations recorded[/green]")
440
449
  else:
441
- console.print(
442
- "\n[bold green]✅ Login flow verified - no mutations created[/bold green]"
443
- )
450
+ console.print(f"[yellow]Unexpected state format: {type(state_data)}[/yellow]")
451
+
452
+ if has_errors:
453
+ console.print("\n[bold red]❌ State check failed due to errors![/bold red]")
454
+ console.print("[yellow]The worker may not be properly connected.[/yellow]")
455
+ elif has_mutations:
456
+ console.print("\n[bold red]⚠️ WARNING: Login flow created mutations![/bold red]")
457
+ console.print("[yellow]The login flow should NOT modify database state.[/yellow]")
444
458
  else:
445
- console.print("[yellow]No state data available[/yellow]")
446
- except Exception as e:
447
- console.print(f"[red] Error getting state: {e}[/red]")
459
+ console.print("\n[bold green] Login flow verified - no mutations created[/bold green]")
460
+ else:
461
+ console.print("[yellow]No state data available[/yellow]")
462
+ except Exception as e:
463
+ console.print(f"[red]❌ Error getting state: {e}[/red]")
448
464
 
465
+ # If skip_review, exit without interactive loop
466
+ if skip_review:
449
467
  console.print("\n[cyan]Skipping interactive review (--skip-review)[/cyan]")
450
468
  return
451
469
 
@@ -497,9 +515,16 @@ def review_base(
497
515
  if state_response and state_response.results:
498
516
  for jid, result in state_response.results.items():
499
517
  state_data = result.state if hasattr(result, "state") else result
518
+ console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
519
+
500
520
  if isinstance(state_data, dict):
521
+ # Check for error in state response
522
+ if "error" in state_data:
523
+ console.print("\n[bold red]❌ State API Error:[/bold red]")
524
+ console.print(f"[red]{state_data['error']}[/red]")
525
+ continue
526
+
501
527
  mutations = state_data.pop("mutations", [])
502
- console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
503
528
  console.print("\n[bold]State:[/bold]")
504
529
  console.print(json.dumps(state_data, indent=2, default=str))
505
530
  if mutations:
@@ -508,7 +533,6 @@ def review_base(
508
533
  else:
509
534
  console.print("\n[yellow]No mutations recorded[/yellow]")
510
535
  else:
511
- console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
512
536
  console.print(json.dumps(state_data, indent=2, default=str))
513
537
  else:
514
538
  console.print("[yellow]No state data available[/yellow]")
@@ -584,9 +608,11 @@ def review_base(
584
608
  console.print(f"[cyan]Status:[/cyan] {current_status} → {new_status}")
585
609
 
586
610
  # If passed, automatically tag artifact as prod-latest
587
- if outcome == "pass":
611
+ if outcome == "pass" and artifact_id:
588
612
  console.print("\n[cyan]Tagging artifact as prod-latest...[/cyan]")
589
613
  try:
614
+ # simulator_name and artifact_id are guaranteed to be set at this point
615
+ assert simulator_name is not None
590
616
  await update_tag.asyncio(
591
617
  client=http_client,
592
618
  body=UpdateTagRequest(
@@ -686,6 +712,9 @@ def review_data(
686
712
 
687
713
  async def _fetch_artifact_info():
688
714
  nonlocal artifact_id
715
+ # simulator_name is guaranteed set by parse_simulator_artifact (or we exit)
716
+ assert simulator_name is not None, "simulator_name must be set"
717
+
689
718
  base_url = _get_base_url()
690
719
  async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
691
720
  try:
@@ -1087,6 +1116,10 @@ def submit_data(
1087
1116
  )
1088
1117
 
1089
1118
  async def _submit_data():
1119
+ # simulator_name and artifact_id are guaranteed set by parse_simulator_artifact with require_artifact=True
1120
+ assert simulator_name is not None, "simulator_name must be set"
1121
+ assert artifact_id is not None, "artifact_id must be set"
1122
+
1090
1123
  base_url = _get_base_url()
1091
1124
 
1092
1125
  async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
@@ -1117,7 +1150,7 @@ def submit_data(
1117
1150
  x_api_key=api_key,
1118
1151
  )
1119
1152
 
1120
- # Set data_artifact_id via tag update
1153
+ # Set data_artifact_id via tag update (simulator_name and artifact_id already asserted above)
1121
1154
  try:
1122
1155
  await update_tag.asyncio(
1123
1156
  client=client,
plato/v1/cli/sandbox.py CHANGED
@@ -13,19 +13,12 @@ import tempfile
13
13
  import time
14
14
  from datetime import datetime, timezone
15
15
  from pathlib import Path
16
- from typing import TYPE_CHECKING
17
16
  from urllib.parse import quote
18
17
 
19
18
  import typer
20
19
  import yaml
21
20
  from rich.logging import RichHandler
22
21
 
23
- if TYPE_CHECKING:
24
- pass
25
-
26
- # UUID pattern for detecting artifact IDs in colon notation
27
- UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
28
-
29
22
  from plato._generated.api.v1.gitea import (
30
23
  create_simulator_repository,
31
24
  get_accessible_simulators,
@@ -77,11 +70,16 @@ from plato.v1.cli.utils import (
77
70
  require_sandbox_state,
78
71
  save_sandbox_state,
79
72
  )
73
+ from plato.v1.cli.verify import sandbox_verify_app
80
74
  from plato.v2.async_.flow_executor import FlowExecutor
81
75
  from plato.v2.sync.client import Plato as PlatoV2
82
76
  from plato.v2.types import Env, SimConfigCompute
83
77
 
78
+ # UUID pattern for detecting artifact IDs in colon notation
79
+ UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
80
+
84
81
  sandbox_app = typer.Typer(help="Manage sandboxes for simulator development")
82
+ sandbox_app.add_typer(sandbox_verify_app, name="verify")
85
83
 
86
84
 
87
85
  def format_public_url_with_router_target(public_url: str | None, service_name: str | None) -> str | None:
@@ -426,9 +424,10 @@ def sandbox_start(
426
424
  listeners=listeners_dict,
427
425
  )
428
426
 
427
+ dataset_value = dataset_name or state_extras.get("dataset", "base")
429
428
  setup_request = AppSchemasBuildModelsSetupSandboxRequest(
430
429
  service=sim_name or "",
431
- dataset=dataset_name or state_extras.get("dataset", "base") or "",
430
+ dataset=str(dataset_value) if dataset_value else "",
432
431
  plato_dataset_config=dataset_config_obj,
433
432
  ssh_public_key=ssh_public_key,
434
433
  )
@@ -1050,6 +1049,9 @@ def sandbox_start_worker(
1050
1049
  console.print(f"[cyan]Waiting for worker to be ready (timeout: {wait_timeout}s)...[/cyan]")
1051
1050
 
1052
1051
  session_id = state.get("session_id")
1052
+ if not session_id:
1053
+ console.print("[red]Session ID not found in .sandbox.yaml[/red]")
1054
+ raise typer.Exit(1)
1053
1055
  start_time = time.time()
1054
1056
  poll_interval = 10 # seconds between polls
1055
1057
  worker_ready = False
@@ -1533,9 +1535,21 @@ def sandbox_flow(
1533
1535
  console.print(f"[red]❌ Failed to fetch flows from API: {e}[/red]")
1534
1536
  raise typer.Exit(1) from e
1535
1537
 
1538
+ # At this point, url and flow_obj must be set (validated above)
1539
+ if not url:
1540
+ console.print("[red]❌ URL is not set[/red]")
1541
+ raise typer.Exit(1)
1542
+ if not flow_obj:
1543
+ console.print("[red]❌ Flow object could not be loaded[/red]")
1544
+ raise typer.Exit(1)
1545
+
1536
1546
  console.print(f"[cyan]URL: {url}[/cyan]")
1537
1547
  console.print(f"[cyan]Flow name: {flow_name}[/cyan]")
1538
1548
 
1549
+ # Capture for closure (narrowed types)
1550
+ _url: str = url
1551
+ _flow_obj: Flow = flow_obj
1552
+
1539
1553
  async def _run():
1540
1554
  from playwright.async_api import async_playwright
1541
1555
 
@@ -1544,8 +1558,8 @@ def sandbox_flow(
1544
1558
  async with async_playwright() as p:
1545
1559
  browser = await p.chromium.launch(headless=False)
1546
1560
  page = await browser.new_page()
1547
- await page.goto(url)
1548
- executor = FlowExecutor(page, flow_obj, screenshots_dir, log=_flow_logger)
1561
+ await page.goto(_url)
1562
+ executor = FlowExecutor(page, _flow_obj, screenshots_dir, log=_flow_logger)
1549
1563
  await executor.execute()
1550
1564
  console.print("[green]✅ Flow executed successfully[/green]")
1551
1565
  except Exception as e:
@@ -1606,8 +1620,23 @@ def sandbox_state_cmd(
1606
1620
  def check_mutations(result_dict: dict) -> tuple[bool, bool, str | None]:
1607
1621
  """Check if result has mutations or errors. Returns (has_mutations, has_error, error_msg)."""
1608
1622
  if isinstance(result_dict, dict):
1609
- if "error" in result_dict:
1610
- return False, True, result_dict.get("error")
1623
+ # Check for state
1624
+ state = result_dict.get("state", {})
1625
+ if isinstance(state, dict):
1626
+ # Check for error wrapped in state (from API layer transformation)
1627
+ if "error" in state:
1628
+ return False, True, state["error"]
1629
+ # Check for db state
1630
+ db_state = state.get("db", {})
1631
+ if isinstance(db_state, dict):
1632
+ mutations = db_state.get("mutations", [])
1633
+ if mutations:
1634
+ return True, False, None
1635
+ # Also check audit_log_count
1636
+ audit_count = db_state.get("audit_log_count", 0)
1637
+ if audit_count > 0:
1638
+ return True, False, None
1639
+ # Check top-level mutations as fallback
1611
1640
  mutations = result_dict.get("mutations", [])
1612
1641
  if mutations:
1613
1642
  return True, False, None
@@ -2009,6 +2038,10 @@ def sandbox_start_services(
2009
2038
  if not json_output:
2010
2039
  console.print("[cyan]Step 3: Getting/creating repository...[/cyan]")
2011
2040
 
2041
+ if sim_id is None:
2042
+ console.print("[red]❌ Simulator ID not available[/red]")
2043
+ raise typer.Exit(1)
2044
+
2012
2045
  if has_repo:
2013
2046
  repo = get_simulator_repository.sync(client=client, simulator_id=sim_id, x_api_key=api_key)
2014
2047
  else:
plato/v1/cli/sim.py ADDED
@@ -0,0 +1,11 @@
1
+ """Plato CLI - Simulator commands (stub)."""
2
+
3
+ import typer
4
+
5
+ sim_app = typer.Typer(help="Simulator management commands (coming soon)")
6
+
7
+
8
+ @sim_app.command()
9
+ def list():
10
+ """List available simulators."""
11
+ typer.echo("Simulator list command not yet implemented.")