runtime-sdk 0.4.35__tar.gz → 0.4.36__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.36
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.36"
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"),
@@ -580,9 +587,9 @@ def main(argv: list[str] | None = None) -> int:
580
587
  if args.secrets_action == "show":
581
588
  return handle_secrets_show(config, args.slug)
582
589
  if args.secrets_action == "set":
583
- return handle_secrets_set(config, args.slug, args.pairs)
590
+ return handle_secrets_set(config, args.slug, args.pairs, args.proxy)
584
591
  if args.secrets_action == "import":
585
- return handle_secrets_import(config, args.slug, args.env_file)
592
+ return handle_secrets_import(config, args.slug, args.env_file, args.proxy)
586
593
  if args.secrets_action in ("delete", "rm"):
587
594
  return handle_secrets_delete(config, args.slug)
588
595
  return report_error("unknown secrets command")
@@ -621,7 +628,7 @@ def main(argv: list[str] | None = None) -> int:
621
628
  if args.command == "start":
622
629
  return handle_start(config, args.id)
623
630
  if args.command == "enter":
624
- return handle_enter(config, args.id)
631
+ return handle_enter(config, args.id, args.enter_command)
625
632
  if args.command in ("delete", "rm"):
626
633
  return handle_delete(config, args.id, force=getattr(args, "force", False))
627
634
  if args.command == "run":
@@ -1591,7 +1598,15 @@ def _render_secret_set_panel(payload: dict[str, Any]) -> None:
1591
1598
  body.append(f"{'slug':<12}", style="dim")
1592
1599
  body.append(f"{payload.get('slug') or '—'}\n")
1593
1600
  body.append(f"{'keys':<12}", style="dim")
1594
- body.append(", ".join(str(key) for key in (payload.get("keys") or [])) or "—")
1601
+ deliveries = payload.get("deliveries") or {}
1602
+ lines = []
1603
+ for key in payload.get("keys") or []:
1604
+ meta = deliveries.get(str(key)) if isinstance(deliveries, dict) else None
1605
+ if isinstance(meta, dict) and meta.get("delivery") == "proxy":
1606
+ lines.append(f"{key} proxy {meta.get('target_host') or '—'}")
1607
+ else:
1608
+ lines.append(f"{key} env")
1609
+ body.append("\n".join(lines) or "—")
1595
1610
  _UI.console().print(Panel(body, title="secret set", border_style="cyan", expand=False))
1596
1611
 
1597
1612
 
@@ -1880,6 +1895,26 @@ def _parse_secret_pairs(pairs: list[str] | None) -> dict[str, str]:
1880
1895
 
1881
1896
 
1882
1897
 
1898
+ def _parse_secret_delivery(proxy_specs: list[str] | None, values: dict[str, str]) -> dict[str, dict[str, str]]:
1899
+ delivery: dict[str, dict[str, str]] = {}
1900
+ for spec in proxy_specs or []:
1901
+ if "=" not in spec:
1902
+ raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
1903
+ key, host = spec.split("=", 1)
1904
+ key = key.strip()
1905
+ host = host.strip()
1906
+ if not key or not host or key not in values:
1907
+ raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
1908
+ delivery[key] = {
1909
+ "delivery": "proxy",
1910
+ "target_host": host,
1911
+ "header": "Authorization",
1912
+ "scheme": "Bearer",
1913
+ }
1914
+ return delivery
1915
+
1916
+
1917
+
1883
1918
  def _parse_env_file(path: str) -> dict[str, str]:
1884
1919
  try:
1885
1920
  lines = Path(path).read_text(encoding="utf-8").splitlines()
@@ -1929,7 +1964,7 @@ def handle_secrets_show(config: RuntimeConfig, slug: str | None) -> int:
1929
1964
 
1930
1965
 
1931
1966
 
1932
- def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str] | None) -> int:
1967
+ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str] | None, proxy_specs: list[str] | None) -> int:
1933
1968
  if (err := _require_api_key(config)) is not None:
1934
1969
  return err
1935
1970
  resolved_slug = _coerce_secret_set_slug(slug)
@@ -1942,8 +1977,9 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
1942
1977
  values = _parse_secret_pairs(pairs)
