runtime-sdk 0.4.35__tar.gz → 0.4.37__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.35
3
+ Version: 0.4.37
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
@@ -76,7 +76,9 @@ runtime switch -c feature/login --repo owner/repo # create a new branch worksp
76
76
  runtime checkout --repo owner/repo feature/login # alias for switch
77
77
  runtime create # creates a computer with the starter app already published
78
78
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
79
- runtime enter <name-or-id> # accepts slug/name like test, or the computer id
79
+ runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
80
+ runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
81
+ runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
80
82
  runtime list
81
83
  runtime info <id>
82
84
  runtime share public <id>
@@ -86,6 +88,7 @@ runtime run <id> "echo hello" # one-shot foreground command only
86
88
  runtime run <id> "apt install -y nodejs" --uid 0
87
89
  runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
88
90
  printf 'hello' | runtime exec <id> --stdin -- cat
91
+ # Use runtime exec for automation and exact exit codes; use runtime enter -- <cmd> for interactive TTY commands.
89
92
  runtime files ls <id> /home/ubuntu
90
93
  runtime files read <id> /home/ubuntu/app.py --output app.py
91
94
  runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
@@ -54,7 +54,9 @@ runtime switch -c feature/login --repo owner/repo # create a new branch worksp
54
54
  runtime checkout --repo owner/repo feature/login # alias for switch
55
55
  runtime create # creates a computer with the starter app already published
56
56
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
57
- runtime enter <name-or-id> # accepts slug/name like test, or the computer id
57
+ runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
58
+ runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
59
+ runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
58
60
  runtime list
59
61
  runtime info <id>
60
62
  runtime share public <id>
@@ -64,6 +66,7 @@ runtime run <id> "echo hello" # one-shot foreground command only
64
66
  runtime run <id> "apt install -y nodejs" --uid 0
65
67
  runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
66
68
  printf 'hello' | runtime exec <id> --stdin -- cat
69
+ # Use runtime exec for automation and exact exit codes; use runtime enter -- <cmd> for interactive TTY commands.
67
70
  runtime files ls <id> /home/ubuntu
68
71
  runtime files read <id> /home/ubuntu/app.py --output app.py
69
72
  runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.35"
3
+ version = "0.4.37"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -189,9 +189,11 @@ def build_parser() -> argparse.ArgumentParser:
189
189
  secrets_set = secrets_subparsers.add_parser("set", help="Create or replace a secret set from KEY=VALUE pairs")
190
190
  secrets_set.add_argument("slug", nargs="?", default=None, help="Secret set slug")
191
191
  secrets_set.add_argument("pairs", nargs="*", help="KEY=VALUE pairs")
192
+ secrets_set.add_argument("--proxy", action="append", default=[], metavar="KEY=HOST", help="Deliver KEY through the egress proxy and inject it only for HOST")
192
193
  secrets_import = secrets_subparsers.add_parser("import", help="Create or replace a secret set from a .env file")
193
194
  secrets_import.add_argument("slug", nargs="?", default=None, help="Secret set slug")
194
195
  secrets_import.add_argument("--env-file", required=True, help="Path to .env file")
196
+ secrets_import.add_argument("--proxy", action="append", default=[], metavar="KEY=HOST", help="Deliver KEY through the egress proxy and inject it only for HOST")
195
197
  secrets_delete = secrets_subparsers.add_parser("delete", aliases=["rm"], help="Delete a secret set")
196
198
  secrets_delete.add_argument("slug", nargs="?", default=None, help="Secret set slug")
197
199
 
@@ -238,8 +240,13 @@ def build_parser() -> argparse.ArgumentParser:
238
240
  help="Re-render the table every N seconds (default 2)",
239
241
  )
240
242
 
241
- enter_cmd = subparsers.add_parser("enter", help="Enter a computer's console")
243
+ enter_cmd = subparsers.add_parser(
244
+ "enter",
245
+ help="Enter a computer's console",
246
+ epilog="Use `runtime exec` for exact exit codes; use `runtime enter <id> -- <cmd>` for interactive TTY commands.",
247
+ )
242
248
  enter_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
