plato-sdk-v2 2.1.11__py3-none-any.whl → 2.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plato/_sims_generator/__init__.py +19 -4
- plato/_sims_generator/instruction.py +203 -0
- plato/_sims_generator/templates/instruction/helpers.py.jinja +161 -0
- plato/_sims_generator/templates/instruction/init.py.jinja +43 -0
- plato/agents/__init__.py +15 -6
- plato/agents/logging.py +401 -0
- plato/agents/runner.py +98 -302
- plato/agents/trajectory.py +4 -4
- plato/chronos/models/__init__.py +1 -1
- plato/sims/cli.py +299 -123
- plato/sims/registry.py +77 -4
- plato/v1/cli/agent.py +10 -0
- plato/v1/cli/main.py +2 -0
- plato/v1/cli/pm.py +84 -44
- plato/v1/cli/sandbox.py +47 -9
- plato/v1/cli/sim.py +11 -0
- plato/v1/cli/verify.py +1269 -0
- plato/v1/cli/world.py +3 -0
- plato/v1/flow_executor.py +21 -17
- plato/v1/models/env.py +11 -11
- plato/v1/sdk.py +2 -2
- plato/v1/sync_env.py +11 -11
- plato/v1/sync_flow_executor.py +21 -17
- plato/v1/sync_sdk.py +4 -2
- plato/v2/async_/environment.py +31 -0
- plato/v2/async_/session.py +37 -4
- plato/v2/sync/environment.py +31 -0
- plato/v2/sync/session.py +37 -4
- plato/worlds/__init__.py +21 -2
- plato/worlds/base.py +222 -2
- plato/worlds/config.py +97 -7
- plato/worlds/runner.py +339 -1
- {plato_sdk_v2-2.1.11.dist-info → plato_sdk_v2-2.3.0.dist-info}/METADATA +1 -1
- {plato_sdk_v2-2.1.11.dist-info → plato_sdk_v2-2.3.0.dist-info}/RECORD +36 -31
- plato/agents/callback.py +0 -246
- {plato_sdk_v2-2.1.11.dist-info → plato_sdk_v2-2.3.0.dist-info}/WHEEL +0 -0
- {plato_sdk_v2-2.1.11.dist-info → plato_sdk_v2-2.3.0.dist-info}/entry_points.txt +0 -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
|
@@ -10,10 +10,6 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
import typer
|
|
13
|
-
|
|
14
|
-
# UUID pattern for detecting artifact IDs in sim:artifact notation
|
|
15
|
-
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)
|
|
16
|
-
from playwright.async_api import async_playwright
|
|
17
13
|
from rich.table import Table
|
|
18
14
|
|
|
19
15
|
from plato._generated.api.v1.env import get_simulator_by_name, get_simulators
|
|
@@ -43,9 +39,17 @@ from plato.v1.cli.utils import (
|
|
|
43
39
|
require_sandbox_field,
|
|
44
40
|
require_sandbox_state,
|
|
45
41
|
)
|
|
42
|
+
from plato.v1.cli.verify import pm_verify_app
|
|
46
43
|
from plato.v2.async_.client import AsyncPlato
|
|
47
44
|
from plato.v2.types import Env
|
|
48
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
|
+
|
|
49
53
|
# =============================================================================
|
|
50
54
|
# APP STRUCTURE
|
|
51
55
|
# =============================================================================
|
|
@@ -58,6 +62,7 @@ submit_app = typer.Typer(help="Submit simulator artifacts for review")
|
|
|
58
62
|
pm_app.add_typer(list_app, name="list")
|
|
59
63
|
pm_app.add_typer(review_app, name="review")
|
|
60
64
|
pm_app.add_typer(submit_app, name="submit")
|
|
65
|
+
pm_app.add_typer(pm_verify_app, name="verify")
|
|
61
66
|
|
|
62
67
|
|
|
63
68
|
# =============================================================================
|
|
@@ -340,6 +345,9 @@ def review_base(
|
|
|
340
345
|
try:
|
|
341
346
|
http_client = plato._http
|
|
342
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
|
+
|
|
343
351
|
# Get simulator by name
|
|
344
352
|
sim = await get_simulator_by_name.asyncio(
|
|
345
353
|
client=http_client,
|
|
@@ -353,7 +361,7 @@ def review_base(
|
|
|
353
361
|
console.print(f"[cyan]Current status:[/cyan] {current_status}")
|
|
354
362
|
|
|
355
363
|
# Use provided artifact ID or fall back to base_artifact_id from server config
|
|
356
|
-
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")
|
|
357
365
|
if not artifact_id:
|
|
358
366
|
console.print("[red]❌ No artifact ID provided.[/red]")
|
|
359
367
|
console.print(
|
|
@@ -390,6 +398,8 @@ def review_base(
|
|
|
390
398
|
|
|
391
399
|
# Launch Playwright browser and login
|
|
392
400
|
console.print("[cyan]Launching browser and logging in...[/cyan]")
|
|
401
|
+
from playwright.async_api import async_playwright
|
|
402
|
+
|
|
393
403
|
playwright = await async_playwright().start()
|
|
394
404
|
browser = await playwright.chromium.launch(headless=False)
|
|
395
405
|
|
|
@@ -403,44 +413,57 @@ def review_base(
|
|
|
403
413
|
if public_url:
|
|
404
414
|
await page.goto(public_url)
|
|
405
415
|
|
|
406
|
-
#
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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]")
|
|
435
449
|
else:
|
|
436
|
-
console.print(
|
|
437
|
-
|
|
438
|
-
|
|
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]")
|
|
439
458
|
else:
|
|
440
|
-
console.print("[
|
|
441
|
-
|
|
442
|
-
console.print(
|
|
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]")
|
|
443
464
|
|
|
465
|
+
# If skip_review, exit without interactive loop
|
|
466
|
+
if skip_review:
|
|
444
467
|
console.print("\n[cyan]Skipping interactive review (--skip-review)[/cyan]")
|
|
445
468
|
return
|
|
446
469
|
|
|
@@ -492,9 +515,16 @@ def review_base(
|
|
|
492
515
|
if state_response and state_response.results:
|
|
493
516
|
for jid, result in state_response.results.items():
|
|
494
517
|
state_data = result.state if hasattr(result, "state") else result
|
|
518
|
+
console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
|
|
519
|
+
|
|
495
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
|
+
|
|
496
527
|
mutations = state_data.pop("mutations", [])
|
|
497
|
-
console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
|
|
498
528
|
console.print("\n[bold]State:[/bold]")
|
|
499
529
|
console.print(json.dumps(state_data, indent=2, default=str))
|
|
500
530
|
if mutations:
|
|
@@ -503,7 +533,6 @@ def review_base(
|
|
|
503
533
|
else:
|
|
504
534
|
console.print("\n[yellow]No mutations recorded[/yellow]")
|
|
505
535
|
else:
|
|
506
|
-
console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
|
|
507
536
|
console.print(json.dumps(state_data, indent=2, default=str))
|
|
508
537
|
else:
|
|
509
538
|
console.print("[yellow]No state data available[/yellow]")
|
|
@@ -579,9 +608,11 @@ def review_base(
|
|
|
579
608
|
console.print(f"[cyan]Status:[/cyan] {current_status} → {new_status}")
|
|
580
609
|
|
|
581
610
|
# If passed, automatically tag artifact as prod-latest
|
|
582
|
-
if outcome == "pass":
|
|
611
|
+
if outcome == "pass" and artifact_id:
|
|
583
612
|
console.print("\n[cyan]Tagging artifact as prod-latest...[/cyan]")
|
|
584
613
|
try:
|
|
614
|
+
# simulator_name and artifact_id are guaranteed to be set at this point
|
|
615
|
+
assert simulator_name is not None
|
|
585
616
|
await update_tag.asyncio(
|
|
586
617
|
client=http_client,
|
|
587
618
|
body=UpdateTagRequest(
|
|
@@ -681,6 +712,9 @@ def review_data(
|
|
|
681
712
|
|
|
682
713
|
async def _fetch_artifact_info():
|
|
683
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
|
+
|
|
684
718
|
base_url = _get_base_url()
|
|
685
719
|
async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
|
|
686
720
|
try:
|
|
@@ -755,6 +789,8 @@ def review_data(
|
|
|
755
789
|
|
|
756
790
|
console.print("[cyan]Launching Chrome with EnvGen Recorder extension...[/cyan]")
|
|
757
791
|
|
|
792
|
+
from playwright.async_api import async_playwright
|
|
793
|
+
|
|
758
794
|
playwright = await async_playwright().start()
|
|
759
795
|
|
|
760
796
|
browser = await playwright.chromium.launch_persistent_context(
|
|
@@ -1080,6 +1116,10 @@ def submit_data(
|
|
|
1080
1116
|
)
|
|
1081
1117
|
|
|
1082
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
|
+
|
|
1083
1123
|
base_url = _get_base_url()
|
|
1084
1124
|
|
|
1085
1125
|
async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
|
|
@@ -1110,7 +1150,7 @@ def submit_data(
|
|
|
1110
1150
|
x_api_key=api_key,
|
|
1111
1151
|
)
|
|
1112
1152
|
|
|
1113
|
-
# Set data_artifact_id via tag update
|
|
1153
|
+
# Set data_artifact_id via tag update (simulator_name and artifact_id already asserted above)
|
|
1114
1154
|
try:
|
|
1115
1155
|
await update_tag.asyncio(
|
|
1116
1156
|
client=client,
|
plato/v1/cli/sandbox.py
CHANGED
|
@@ -17,12 +17,8 @@ from urllib.parse import quote
|
|
|
17
17
|
|
|
18
18
|
import typer
|
|
19
19
|
import yaml
|
|
20
|
-
from playwright.async_api import async_playwright
|
|
21
20
|
from rich.logging import RichHandler
|
|
22
21
|
|
|
23
|
-
# UUID pattern for detecting artifact IDs in colon notation
|
|
24
|
-
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)
|
|
25
|
-
|
|
26
22
|
from plato._generated.api.v1.gitea import (
|
|
27
23
|
create_simulator_repository,
|
|
28
24
|
get_accessible_simulators,
|
|
@@ -74,11 +70,16 @@ from plato.v1.cli.utils import (
|
|
|
74
70
|
require_sandbox_state,
|
|
75
71
|
save_sandbox_state,
|
|
76
72
|
)
|
|
73
|
+
from plato.v1.cli.verify import sandbox_verify_app
|
|
77
74
|
from plato.v2.async_.flow_executor import FlowExecutor
|
|
78
75
|
from plato.v2.sync.client import Plato as PlatoV2
|
|
79
76
|
from plato.v2.types import Env, SimConfigCompute
|
|
80
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
|
+
|
|
81
81
|
sandbox_app = typer.Typer(help="Manage sandboxes for simulator development")
|
|
82
|
+
sandbox_app.add_typer(sandbox_verify_app, name="verify")
|
|
82
83
|
|
|
83
84
|
|
|
84
85
|
def format_public_url_with_router_target(public_url: str | None, service_name: str | None) -> str | None:
|
|
@@ -423,9 +424,10 @@ def sandbox_start(
|
|
|
423
424
|
listeners=listeners_dict,
|
|
424
425
|
)
|
|
425
426
|
|
|
427
|
+
dataset_value = dataset_name or state_extras.get("dataset", "base")
|
|
426
428
|
setup_request = AppSchemasBuildModelsSetupSandboxRequest(
|
|
427
429
|
service=sim_name or "",
|
|
428
|
-
dataset=
|
|
430
|
+
dataset=str(dataset_value) if dataset_value else "",
|
|
429
431
|
plato_dataset_config=dataset_config_obj,
|
|
430
432
|
ssh_public_key=ssh_public_key,
|
|
431
433
|
)
|
|
@@ -1047,6 +1049,9 @@ def sandbox_start_worker(
|
|
|
1047
1049
|
console.print(f"[cyan]Waiting for worker to be ready (timeout: {wait_timeout}s)...[/cyan]")
|
|
1048
1050
|
|
|
1049
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)
|
|
1050
1055
|
start_time = time.time()
|
|
1051
1056
|
poll_interval = 10 # seconds between polls
|
|
1052
1057
|
worker_ready = False
|
|
@@ -1530,17 +1535,31 @@ def sandbox_flow(
|
|
|
1530
1535
|
console.print(f"[red]❌ Failed to fetch flows from API: {e}[/red]")
|
|
1531
1536
|
raise typer.Exit(1) from e
|
|
1532
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
|
+
|
|
1533
1546
|
console.print(f"[cyan]URL: {url}[/cyan]")
|
|
1534
1547
|
console.print(f"[cyan]Flow name: {flow_name}[/cyan]")
|
|
1535
1548
|
|
|
1549
|
+
# Capture for closure (narrowed types)
|
|
1550
|
+
_url: str = url
|
|
1551
|
+
_flow_obj: Flow = flow_obj
|
|
1552
|
+
|
|
1536
1553
|
async def _run():
|
|
1554
|
+
from playwright.async_api import async_playwright
|
|
1555
|
+
|
|
1537
1556
|
browser = None
|
|
1538
1557
|
try:
|
|
1539
1558
|
async with async_playwright() as p:
|
|
1540
1559
|
browser = await p.chromium.launch(headless=False)
|
|
1541
1560
|
page = await browser.new_page()
|
|
1542
|
-
await page.goto(
|
|
1543
|
-
executor = FlowExecutor(page,
|
|
1561
|
+
await page.goto(_url)
|
|
1562
|
+
executor = FlowExecutor(page, _flow_obj, screenshots_dir, log=_flow_logger)
|
|
1544
1563
|
await executor.execute()
|
|
1545
1564
|
console.print("[green]✅ Flow executed successfully[/green]")
|
|
1546
1565
|
except Exception as e:
|
|
@@ -1601,8 +1620,23 @@ def sandbox_state_cmd(
|
|
|
1601
1620
|
def check_mutations(result_dict: dict) -> tuple[bool, bool, str | None]:
|
|
1602
1621
|
"""Check if result has mutations or errors. Returns (has_mutations, has_error, error_msg)."""
|
|
1603
1622
|
if isinstance(result_dict, dict):
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
|
1606
1640
|
mutations = result_dict.get("mutations", [])
|
|
1607
1641
|
if mutations:
|
|
1608
1642
|
return True, False, None
|
|
@@ -2004,6 +2038,10 @@ def sandbox_start_services(
|
|
|
2004
2038
|
if not json_output:
|
|
2005
2039
|
console.print("[cyan]Step 3: Getting/creating repository...[/cyan]")
|
|
2006
2040
|
|
|
2041
|
+
if sim_id is None:
|
|
2042
|
+
console.print("[red]❌ Simulator ID not available[/red]")
|
|
2043
|
+
raise typer.Exit(1)
|
|
2044
|
+
|
|
2007
2045
|
if has_repo:
|
|
2008
2046
|
repo = get_simulator_repository.sync(client=client, simulator_id=sim_id, x_api_key=api_key)
|
|
2009
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.")
|