1943
1978
  if not values:
1944
1979
  return report_error("at least one KEY=VALUE pair is required")
1980
+ delivery = _parse_secret_delivery(proxy_specs, values)
1945
1981
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1946
- secret_set = client.put_secret_set(resolved_slug, values)
1982
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
1947
1983
  return report_success(
1948
1984
  {"secret_set": secret_set},
1949
1985
  lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold]"),
@@ -1951,7 +1987,7 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
1951
1987
 
1952
1988
 
1953
1989
 
1954
- def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str) -> int:
1990
+ def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str, proxy_specs: list[str] | None) -> int:
1955
1991
  if (err := _require_api_key(config)) is not None:
1956
1992
  return err
1957
1993
  resolved_slug = _coerce_secret_set_slug(slug)
@@ -1964,8 +2000,9 @@ def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str
1964
2000
  values = _parse_env_file(env_file)
1965
2001
  if not values:
1966
2002
  return report_error("env file did not contain any secrets")
2003
+ delivery = _parse_secret_delivery(proxy_specs, values)
1967
2004
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1968
- secret_set = client.put_secret_set(resolved_slug, values)
2005
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
1969
2006
  return report_success(
1970
2007
  {"secret_set": secret_set},
1971
2008
  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 +2622,14 @@ def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
2585
2622
  return report_success(result, render)
2586
2623
 
2587
2624
 
2588
- def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
2625
+ def handle_enter(config: RuntimeConfig, computer_id: str | None, command_parts: list[str] | None = None) -> int:
2589
2626
  if (err := _require_api_key(config)) is not None:
2590
2627
  return err
2591
2628
  if not _interactive():
2592
2629
  return report_error("enter requires an interactive terminal")
2593
2630
 
2631
+ command = _command_from_remainder(command_parts)
2632
+
2594
2633
  if computer_id is None:
2595
2634
  picked = _pick_computer(
2596
2635
  config,
@@ -2605,6 +2644,8 @@ def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
2605
2644
 
2606
2645
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2607
2646
  computer_id = _resolve_computer_ref(client, str(computer_id))
2647
+ if command:
2648
+ return _enter_computer(config, str(computer_id), command=command)
2608
2649
  return _enter_computer(config, str(computer_id))
2609
2650
 
2610
2651
 
@@ -3190,7 +3231,7 @@ def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_p
3190
3231
  conn.close()
3191
3232
 
3192
3233
 
3193
- def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
3234
+ def _enter_computer(config: RuntimeConfig, computer_id: str, *, command: str | None = None) -> int:
3194
3235
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
3195
3236
  ticket_payload = _with_spinner(
3196
3237
  f"preparing console for {computer_id}…",
@@ -3203,7 +3244,7 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
3203
3244
  _UI.console().print("[cyan]●[/cyan] Connecting to console...")
3204
3245
 
3205
3246
  try:
3206
- return _run_terminal_session(ws_url)
3247
+ return _run_terminal_session(ws_url, bootstrap_command=command)
3207
3248
  except RuntimeAPIError as exc:
3208
3249
  return report_error(str(exc), status_code=exc.status_code)
3209
3250
  except (InvalidHandshake, TimeoutError, OSError) as exc:
@@ -3242,7 +3283,7 @@ def _parse_terminal_frame(raw: bytes) -> tuple[int, bytes]:
3242
3283
  return raw[0], raw[TERMINAL_FRAME_HEADER_SIZE:]
3243
3284
 
3244
3285
 
3245
- def _run_terminal_session(ws_url: str) -> int:
3286
+ def _run_terminal_session(ws_url: str, *, bootstrap_command: str | None = None) -> int:
3246
3287
  if not sys.stdin.isatty() or not sys.stdout.isatty():
3247
3288
  return report_error("enter requires an interactive terminal")
3248
3289
 
@@ -3251,10 +3292,12 @@ def _run_terminal_session(ws_url: str) -> int:
3251
3292
  old_settings = termios_mod.tcgetattr(fd)
3252
3293
  stop = threading.Event()
3253
3294
  result: dict[str, Any] = {"code": 0, "error": None}
3254
- output_state: dict[str, Any] = {"buffer": "", "suppressing_preamble": True}
3295
+ bootstrap_echo = f"exec {bootstrap_command.strip()}" if bootstrap_command and bootstrap_command.strip() else ""
3296
+ output_state: dict[str, Any] = {"buffer": "", "suppressing_preamble": True, "bootstrap_echo": bootstrap_echo}
3255
3297
 
3256
3298
  with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1) as ws:
3257
3299
  _send_terminal_resize(ws)
3300
+ _send_terminal_bootstrap(ws, bootstrap_command)
3258
3301
 
3259
3302
  def recv_loop() -> None:
3260
3303
  try:
@@ -3332,7 +3375,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3332
3375
  if not chunk:
3333
3376
  return ""
3334
3377
  if not state.get("suppressing_preamble", False):
3335
- return _strip_terminal_noise(chunk)
3378
+ return _strip_terminal_noise(chunk, str(state.get("bootstrap_echo") or ""))
3336
3379
 
3337
3380
  buffer = str(state.get("buffer") or "") + chunk
3338
3381
  marker = "\x1b[1;34mruntime@"
@@ -3340,9 +3383,9 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3340
3383
  if idx != -1:
3341
3384
  state["suppressing_preamble"] = False
3342
3385
  state["buffer"] = ""
3343
- return _strip_terminal_noise(buffer[idx:])
3386
+ return _strip_terminal_noise(buffer[idx:], str(state.get("bootstrap_echo") or ""))
3344
3387
 
3345
- cleaned = _strip_terminal_noise(buffer)
3388
+ cleaned = _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3346
3389
  if cleaned and _should_release_terminal_output(cleaned):
3347
3390
  state["suppressing_preamble"] = False
3348
3391
  state["buffer"] = ""
@@ -3351,7 +3394,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3351
3394
  if len(buffer) > 8192:
3352
3395
  state["suppressing_preamble"] = False
3353
3396
  state["buffer"] = ""
3354
- return _strip_terminal_noise(buffer)
3397
+ return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3355
3398
 
3356
3399
  state["buffer"] = buffer
3357
3400
  return ""
@@ -3374,10 +3417,13 @@ def _should_release_terminal_output(text: str) -> bool:
3374
3417
  def _flush_terminal_output(state: dict[str, Any]) -> str:
3375
3418
  buffer = str(state.get("buffer") or "")
3376
3419
  state["buffer"] = ""
3377
- return _strip_terminal_noise(buffer)
3420
+ return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3421
+
3378
3422
 
3423
+ ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
3379
3424
 
3380
- def _strip_terminal_noise(text: str) -> str:
3425
+
3426
+ def _strip_terminal_noise(text: str, bootstrap_echo: str = "") -> str:
3381
3427
  if not text:
3382
3428
  return ""
3383
3429
 
@@ -3395,10 +3441,27 @@ def _strip_terminal_noise(text: str) -> str:
3395
3441
  continue
3396
3442
  if stripped == "* Docs: https://docs.slicervm.com":
3397
3443
  continue
3444
+ if _is_terminal_bootstrap_echo(stripped, bootstrap_echo):
3445
+ continue
3398
3446
  filtered.append(line)
3399
3447
  return "".join(filtered)
3400
3448
 
3401
3449
 
3450
+ def _is_terminal_bootstrap_echo(line: str, bootstrap_echo: str) -> bool:
3451
+ bootstrap_echo = bootstrap_echo.strip()
3452
+ if not bootstrap_echo:
3453
+ return False
3454
+ clean = ANSI_ESCAPE_RE.sub("", line).strip()
3455
+ return clean == bootstrap_echo or clean.endswith(bootstrap_echo)
3456
+
3457
+
3458
+ def _send_terminal_bootstrap(ws: Any, command: str | None) -> None:
3459
+ command = (command or "").strip()
3460
+ if not command:
3461
+ return
3462
+ ws.send(_encode_terminal_frame(TERMINAL_FRAME_DATA, f"exec {command}\n".encode()))
3463
+
3464
+
3402
3465
  def _send_terminal_resize(ws: Any) -> None:
3403
3466
  size = shutil.get_terminal_size((100, 24))
3404
3467
  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.36
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