249
+ enter_cmd.add_argument("enter_command", nargs="*", help="Command to run in the interactive terminal")
243
250
 
244
251
  info_cmd = subparsers.add_parser("info", help="Get computer details")
245
252
  info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
@@ -408,7 +415,7 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
408
415
  ("info [id]", "Show computer details"),
409
416
  ("url [id]", "Show a computer's public URL and live published port"),
410
417
  ("start [id]", "Wake a cold computer"),
411
- ("enter [id]", "Enter a computer's console (TTY)"),
418
+ ("enter [id] [-- <cmd>]", "Enter a computer's console or interactive TTY command"),
412
419
  ("run [id] <cmd>", "Run a one-shot command"),
413
420
  ("exec [id] -- <cmd>", "Stream stdout/stderr and optionally pipe stdin"),
414
421
  ("publish [id] <port> -- <cmd>", "Run and publish a durable service command"),
@@ -529,13 +536,17 @@ def _windows_unsupported() -> bool:
529
536
 
530
537
 
531
538
  def main(argv: list[str] | None = None) -> int:
539
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
532
540
  parser = build_parser()
533
- args = parser.parse_args(argv)
541
+ args = parser.parse_args(raw_argv)
534
542
 
535
543
  if _windows_unsupported():
536
544
  return report_error("runtime-sdk supports macOS and Linux only; Windows is not supported")
537
545
 
538
546
  try:
547
+ if args.command == "enter" and args.enter_command and "--" not in raw_argv:
548
+ return report_error("enter command must be separated from the computer id with --")
549
+
539
550
  config = load_config()
540
551
  if args.base_url:
541
552
  config.base_url = args.base_url
@@ -580,9 +591,9 @@ def main(argv: list[str] | None = None) -> int:
580
591
  if args.secrets_action == "show":
581
592
  return handle_secrets_show(config, args.slug)
582
593
  if args.secrets_action == "set":
583
- return handle_secrets_set(config, args.slug, args.pairs)
594
+ return handle_secrets_set(config, args.slug, args.pairs, args.proxy)
584
595
  if args.secrets_action == "import":
585
- return handle_secrets_import(config, args.slug, args.env_file)
596
+ return handle_secrets_import(config, args.slug, args.env_file, args.proxy)
586
597
  if args.secrets_action in ("delete", "rm"):
587
598
  return handle_secrets_delete(config, args.slug)
588
599
  return report_error("unknown secrets command")
@@ -621,7 +632,7 @@ def main(argv: list[str] | None = None) -> int:
621
632
  if args.command == "start":
622
633
  return handle_start(config, args.id)
623
634
  if args.command == "enter":
624
- return handle_enter(config, args.id)
635
+ return handle_enter(config, args.id, args.enter_command)
625
636
  if args.command in ("delete", "rm"):
626
637
  return handle_delete(config, args.id, force=getattr(args, "force", False))
627
638
  if args.command == "run":
@@ -1591,7 +1602,15 @@ def _render_secret_set_panel(payload: dict[str, Any]) -> None:
1591
1602
  body.append(f"{'slug':<12}", style="dim")
1592
1603
  body.append(f"{payload.get('slug') or '—'}\n")
1593
1604
  body.append(f"{'keys':<12}", style="dim")
1594
- body.append(", ".join(str(key) for key in (payload.get("keys") or [])) or "—")
1605
+ deliveries = payload.get("deliveries") or {}
1606
+ lines = []
1607
+ for key in payload.get("keys") or []:
1608
+ meta = deliveries.get(str(key)) if isinstance(deliveries, dict) else None
1609
+ if isinstance(meta, dict) and meta.get("delivery") == "proxy":
1610
+ lines.append(f"{key} proxy {meta.get('target_host') or '—'}")
1611
+ else:
1612
+ lines.append(f"{key} env")
1613
+ body.append("\n".join(lines) or "—")
1595
1614
  _UI.console().print(Panel(body, title="secret set", border_style="cyan", expand=False))
1596
1615
 
1597
1616
 
