plato-sdk-v2 2.8.7__py3-none-any.whl → 2.9.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/cli/main.py CHANGED
@@ -10,8 +10,10 @@ from dotenv import load_dotenv
10
10
 
11
11
  from plato.cli.agent import agent_app
12
12
  from plato.cli.chronos import chronos_app
13
+ from plato.cli.compose import app as compose_app
13
14
  from plato.cli.pm import pm_app
14
15
  from plato.cli.sandbox import sandbox_app
16
+ from plato.cli.session import app as session_app
15
17
  from plato.cli.utils import console
16
18
  from plato.cli.world import world_app
17
19
 
@@ -69,6 +71,8 @@ app = typer.Typer(help="[bold blue]Plato CLI[/bold blue] - Manage Plato environm
69
71
 
70
72
  # Register sub-apps
71
73
  app.add_typer(sandbox_app, name="sandbox")
74
+ app.add_typer(session_app, name="session")
75
+ app.add_typer(compose_app, name="compose")
72
76
  app.add_typer(pm_app, name="pm")
73
77
  app.add_typer(agent_app, name="agent")
74
78
  app.add_typer(world_app, name="world")
plato/cli/sandbox.py CHANGED
@@ -305,10 +305,11 @@ def sandbox_context(
305
305
  logging.getLogger("httpcore").setLevel(logging.INFO)
306
306
 
307
307
  out = Output(json_output, verbose)
308
+ # Use super_console for status updates (always visible), not the quiet console
308
309
  client = SandboxClient(
309
310
  working_dir=working_dir,
310
311
  api_key=require_api_key(),
311
- console=out.console,
312
+ console=out.super_console if not json_output else out.console,
312
313
  )
313
314
  try:
314
315
  yield client, out
@@ -743,34 +744,81 @@ def sandbox_ssh(
743
744
  ctx: typer.Context,
744
745
  ssh_config: SshConfigArg,
745
746
  ssh_host: SshHostArg,
747
+ job_id: Annotated[
748
+ str | None,
749
+ typer.Option(
750
+ "--job-id",
751
+ "-J",
752
+ help="Connect to a specific job ID (bypasses .plato/state.json)",
753
+ ),
754
+ ] = None,
746
755
  json_output: JsonArg = False,
747
756
  verbose: VerboseArg = False,
748
757
  ):
749
758
  """SSH to the sandbox VM.
750
759
 
751
- Uses .plato/ssh_config from 'start'. Extra args after -- are passed to ssh.
760
+ Uses .plato/ssh_config from 'start', or connect directly to a job with -J.
752
761
 
753
762
  NOTE FOR AGENTS: Do not use this command. Instead, use the raw SSH command
754
763
  from 'plato sandbox status' which shows: ssh -F .plato/ssh_config sandbox
755
764
 
756
765
  Examples:
757
- plato sandbox ssh
766
+ plato sandbox ssh # Use saved state
767
+ plato sandbox ssh -J <job-id> # Connect to specific job
758
768
  plato sandbox ssh -- -L 8080:localhost:8080
759
769
  """
760
770
  import subprocess
771
+ import tempfile
761
772
 
762
773
  with sandbox_context(working_dir, json_output, verbose) as (client, out):
763
- if not ssh_config:
764
- out.error("No SSH config found. Run 'plato sandbox start' first.")
765
- raise typer.Exit(1)
766
-
767
- config_path = client.working_dir / ssh_config if not Path(ssh_config).is_absolute() else Path(ssh_config)
768
- cmd = ["ssh", "-F", str(config_path), ssh_host or "sandbox"] + (ctx.args or [])
769
-
770
- try:
771
- raise typer.Exit(subprocess.run(cmd).returncode)
772
- except KeyboardInterrupt:
773
- raise typer.Exit(130) from None
774
+ # If job_id provided, generate SSH config dynamically
775
+ if job_id:
776
+ out.console.print(f"Connecting to job: {job_id}")
777
+
778
+ # Fetch SSH config for the job (generates temp key and adds to VM)
779
+ try:
780
+ ssh_info = client.get_ssh_config_for_job(job_id)
781
+ except Exception as e:
782
+ out.error(f"Failed to get SSH config for job {job_id}: {e}")
783
+ raise typer.Exit(1)
784
+
785
+ # Write temporary SSH config
786
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".ssh_config", delete=False) as f:
787
+ f.write(ssh_info.config_content)
788
+ temp_config_path = f.name
789
+
790
+ cmd = ["ssh", "-F", temp_config_path, "sandbox"] + (ctx.args or [])
791
+
792
+ try:
793
+ raise typer.Exit(subprocess.run(cmd).returncode)
794
+ except KeyboardInterrupt:
795
+ raise typer.Exit(130) from None
796
+ finally:
797
+ # Clean up temp config file
798
+ try:
799
+ os.unlink(temp_config_path)
800
+ except Exception:
801
+ pass
802
+ # Clean up temp key directory
803
+ try:
804
+ import shutil
805
+
806
+ shutil.rmtree(os.path.dirname(ssh_info.private_key_path))
807
+ except Exception:
808
+ pass
809
+ else:
810
+ # Use saved SSH config
811
+ if not ssh_config:
812
+ out.error("No SSH config found. Run 'plato sandbox start' first or use -J <job-id>.")
813
+ raise typer.Exit(1)
814
+
815
+ config_path = client.working_dir / ssh_config if not Path(ssh_config).is_absolute() else Path(ssh_config)
816
+ cmd = ["ssh", "-F", str(config_path), ssh_host or "sandbox"] + (ctx.args or [])
817
+
818
+ try:
819
+ raise typer.Exit(subprocess.run(cmd).returncode)
820
+ except KeyboardInterrupt:
821
+ raise typer.Exit(130) from None
774
822
 
775
823
 
776
824
  @sandbox_app.command(name="tunnel")
plato/cli/session.py ADDED
@@ -0,0 +1,492 @@
1
+ """Session CLI commands for Plato - manage multi-environment sessions."""
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from plato.cli.utils import require_api_key
14
+ from plato.v2.sync.client import Plato
15
+ from plato.v2.types import Env
16
+
17
+ app = typer.Typer(
18
+ name="session",
19
+ help="Manage sessions with multiple environments.",
20
+ no_args_is_help=True,
21
+ )
22
+
23
+ console = Console()
24
+
25
+ # State file location
26
+ STATE_DIR = Path(".plato")
27
+ STATE_FILE = STATE_DIR / "session.json"
28
+
29
+
30
+ def save_state(state: dict) -> None:
31
+ """Save session state to .plato/session.json."""
32
+ STATE_DIR.mkdir(exist_ok=True)
33
+ STATE_FILE.write_text(json.dumps(state, indent=2))
34
+
35
+
36
+ def load_state() -> dict | None:
37
+ """Load session state from .plato/session.json."""
38
+ if not STATE_FILE.exists():
39
+ return None
40
+ try:
41
+ return json.loads(STATE_FILE.read_text())
42
+ except Exception:
43
+ return None
44
+
45
+
46
+ def clear_state() -> None:
47
+ """Clear session state."""
48
+ if STATE_FILE.exists():
49
+ STATE_FILE.unlink()
50
+
51
+
52
+ def make_status_table(session, title: str = "Session Status") -> Table:
53
+ """Create a rich table showing session/environment status."""
54
+ table = Table(title=title, show_header=True, header_style="bold cyan")
55
+ table.add_column("Alias", style="bold")
56
+ table.add_column("Simulator")
57
+ table.add_column("Status")
58
+ table.add_column("Job ID", style="dim")
59
+ table.add_column("Public URL", style="blue underline")
60
+
61
+ for env in session.envs:
62
+ status_style = "green" if env.status == "running" else "yellow"
63
+ table.add_row(
64
+ env.alias or "-",
65
+ env.simulator or "-",
66
+ Text(env.status or "unknown", style=status_style),
67
+ env.job_id[:12] + "..." if env.job_id and len(env.job_id) > 12 else (env.job_id or "-"),
68
+ env.public_url or "-",
69
+ )
70
+
71
+ return table
72
+
73
+
74
+ @app.command("start")
75
+ def start(
76
+ # Environment specification options
77
+ sim: Annotated[
78
+ list[str] | None,
79
+ typer.Option(
80
+ "--sim",
81
+ "-s",
82
+ help="Simulator to start (can specify multiple). Format: 'name' or 'name:tag' or 'name:tag@dataset'",
83
+ ),
84
+ ] = None,
85
+ artifact: Annotated[
86
+ list[str] | None,
87
+ typer.Option(
88
+ "--artifact",
89
+ "-a",
90
+ help="Artifact ID to start (can specify multiple)",
91
+ ),
92
+ ] = None,
93
+ task: Annotated[
94
+ str | None,
95
+ typer.Option(
96
+ "--task",
97
+ "-t",
98
+ help="Task ID to create session from",
99
+ ),
100
+ ] = None,
101
+ n: Annotated[
102
+ int,
103
+ typer.Option(
104
+ "--n",
105
+ "-n",
106
+ help="Number of instances (only with single --sim)",
107
+ ),
108
+ ] = 1,
109
+ # Resource options
110
+ timeout: Annotated[
111
+ int,
112
+ typer.Option(
113
+ "--timeout",
114
+ help="VM timeout in seconds",
115
+ ),
116
+ ] = 1800,
117
+ no_network: Annotated[
118
+ bool,
119
+ typer.Option(
120
+ "--no-network",
121
+ help="Don't connect VMs to network",
122
+ ),
123
+ ] = False,
124
+ # Output options
125
+ json_output: Annotated[
126
+ bool,
127
+ typer.Option(
128
+ "--json",
129
+ "-j",
130
+ help="Output as JSON",
131
+ ),
132
+ ] = False,
133
+ wait: Annotated[
134
+ bool,
135
+ typer.Option(
136
+ "--wait/--no-wait",
137
+ "-w",
138
+ help="Wait for all environments to be ready",
139
+ ),
140
+ ] = True,
141
+ ) -> None:
142
+ """Start a new session with one or more environments.
143
+
144
+ Examples:
145
+ # Single simulator
146
+ plato session start --sim espocrm
147
+
148
+ # Multiple simulators
149
+ plato session start --sim espocrm --sim gitea
150
+
151
+ # With specific tags
152
+ plato session start --sim espocrm:staging --sim gitea:latest
153
+
154
+ # With dataset
155
+ plato session start --sim espocrm:latest@blank
156
+
157
+ # Multiple instances of same simulator
158
+ plato session start --sim espocrm -n 3
159
+
160
+ # From artifact
161
+ plato session start --artifact abc123
162
+
163
+ # From task
164
+ plato session start --task task-id-123
165
+ """
166
+ api_key = require_api_key()
167
+
168
+ # Validate inputs
169
+ if task and (sim or artifact):
170
+ console.print("[red]Error:[/red] Cannot specify --task with --sim or --artifact")
171
+ raise typer.Exit(1)
172
+
173
+ if not task and not sim and not artifact:
174
+ console.print("[red]Error:[/red] Must specify --sim, --artifact, or --task")
175
+ raise typer.Exit(1)
176
+
177
+ if n > 1 and (len(sim or []) > 1 or artifact):
178
+ console.print("[red]Error:[/red] --n only works with a single --sim")
179
+ raise typer.Exit(1)
180
+
181
+ # Build environment list
182
+ envs = []
183
+
184
+ if sim:
185
+ for i, s in enumerate(sim):
186
+ # Parse format: name, name:tag, or name:tag@dataset
187
+ dataset = None
188
+ tag = "latest"
189
+
190
+ if "@" in s:
191
+ s, dataset = s.rsplit("@", 1)
192
+ if ":" in s:
193
+ name, tag = s.split(":", 1)
194
+ else:
195
+ name = s
196
+
197
+ # Handle --n for multiple instances
198
+ count = n if len(sim) == 1 else 1
199
+ for j in range(count):
200
+ alias = f"{name}-{j}" if count > 1 else (name if len(sim) == 1 else f"{name}-{i}")
201
+ envs.append(Env.simulator(name, tag=tag, dataset=dataset, alias=alias))
202
+
203
+ if artifact:
204
+ for i, a in enumerate(artifact):
205
+ alias = f"artifact-{i}" if len(artifact) > 1 else "artifact"
206
+ envs.append(Env.artifact(a, alias=alias))
207
+
208
+ # Create session
209
+ plato = Plato(api_key=api_key)
210
+
211
+ try:
212
+ if not json_output:
213
+ console.print(f"\n[bold]Starting session with {len(envs) if envs else 'task'} environment(s)...[/bold]\n")
214
+
215
+ if envs:
216
+ for env in envs:
217
+ if hasattr(env, "simulator"):
218
+ console.print(
219
+ f" • {env.alias}: {env.simulator}:{env.tag}"
220
+ + (f" (dataset: {env.dataset})" if env.dataset else "")
221
+ )
222
+ else:
223
+ console.print(f" • {env.alias}: artifact {env.artifact_id}")
224
+ console.print()
225
+
226
+ start_time = time.time()
227
+
228
+ with console.status("[bold green]Creating session..."):
229
+ if task:
230
+ session = plato.sessions.create(task=task, timeout=timeout, connect_network=not no_network)
231
+ else:
232
+ session = plato.sessions.create(envs=envs, timeout=timeout, connect_network=not no_network)
233
+
234
+ elapsed = time.time() - start_time
235
+
236
+ if json_output:
237
+ output = {
238
+ "session_id": session.session_id,
239
+ "environments": [
240
+ {
241
+ "alias": env.alias,
242
+ "job_id": env.job_id,
243
+ "simulator": env.simulator,
244
+ "status": env.status,
245
+ "public_url": env.public_url,
246
+ }
247
+ for env in session.envs
248
+ ],
249
+ }
250
+ print(json.dumps(output, indent=2))
251
+ else:
252
+ console.print(f"[green]✓[/green] Session created in {elapsed:.1f}s\n")
253
+ console.print(f"[bold]Session ID:[/bold] {session.session_id}\n")
254
+ console.print(make_status_table(session))
255
+ console.print()
256
+
257
+ # Show helpful commands
258
+ console.print("[dim]Helpful commands:[/dim]")
259
+ console.print(" [cyan]plato session status[/cyan] - Check environment status")
260
+ console.print(" [cyan]plato session stop[/cyan] - Stop the session")
261
+ console.print()
262
+
263
+ # Save state
264
+ save_state(
265
+ {
266
+ "session_id": session.session_id,
267
+ "environments": [
268
+ {
269
+ "alias": env.alias,
270
+ "job_id": env.job_id,
271
+ "simulator": env.simulator,
272
+ "public_url": env.public_url,
273
+ }
274
+ for env in session.envs
275
+ ],
276
+ }
277
+ )
278
+
279
+ # Start heartbeat in background
280
+ session.start_heartbeat()
281
+
282
+ except Exception as e:
283
+ console.print(f"[red]Error:[/red] {e}")
284
+ raise typer.Exit(1)
285
+ finally:
286
+ plato.close()
287
+
288
+
289
+ @app.command("status")
290
+ def status(
291
+ session_id: Annotated[
292
+ str | None,
293
+ typer.Option(
294
+ "--session-id",
295
+ "-s",
296
+ help="Session ID (uses saved state if not provided)",
297
+ ),
298
+ ] = None,
299
+ json_output: Annotated[
300
+ bool,
301
+ typer.Option(
302
+ "--json",
303
+ "-j",
304
+ help="Output as JSON",
305
+ ),
306
+ ] = False,
307
+ ) -> None:
308
+ """Show status of the current session and all environments."""
309
+ from plato._generated.api.v2.sessions import state as sessions_state
310
+
311
+ api_key = require_api_key()
312
+
313
+ # Get session ID and env info from state if not provided
314
+ saved_state = load_state()
315
+ if not session_id:
316
+ if not saved_state:
317
+ console.print("[red]Error:[/red] No session found. Run `plato session start` first or provide --session-id")
318
+ raise typer.Exit(1)
319
+ session_id = saved_state.get("session_id")
320
+
321
+ if not session_id:
322
+ console.print("[red]Error:[/red] No session ID found")
323
+ raise typer.Exit(1)
324
+
325
+ plato = Plato(api_key=api_key)
326
+
327
+ try:
328
+ # Get session state from API
329
+ state_response = sessions_state.sync(
330
+ client=plato._http,
331
+ session_id=session_id,
332
+ x_api_key=api_key,
333
+ )
334
+
335
+ # Build env info from saved state and API response
336
+ saved_envs = {e.get("job_id"): e for e in (saved_state or {}).get("environments", [])}
337
+
338
+ if json_output:
339
+ output = {
340
+ "session_id": session_id,
341
+ "environments": [
342
+ {
343
+ "job_id": job_id,
344
+ "alias": saved_envs.get(job_id, {}).get("alias", job_id[:8]),
345
+ "simulator": saved_envs.get(job_id, {}).get("simulator", "-"),
346
+ "status": result.status if hasattr(result, "status") else "unknown",
347
+ }
348
+ for job_id, result in state_response.results.items()
349
+ ],
350
+ }
351
+ print(json.dumps(output, indent=2))
352
+ else:
353
+ console.print(f"\n[bold]Session ID:[/bold] {session_id}\n")
354
+
355
+ table = Table(title="Session Status", show_header=True, header_style="bold cyan")
356
+ table.add_column("Alias", style="bold")
357
+ table.add_column("Simulator")
358
+ table.add_column("Job ID", style="dim")
359
+ table.add_column("Status")
360
+
361
+ for job_id, result in state_response.results.items():
362
+ env_info = saved_envs.get(job_id, {})
363
+ status_val = getattr(result, "status", "unknown") if result else "unknown"
364
+ status_style = "green" if status_val == "running" else "yellow"
365
+ table.add_row(
366
+ env_info.get("alias", job_id[:8]),
367
+ env_info.get("simulator", "-"),
368
+ job_id[:12] + "..." if len(job_id) > 12 else job_id,
369
+ Text(status_val, style=status_style),
370
+ )
371
+
372
+ console.print(table)
373
+ console.print()
374
+
375
+ except Exception as e:
376
+ console.print(f"[red]Error:[/red] {e}")
377
+ raise typer.Exit(1)
378
+ finally:
379
+ plato.close()
380
+
381
+
382
+ @app.command("stop")
383
+ def stop(
384
+ session_id: Annotated[
385
+ str | None,
386
+ typer.Option(
387
+ "--session-id",
388
+ "-s",
389
+ help="Session ID (uses saved state if not provided)",
390
+ ),
391
+ ] = None,
392
+ force: Annotated[
393
+ bool,
394
+ typer.Option(
395
+ "--force",
396
+ "-f",
397
+ help="Force stop without confirmation",
398
+ ),
399
+ ] = False,
400
+ ) -> None:
401
+ """Stop the current session and all environments."""
402
+ from plato._generated.api.v2.sessions import close as sessions_close
403
+
404
+ api_key = require_api_key()
405
+
406
+ # Get session ID from state if not provided
407
+ if not session_id:
408
+ state = load_state()
409
+ if not state:
410
+ console.print("[red]Error:[/red] No session found. Run `plato session start` first or provide --session-id")
411
+ raise typer.Exit(1)
412
+ session_id = state.get("session_id")
413
+
414
+ if not session_id:
415
+ console.print("[red]Error:[/red] No session ID found")
416
+ raise typer.Exit(1)
417
+
418
+ if not force:
419
+ confirm = typer.confirm(f"Stop session {session_id}?")
420
+ if not confirm:
421
+ console.print("Cancelled")
422
+ raise typer.Exit(0)
423
+
424
+ plato = Plato(api_key=api_key)
425
+
426
+ try:
427
+ with console.status("[bold red]Stopping session..."):
428
+ sessions_close.sync(
429
+ client=plato._http,
430
+ session_id=session_id,
431
+ x_api_key=api_key,
432
+ )
433
+
434
+ console.print(f"[green]✓[/green] Session {session_id} stopped")
435
+ clear_state()
436
+
437
+ except Exception as e:
438
+ console.print(f"[red]Error:[/red] {e}")
439
+ raise typer.Exit(1)
440
+ finally:
441
+ plato.close()
442
+
443
+
444
+ @app.command("list")
445
+ def list_sessions(
446
+ json_output: Annotated[
447
+ bool,
448
+ typer.Option(
449
+ "--json",
450
+ "-j",
451
+ help="Output as JSON",
452
+ ),
453
+ ] = False,
454
+ ) -> None:
455
+ """List all active sessions."""
456
+ require_api_key() # Validate API key is set
457
+
458
+ # Just show saved state for now
459
+ state = load_state()
460
+
461
+ if not state:
462
+ if json_output:
463
+ print(json.dumps({"sessions": []}))
464
+ else:
465
+ console.print("[dim]No active session saved locally.[/dim]")
466
+ console.print("[dim]Use `plato session start` to create one.[/dim]")
467
+ return
468
+
469
+ if json_output:
470
+ print(json.dumps({"sessions": [state]}))
471
+ else:
472
+ console.print(f"\n[bold]Active Session:[/bold] {state.get('session_id')}")
473
+ console.print(f"[dim]Environments: {len(state.get('environments', []))}[/dim]\n")
474
+
475
+ table = Table(show_header=True, header_style="bold cyan")
476
+ table.add_column("Alias")
477
+ table.add_column("Simulator")
478
+ table.add_column("Job ID", style="dim")
479
+
480
+ for env in state.get("environments", []):
481
+ table.add_row(
482
+ env.get("alias", "-"),
483
+ env.get("simulator", "-"),
484
+ env.get("job_id", "-")[:12] + "..." if env.get("job_id") else "-",
485
+ )
486
+
487
+ console.print(table)
488
+ console.print()
489
+
490
+
491
+ if __name__ == "__main__":
492
+ app()