@@ -1880,6 +1899,26 @@ def _parse_secret_pairs(pairs: list[str] | None) -> dict[str, str]:
1880
1899
 
1881
1900
 
1882
1901
 
1902
+ def _parse_secret_delivery(proxy_specs: list[str] | None, values: dict[str, str]) -> dict[str, dict[str, str]]:
1903
+ delivery: dict[str, dict[str, str]] = {}
1904
+ for spec in proxy_specs or []:
1905
+ if "=" not in spec:
1906
+ raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
1907
+ key, host = spec.split("=", 1)
1908
+ key = key.strip()
1909
+ host = host.strip()
1910
+ if not key or not host or key not in values:
1911
+ raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
1912
+ delivery[key] = {
1913
+ "delivery": "proxy",
1914
+ "target_host": host,
1915
+ "header": "Authorization",
1916
+ "scheme": "Bearer",
1917
+ }
1918
+ return delivery
1919
+
1920
+
1921
+
1883
1922
  def _parse_env_file(path: str) -> dict[str, str]:
1884
1923
  try:
1885
1924
  lines = Path(path).read_text(encoding="utf-8").splitlines()
@@ -1929,7 +1968,7 @@ def handle_secrets_show(config: RuntimeConfig, slug: str | None) -> int:
1929
1968
 
1930
1969
 
1931
1970
 
1932
- def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str] | None) -> int:
1971
+ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str] | None, proxy_specs: list[str] | None) -> int:
1933
1972
  if (err := _require_api_key(config)) is not None:
1934
1973
  return err
1935
1974
  resolved_slug = _coerce_secret_set_slug(slug)
@@ -1942,8 +1981,9 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
1942
1981
  values = _parse_secret_pairs(pairs)
1943
1982
  if not values:
1944
1983
  return report_error("at least one KEY=VALUE pair is required")
1984
+ delivery = _parse_secret_delivery(proxy_specs, values)
1945
1985
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1946
- secret_set = client.put_secret_set(resolved_slug, values)
1986
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
1947
1987
  return report_success(
1948
1988
  {"secret_set": secret_set},
1949
1989
  lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold]"),
@@ -1951,7 +1991,7 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
1951
1991
 
1952
1992
 
1953
1993
 
1954
- def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str) -> int:
1994
+ def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str, proxy_specs: list[str] | None) -> int:
1955
1995
  if (err := _require_api_key(config)) is not None:
1956
1996
  return err
1957
1997
  resolved_slug = _coerce_secret_set_slug(slug)
@@ -1964,8 +2004,9 @@ def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str
1964
2004
  values = _parse_env_file(env_file)
1965
2005
  if not values:
1966
2006
  return report_error("env file did not contain any secrets")
2007
+ delivery = _parse_secret_delivery(proxy_specs, values)
1967
2008
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1968
- secret_set = client.put_secret_set(resolved_slug, values)
2009
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
1969
2010
  return report_success(
1970
2011
  {"secret_set": secret_set},
1971
2012
  lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold] from [bold]{env_file}[/bold]"),
@@ -2585,12 +2626,14 @@ def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
2585
2626
  return report_success(result, render)
2586
2627
 
2587
2628
 
2588
- def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
2629
+ def handle_enter(config: RuntimeConfig, computer_id: str | None, command_parts: list[str] | None = None) -> int:
2589
2630
  if (err := _require_api_key(config)) is not None:
2590
2631
  return err
2591
2632
  if not _interactive():
2592
2633
  return report_error("enter requires an interactive terminal")
2593
2634
 
2635
+ command = _command_from_remainder(command_parts)
2636
+
2594
2637
  if computer_id is None:
2595
2638
  picked = _pick_computer(
2596
2639
  config,
@@ -2605,6 +2648,8 @@ def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
2605
2648
 
2606
2649
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2607
2650
  computer_id = _resolve_computer_ref(client, str(computer_id))
2651
+ if command:
2652
+ return _enter_computer(config, str(computer_id), command=command)
2608
2653
  return _enter_computer(config, str(computer_id))
2609
2654
 
2610
2655
 
@@ -3190,7 +3235,7 @@ def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_p
3190
3235
  conn.close()
3191
3236
 
3192
3237
 
3193
- def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
3238
+ def _enter_computer(config: RuntimeConfig, computer_id: str, *, command: str | None = None) -> int:
3194
3239
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
3195
3240
  ticket_payload = _with_spinner(
3196
3241
  f"preparing console for {computer_id}…",
@@ -3203,7 +3248,7 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
3203
3248
  _UI.console().print("[cyan]●[/cyan] Connecting to console...")
3204
3249
 
3205
3250
  try:
3206
- return _run_terminal_session(ws_url)
3251
+ return _run_terminal_session(ws_url, bootstrap_command=command)
3207
3252
  except RuntimeAPIError as exc:
3208
3253
  return report_error(str(exc), status_code=exc.status_code)
3209
3254
  except (InvalidHandshake, TimeoutError, OSError) as exc:
@@ -3242,7 +3287,7 @@ def _parse_terminal_frame(raw: bytes) -> tuple[int, bytes]:
3242
3287
  return raw[0], raw[TERMINAL_FRAME_HEADER_SIZE:]
3243
3288
 
3244
3289
 
3245
- def _run_terminal_session(ws_url: str) -> int:
3290
+ def _run_terminal_session(ws_url: str, *, bootstrap_command: str | None = None) -> int:
3246
3291
  if not sys.stdin.isatty() or not sys.stdout.isatty():
3247
3292
  return report_error("enter requires an interactive terminal")
3248
3293
 
@@ -3251,10 +3296,12 @@ def _run_terminal_session(ws_url: str) -> int:
3251
3296
  old_settings = termios_mod.tcgetattr(fd)
3252
3297
  stop = threading.Event()
3253
3298
  result: dict[str, Any] = {"code": 0, "error": None}
3254
- output_state: dict[str, Any] = {"buffer": "", "suppressing_preamble": True}
3299
+ bootstrap_echo = f"exec {bootstrap_command.strip()}" if bootstrap_command and bootstrap_command.strip() else ""
3300
+ output_state: dict[str, Any] = {"buffer": "", "suppressing_preamble": True, "bootstrap_echo": bootstrap_echo}
3255
3301
 
3256
3302
  with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1) as ws:
3257
3303
  _send_terminal_resize(ws)
3304
+ _send_terminal_bootstrap(ws, bootstrap_command)
3258
3305
 
3259
3306
  def recv_loop() -> None:
3260
3307
  try:
@@ -3332,7 +3379,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3332
3379
  if not chunk:
3333
3380
  return ""
3334
3381
  if not state.get("suppressing_preamble", False):
3335
- return _strip_terminal_noise(chunk)
3382
+ return _strip_terminal_noise(chunk, str(state.get("bootstrap_echo") or ""))
3336
3383
 
3337
3384
  buffer = str(state.get("buffer") or "") + chunk
3338
3385
  marker = "\x1b[1;34mruntime@"
@@ -3340,9 +3387,9 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3340
3387
  if idx != -1:
3341
3388
  state["suppressing_preamble"] = False
3342
3389
  state["buffer"] = ""
3343
- return _strip_terminal_noise(buffer[idx:])
3390
+ return _strip_terminal_noise(buffer[idx:], str(state.get("bootstrap_echo") or ""))
3344
3391
 
3345
- cleaned = _strip_terminal_noise(buffer)
3392
+ cleaned = _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3346
3393
  if cleaned and _should_release_terminal_output(cleaned):
3347
3394
  state["suppressing_preamble"] = False
3348
3395
  state["buffer"] = ""
@@ -3351,7 +3398,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3351
3398
  if len(buffer) > 8192:
3352
3399
  state["suppressing_preamble"] = False
3353
3400
  state["buffer"] = ""
3354
- return _strip_terminal_noise(buffer)
3401
+ return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3355
3402
 
3356
3403
  state["buffer"] = buffer
3357
3404
  return ""
@@ -3374,10 +3421,13 @@ def _should_release_terminal_output(text: str) -> bool:
3374
3421
  def _flush_terminal_output(state: dict[str, Any]) -> str:
3375
3422
  buffer = str(state.get("buffer") or "")
3376
3423
  state["buffer"] = ""
3377
- return _strip_terminal_noise(buffer)
3424
+ return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3425
+
3378
3426
 
3427
+ ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
3379
3428
 
3380
- def _strip_terminal_noise(text: str) -> str:
3429
+
3430
+ def _strip_terminal_noise(text: str, bootstrap_echo: str = "") -> str:
3381
3431
  if not text:
3382
3432
  return ""
3383
3433
 
@@ -3395,10 +3445,27 @@ def _strip_terminal_noise(text: str) -> str:
3395
3445
  continue
3396
3446
  if stripped == "* Docs: https://docs.slicervm.com":
3397
3447
  continue
3448
+ if _is_terminal_bootstrap_echo(stripped, bootstrap_echo):
3449
+ continue
3398
3450
  filtered.append(line)
3399
3451
  return "".join(filtered)
3400
3452
 
3401
3453
 
3454
+ def _is_terminal_bootstrap_echo(line: str, bootstrap_echo: str) -> bool:
3455
+ bootstrap_echo = bootstrap_echo.strip()
3456
+ if not bootstrap_echo:
3457
+ return False
3458
+ clean = ANSI_ESCAPE_RE.sub("", line).strip()
3459
+ return clean == bootstrap_echo or clean.endswith(bootstrap_echo)
3460
+
3461
+
3462
+ def _send_terminal_bootstrap(ws: Any, command: str | None) -> None:
3463
+ command = (command or "").strip()
3464
+ if not command:
3465
+ return
3466
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_DATA, f"exec {command}\n".encode()))
3467
+
3468
+
3402
3469
  def _send_terminal_resize(ws: Any) -> None:
3403
3470
  size = shutil.get_terminal_size((100, 24))
3404
3471
  ws.send(_encode_terminal_frame(TERMINAL_FRAME_WINDOW_SIZE, struct.pack(">II", size.columns, size.lines)))
@@ -75,8 +75,11 @@ class RuntimeClient:
75
75
  def get_secret_set(self, slug: str) -> dict[str, Any]:
76
76
  return self._request("GET", f"/api/secrets/{slug}", auth_required=True)
77
77
 
78
- def put_secret_set(self, slug: str, values: dict[str, str]) -> dict[str, Any]:
79
- return self._request("PUT", f"/api/secrets/{slug}", json={"values": values}, auth_required=True)
78
+ def put_secret_set(self, slug: str, values: dict[str, str], delivery: dict[str, Any] | None = None) -> dict[str, Any]:
79
+ payload: dict[str, Any] = {"values": values}
80
+ if delivery:
81
+ payload["deliveries"] = delivery
82
+ return self._request("PUT", f"/api/secrets/{slug}", json=payload, auth_required=True)
80
83
 
81
84
  def delete_secret_set(self, slug: str) -> dict[str, Any]:
82
85
  return self._request("DELETE", f"/api/secrets/{slug}", auth_required=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.35
3
+ Version: 0.4.37
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
@@ -76,7 +76,9 @@ runtime switch -c feature/login --repo owner/repo # create a new branch worksp
76
76
  runtime checkout --repo owner/repo feature/login # alias for switch
77
77
  runtime create # creates a computer with the starter app already published
78
78
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
79
- runtime enter <name-or-id> # accepts slug/name like test, or the computer id
79
+ runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
80
+ runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
81
+ runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
80
82
  runtime list
81
83
  runtime info <id>
82
84
  runtime share public <id>
@@ -86,6 +88,7 @@ runtime run <id> "echo hello" # one-shot foreground command only
86
88
  runtime run <id> "apt install -y nodejs" --uid 0
87
89
  runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
88
90
  printf 'hello' | runtime exec <id> --stdin -- cat
91
+ # Use runtime exec for automation and exact exit codes; use runtime enter -- <cmd> for interactive TTY commands.
89
92
  runtime files ls <id> /home/ubuntu
90
93
  runtime files read <id> /home/ubuntu/app.py --output app.py
91
94
  runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
File without changes