runtime-sdk 0.4.34__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.34
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>
@@ -84,6 +86,16 @@ runtime share private <id>
84
86
  runtime start <id>
85
87
  runtime run <id> "echo hello" # one-shot foreground command only
86
88
  runtime run <id> "apt install -y nodejs" --uid 0
89
+ runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
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.
92
+ runtime files ls <id> /home/ubuntu
93
+ runtime files read <id> /home/ubuntu/app.py --output app.py
94
+ runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
95
+ runtime files mkdir <id> /home/ubuntu/data --parents
96
+ runtime files upload <id> ./local-app /home/ubuntu/app
97
+ runtime files download <id> /home/ubuntu/app ./downloaded-app
98
+ runtime files rm <id> /home/ubuntu/data --recursive
87
99
  runtime startup show <id>
88
100
  runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
89
101
  runtime startup clear <id> # low-level durable service config
@@ -123,6 +135,10 @@ app = client.create_computer(
123
135
  result = client.run_command(computer["id"], "echo hello")
124
136
  print(result["stdout"])
125
137
 
138
+ client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
139
+ print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
140
+ print(client.list_files(computer["id"], "/home/ubuntu"))
141
+
126
142
  # Wake a cold computer explicitly
127
143
  client.start_computer(app["id"])
128
144
 
@@ -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>
@@ -62,6 +64,16 @@ runtime share private <id>
62
64
  runtime start <id>
63
65
  runtime run <id> "echo hello" # one-shot foreground command only
64
66
  runtime run <id> "apt install -y nodejs" --uid 0
67
+ runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
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.
70
+ runtime files ls <id> /home/ubuntu
71
+ runtime files read <id> /home/ubuntu/app.py --output app.py
72
+ runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
73
+ runtime files mkdir <id> /home/ubuntu/data --parents
74
+ runtime files upload <id> ./local-app /home/ubuntu/app
75
+ runtime files download <id> /home/ubuntu/app ./downloaded-app
76
+ runtime files rm <id> /home/ubuntu/data --recursive
65
77
  runtime startup show <id>
66
78
  runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
67
79
  runtime startup clear <id> # low-level durable service config
@@ -101,6 +113,10 @@ app = client.create_computer(
101
113
  result = client.run_command(computer["id"], "echo hello")
102
114
  print(result["stdout"])
103
115
 
116
+ client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
117
+ print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
118
+ print(client.list_files(computer["id"], "/home/ubuntu"))
119
+
104
120
  # Wake a cold computer explicitly
105
121
  client.start_computer(app["id"])
106
122
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.34"
3
+ version = "0.4.36"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -15,6 +15,8 @@ import socket
15
15
  import struct
16
16
  import subprocess
17
17
  import sys
18
+ import tarfile
19
+ import tempfile
18
20
  import threading
19
21
  import time
20
22
  import webbrowser
@@ -187,9 +189,11 @@ def build_parser() -> argparse.ArgumentParser:
187
189
  secrets_set = secrets_subparsers.add_parser("set", help="Create or replace a secret set from KEY=VALUE pairs")
188
190
  secrets_set.add_argument("slug", nargs="?", default=None, help="Secret set slug")
189
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")
190
193
  secrets_import = secrets_subparsers.add_parser("import", help="Create or replace a secret set from a .env file")
191
194
  secrets_import.add_argument("slug", nargs="?", default=None, help="Secret set slug")
192
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")
193
197
  secrets_delete = secrets_subparsers.add_parser("delete", aliases=["rm"], help="Delete a secret set")
194
198
  secrets_delete.add_argument("slug", nargs="?", default=None, help="Secret set slug")
195
199
 
@@ -236,8 +240,13 @@ def build_parser() -> argparse.ArgumentParser:
236
240
  help="Re-render the table every N seconds (default 2)",
237
241
  )
238
242
 
239
- 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
+ )
240
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")
241
250
 
242
251
  info_cmd = subparsers.add_parser("info", help="Get computer details")
243
252
  info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
@@ -278,6 +287,53 @@ def build_parser() -> argparse.ArgumentParser:
278
287
  run_cmd.add_argument("--cwd", help="Working directory")
279
288
  run_cmd.add_argument("--shell", help="Shell to use")
280
289
 
290
+ exec_cmd = subparsers.add_parser("exec", help="Stream a command in a computer")
291
+ exec_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
292
+ exec_cmd.add_argument("exec_command", nargs="*", help="Command after --")
293
+ exec_cmd.add_argument("--stdin", action="store_true", help="Pipe local stdin to the command")
294
+ exec_cmd.add_argument("--uid", type=int, help="User ID")
295
+ exec_cmd.add_argument("--gid", type=int, help="Group ID")
296
+ exec_cmd.add_argument("--cwd", help="Working directory")
297
+ exec_cmd.add_argument("--shell", help="Shell to use")
298
+
299
+ files_cmd = subparsers.add_parser("files", aliases=["fs", "file"], help="Read, write, list, and delete files")
300
+ files_subparsers = files_cmd.add_subparsers(dest="files_action", required=True)
301
+ files_read = files_subparsers.add_parser("read", help="Read a file to stdout or --output")
302
+ files_read.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
303
+ files_read.add_argument("path", nargs="?", default=None, help="Path inside the computer")
304
+ files_read.add_argument("--output", "-o", help="Local output file")
305
+ files_write = files_subparsers.add_parser("write", help="Write stdin, --content, or --input into a file")
306
+ files_write.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
307
+ files_write.add_argument("path", nargs="?", default=None, help="Path inside the computer")
308
+ files_write.add_argument("--input", "-i", help="Local input file (defaults to stdin)")
309
+ files_write.add_argument("--content", help="Literal text to write")
310
+ files_write.add_argument("--mode", help="File mode, e.g. 0644")
311
+ files_write.add_argument("--uid", type=int, help="User ID")
312
+ files_write.add_argument("--gid", type=int, help="Group ID")
313
+ files_list = files_subparsers.add_parser("list", aliases=["ls"], help="List a directory")
314
+ files_list.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
315
+ files_list.add_argument("path", nargs="?", default=None, help="Directory path")
316
+ files_stat = files_subparsers.add_parser("stat", help="Show file metadata")
317
+ files_stat.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
318
+ files_stat.add_argument("path", nargs="?", default=None, help="Path inside the computer")
319
+ files_mkdir = files_subparsers.add_parser("mkdir", help="Create a directory")
320
+ files_mkdir.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
321
+ files_mkdir.add_argument("path", nargs="?", default=None, help="Directory path")
322
+ files_mkdir.add_argument("--parents", "-p", action="store_true", help="Create parent directories")
323
+ files_mkdir.add_argument("--mode", help="Directory mode, e.g. 0755")
324
+ files_rm = files_subparsers.add_parser("rm", aliases=["delete"], help="Delete a file or directory")
325
+ files_rm.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
326
+ files_rm.add_argument("path", nargs="?", default=None, help="Path inside the computer")
327
+ files_rm.add_argument("--recursive", "-r", action="store_true", help="Delete directories recursively")
328
+ files_upload = files_subparsers.add_parser("upload", help="Upload a local file or directory")
329
+ files_upload.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
330
+ files_upload.add_argument("local", nargs="?", default=None, help="Local file or directory")
331
+ files_upload.add_argument("remote", nargs="?", default=None, help="Remote destination directory")
332
+ files_download = files_subparsers.add_parser("download", help="Download a remote file or directory")
333
+ files_download.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
334
+ files_download.add_argument("remote", nargs="?", default=None, help="Remote file or directory")
335
+ files_download.add_argument("local", nargs="?", default=None, help="Local destination directory")
336
+
281
337
  publish_cmd = subparsers.add_parser(
282
338
  "publish", help="Promote the running app on a local port into the durable public app"
283
339
  )
@@ -359,12 +415,21 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
359
415
  ("info [id]", "Show computer details"),
360
416
  ("url [id]", "Show a computer's public URL and live published port"),
361
417
  ("start [id]", "Wake a cold computer"),
362
- ("enter [id]", "Enter a computer's console (TTY)"),
418
+ ("enter [id] [-- <cmd>]", "Enter a computer's console or interactive TTY command"),
363
419
  ("run [id] <cmd>", "Run a one-shot command"),
420
+ ("exec [id] -- <cmd>", "Stream stdout/stderr and optionally pipe stdin"),
364
421
  ("publish [id] <port> -- <cmd>", "Run and publish a durable service command"),
365
422
  ("delete, rm [id]", "Delete a computer (typed-name confirm; --force / -y to skip)"),
366
423
  ),
367
424
  ),
425
+ (
426
+ "Files",
427
+ (
428
+ ("files read [id] <path>", "Read a file to stdout or --output"),
429
+ ("files write [id] <path>", "Write stdin, --input, or --content to a file"),
430
+ ("files ls/stat/mkdir/rm", "List, inspect, create, and delete paths"),
431
+ ),
432
+ ),
368
433
  (
369
434
  "Sharing",
370
435
  (
@@ -522,9 +587,9 @@ def main(argv: list[str] | None = None) -> int:
522
587
  if args.secrets_action == "show":
523
588
  return handle_secrets_show(config, args.slug)
524
589
  if args.secrets_action == "set":
525
- return handle_secrets_set(config, args.slug, args.pairs)
590
+ return handle_secrets_set(config, args.slug, args.pairs, args.proxy)
526
591
  if args.secrets_action == "import":
527
- return handle_secrets_import(config, args.slug, args.env_file)
592
+ return handle_secrets_import(config, args.slug, args.env_file, args.proxy)
528
593
  if args.secrets_action in ("delete", "rm"):
529
594
  return handle_secrets_delete(config, args.slug)
530
595
  return report_error("unknown secrets command")
@@ -563,13 +628,17 @@ def main(argv: list[str] | None = None) -> int:
563
628
  if args.command == "start":
564
629
  return handle_start(config, args.id)
565
630
  if args.command == "enter":
566
- return handle_enter(config, args.id)
631
+ return handle_enter(config, args.id, args.enter_command)
567
632
  if args.command in ("delete", "rm"):
568
633
  return handle_delete(config, args.id, force=getattr(args, "force", False))
569
634
  if args.command == "run":
570
635
  return handle_run(
571
636
  config, args.id, args.run_command, args.uid, args.gid, args.cwd, args.shell
572
637
  )
638
+ if args.command == "exec":
639
+ return handle_exec_stream(config, args.id, args.exec_command, args.stdin, args.uid, args.gid, args.cwd, args.shell)
640
+ if args.command in ("files", "fs", "file"):
641
+ return handle_files(config, args)
573
642
  if args.command == "publish":
574
643
  return handle_publish(config, args.id, args.port, args.publish_command)
575
644
  if args.command == "startup":
@@ -1529,7 +1598,15 @@ def _render_secret_set_panel(payload: dict[str, Any]) -> None:
1529
1598
  body.append(f"{'slug':<12}", style="dim")
1530
1599
  body.append(f"{payload.get('slug') or '—'}\n")
1531
1600
  body.append(f"{'keys':<12}", style="dim")
1532
- 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 "—")
1533
1610
  _UI.console().print(Panel(body, title="secret set", border_style="cyan", expand=False))
1534
1611
 
1535
1612
 
@@ -1818,6 +1895,26 @@ def _parse_secret_pairs(pairs: list[str] | None) -> dict[str, str]:
1818
1895
 
1819
1896
 
1820
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
+
1821
1918
  def _parse_env_file(path: str) -> dict[str, str]:
1822
1919
  try:
1823
1920
  lines = Path(path).read_text(encoding="utf-8").splitlines()
@@ -1867,7 +1964,7 @@ def handle_secrets_show(config: RuntimeConfig, slug: str | None) -> int:
1867
1964
 
1868
1965
 
1869
1966
 
1870
- 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:
1871
1968
  if (err := _require_api_key(config)) is not None:
1872
1969
  return err
1873
1970
  resolved_slug = _coerce_secret_set_slug(slug)
@@ -1880,8 +1977,9 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
1880
1977
  values = _parse_secret_pairs(pairs)
1881
1978
  if not values:
1882
1979
  return report_error("at least one KEY=VALUE pair is required")
1980
+ delivery = _parse_secret_delivery(proxy_specs, values)
1883
1981
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1884
- secret_set = client.put_secret_set(resolved_slug, values)
1982
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
1885
1983
  return report_success(
1886
1984
  {"secret_set": secret_set},
1887
1985
  lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold]"),
@@ -1889,7 +1987,7 @@ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str]
1889
1987
 
1890
1988
 
1891
1989
 
1892
- 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:
1893
1991
  if (err := _require_api_key(config)) is not None:
1894
1992
  return err
1895
1993
  resolved_slug = _coerce_secret_set_slug(slug)
@@ -1902,8 +2000,9 @@ def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str
1902
2000
  values = _parse_env_file(env_file)
1903
2001
  if not values:
1904
2002
  return report_error("env file did not contain any secrets")
2003
+ delivery = _parse_secret_delivery(proxy_specs, values)
1905
2004
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1906
- secret_set = client.put_secret_set(resolved_slug, values)
2005
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
1907
2006
  return report_success(
1908
2007
  {"secret_set": secret_set},
1909
2008
  lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold] from [bold]{env_file}[/bold]"),
@@ -2523,12 +2622,14 @@ def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
2523
2622
  return report_success(result, render)
2524
2623
 
2525
2624
 
2526
- 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:
2527
2626
  if (err := _require_api_key(config)) is not None:
2528
2627
  return err
2529
2628
  if not _interactive():
2530
2629
  return report_error("enter requires an interactive terminal")
2531
2630
 
2631
+ command = _command_from_remainder(command_parts)
2632
+
2532
2633
  if computer_id is None:
2533
2634
  picked = _pick_computer(
2534
2635
  config,
@@ -2543,6 +2644,8 @@ def handle_enter(config: RuntimeConfig, computer_id: str | None) -> int:
2543
2644
 
2544
2645
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2545
2646
  computer_id = _resolve_computer_ref(client, str(computer_id))
2647
+ if command:
2648
+ return _enter_computer(config, str(computer_id), command=command)
2546
2649
  return _enter_computer(config, str(computer_id))
2547
2650
 
2548
2651
 
@@ -2625,6 +2728,241 @@ def handle_run(
2625
2728
  return report_success(result, _render_run_result)
2626
2729
 
2627
2730
 
2731
+ def handle_exec_stream(
2732
+ config: RuntimeConfig,
2733
+ computer_id: str | None,
2734
+ command_parts: list[str] | None,
2735
+ pipe_stdin: bool,
2736
+ uid: int | None,
2737
+ gid: int | None,
2738
+ cwd: str | None,
2739
+ shell: str | None,
2740
+ ) -> int:
2741
+ if (err := _require_api_key(config)) is not None:
2742
+ return err
2743
+ if not computer_id:
2744
+ return report_error("computer id is required")
2745
+ command = _command_from_remainder(command_parts)
2746
+ if not command:
2747
+ return report_error("exec requires -- <command...>")
2748
+ stdin_data = None
2749
+ if pipe_stdin:
2750
+ stdin_buffer = getattr(sys.stdin, "buffer", None)
2751
+ stdin_data = stdin_buffer.read() if stdin_buffer is not None else sys.stdin.read().encode()
2752
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2753
+ computer_id = _resolve_computer_ref(client, computer_id)
2754
+ exit_code = 0
2755
+ for frame in client.stream_command(computer_id, command, stdin=stdin_data, uid=uid, gid=gid, cwd=cwd, shell=shell):
2756
+ stdout = frame.get("stdout") or frame.get("data") or ""
2757
+ stderr = frame.get("stderr") or ""
2758
+ error = frame.get("error") or ""
2759
+ if stdout:
2760
+ print(stdout, end="")
2761
+ if stderr:
2762
+ print(stderr, end="", file=sys.stderr)
2763
+ if error:
2764
+ print(error, file=sys.stderr)
2765
+ exit_code = 1
2766
+ if frame.get("type") == "exit" or frame.get("exit_code") is not None:
2767
+ try:
2768
+ exit_code = int(frame.get("exit_code") or 0)
2769
+ except (TypeError, ValueError):
2770
+ exit_code = 1
2771
+ return exit_code
2772
+
2773
+
2774
+ def handle_files(config: RuntimeConfig, args: Any) -> int:
2775
+ action = str(getattr(args, "files_action", "") or "")
2776
+ if action == "read":
2777
+ return handle_files_read(config, args.id, args.path, args.output)
2778
+ if action == "write":
2779
+ return handle_files_write(config, args)
2780
+ if action in ("list", "ls"):
2781
+ return handle_files_list(config, args.id, args.path)
2782
+ if action == "stat":
2783
+ return handle_files_stat(config, args.id, args.path)
2784
+ if action == "mkdir":
2785
+ return handle_files_mkdir(config, args.id, args.path, args.parents, args.mode)
2786
+ if action in ("rm", "delete"):
2787
+ return handle_files_rm(config, args.id, args.path, args.recursive)
2788
+ if action == "upload":
2789
+ return handle_files_upload(config, args.id, args.local, args.remote)
2790
+ if action == "download":
2791
+ return handle_files_download(config, args.id, args.remote, args.local)
2792
+ return report_error("unknown files command")
2793
+
2794
+
2795
+ def _resolve_file_target(config: RuntimeConfig, computer_id: str | None, path: str | None) -> tuple[RuntimeClient, str, str] | int:
2796
+ if (err := _require_api_key(config)) is not None:
2797
+ return err
2798
+ if not computer_id:
2799
+ return report_error("computer id is required")
2800
+ if not path:
2801
+ return report_error("path is required")
2802
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2803
+ resolved_id = _resolve_computer_ref(client, computer_id)
2804
+ return client, resolved_id, path
2805
+
2806
+
2807
+ def handle_files_read(config: RuntimeConfig, computer_id: str | None, path: str | None, output: str | None) -> int:
2808
+ resolved = _resolve_file_target(config, computer_id, path)
2809
+ if isinstance(resolved, int):
2810
+ return resolved
2811
+ client, resolved_id, resolved_path = resolved
2812
+ data = client.read_file(resolved_id, resolved_path)
2813
+ if output:
2814
+ try:
2815
+ Path(output).write_bytes(data)
2816
+ except OSError as exc:
2817
+ return report_error(f"failed to write output file: {exc}")
2818
+ return report_success({"path": resolved_path, "output": output, "size": len(data)}, lambda p: _UI.console().print(f"[green]✓[/green] wrote [bold]{p['size']}[/bold] bytes to [bold]{p['output']}[/bold]"))
2819
+ stdout_buffer = getattr(sys.stdout, "buffer", None)
2820
+ if stdout_buffer is None:
2821
+ sys.stdout.write(data.decode(errors="replace"))
2822
+ sys.stdout.flush()
2823
+ return 0
2824
+ stdout_buffer.write(data)
2825
+ stdout_buffer.flush()
2826
+ return 0
2827
+
2828
+
2829
+ def handle_files_write(config: RuntimeConfig, args: Any) -> int:
2830
+ input_path = getattr(args, "input", None)
2831
+ content = getattr(args, "content", None)
2832
+ if input_path and content is not None:
2833
+ return report_error("pass either --input or --content, not both")
2834
+ resolved = _resolve_file_target(config, args.id, args.path)
2835
+ if isinstance(resolved, int):
2836
+ return resolved
2837
+ client, resolved_id, resolved_path = resolved
2838
+ if input_path:
2839
+ try:
2840
+ data = Path(input_path).read_bytes()
2841
+ except OSError as exc:
2842
+ return report_error(f"failed to read input file: {exc}")
2843
+ elif content is not None:
2844
+ data = content.encode()
2845
+ else:
2846
+ stdin_buffer = getattr(sys.stdin, "buffer", None)
2847
+ data = stdin_buffer.read() if stdin_buffer is not None else sys.stdin.read().encode()
2848
+ result = client.write_file(resolved_id, resolved_path, data, uid=args.uid, gid=args.gid, mode=args.mode)
2849
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] wrote [bold]{p.get('size', len(data))}[/bold] bytes to [bold]{p.get('path', resolved_path)}[/bold]"))
2850
+
2851
+
2852
+ def _render_file_list(payload: dict[str, Any]) -> None:
2853
+ mods = _UI.rich()
2854
+ Table = mods["Table"]
2855
+ table = Table(title=str(payload.get("path") or "files"))
2856
+ table.add_column("Name", style="bold white")
2857
+ table.add_column("Type")
2858
+ table.add_column("Size", justify="right")
2859
+ table.add_column("Mode", style="dim")
2860
+ for entry in payload.get("entries") or []:
2861
+ if not isinstance(entry, dict):
2862
+ continue
2863
+ table.add_row(str(entry.get("name") or ""), str(entry.get("type") or ""), str(entry.get("size") or 0), str(entry.get("mode") or ""))
2864
+ _UI.console().print(table)
2865
+
2866
+
2867
+ def handle_files_list(config: RuntimeConfig, computer_id: str | None, path: str | None) -> int:
2868
+ resolved = _resolve_file_target(config, computer_id, path or "/home/ubuntu")
2869
+ if isinstance(resolved, int):
2870
+ return resolved
2871
+ client, resolved_id, resolved_path = resolved
2872
+ return report_success(client.list_files(resolved_id, resolved_path), _render_file_list)
2873
+
2874
+
2875
+ def handle_files_stat(config: RuntimeConfig, computer_id: str | None, path: str | None) -> int:
2876
+ resolved = _resolve_file_target(config, computer_id, path)
2877
+ if isinstance(resolved, int):
2878
+ return resolved
2879
+ client, resolved_id, resolved_path = resolved
2880
+ return report_success(client.stat_file(resolved_id, resolved_path))
2881
+
2882
+
2883
+ def handle_files_mkdir(config: RuntimeConfig, computer_id: str | None, path: str | None, parents: bool, mode: str | None) -> int:
2884
+ resolved = _resolve_file_target(config, computer_id, path)
2885
+ if isinstance(resolved, int):
2886
+ return resolved
2887
+ client, resolved_id, resolved_path = resolved
2888
+ result = client.mkdir(resolved_id, resolved_path, recursive=parents, mode=mode)
2889
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] created [bold]{p.get('name', resolved_path)}[/bold]"))
2890
+
2891
+
2892
+ def handle_files_rm(config: RuntimeConfig, computer_id: str | None, path: str | None, recursive: bool) -> int:
2893
+ resolved = _resolve_file_target(config, computer_id, path)
2894
+ if isinstance(resolved, int):
2895
+ return resolved
2896
+ client, resolved_id, resolved_path = resolved
2897
+ result = client.delete_file(resolved_id, resolved_path, recursive=recursive)
2898
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] deleted [bold]{p.get('path', resolved_path)}[/bold]"))
2899
+
2900
+
2901
+ def handle_files_upload(config: RuntimeConfig, computer_id: str | None, local: str | None, remote: str | None) -> int:
2902
+ if not local:
2903
+ return report_error("local path is required")
2904
+ local_path = Path(local)
2905
+ if not local_path.exists():
2906
+ return report_error(f"local path does not exist: {local}")
2907
+ resolved = _resolve_file_target(config, computer_id, remote)
2908
+ if isinstance(resolved, int):
2909
+ return resolved
2910
+ client, resolved_id, remote_path = resolved
2911
+ with tempfile.TemporaryFile() as archive:
2912
+ try:
2913
+ with tarfile.open(fileobj=archive, mode="w") as tar:
2914
+ if local_path.is_dir():
2915
+ for child in sorted(local_path.rglob("*")):
2916
+ tar.add(child, arcname=str(child.relative_to(local_path)), recursive=False)
2917
+ else:
2918
+ tar.add(local_path, arcname=local_path.name, recursive=False)
2919
+ except (OSError, tarfile.TarError) as exc:
2920
+ return report_error(f"failed to read local path: {exc}")
2921
+ archive.seek(0)
2922
+ result = client.upload_files(resolved_id, remote_path, archive)
2923
+ return report_success(result, lambda p: _UI.console().print(f"[green]✓[/green] uploaded [bold]{p.get('files', 0)}[/bold] files to [bold]{p.get('path', remote_path)}[/bold]"))
2924
+
2925
+
2926
+ def handle_files_download(config: RuntimeConfig, computer_id: str | None, remote: str | None, local: str | None) -> int:
2927
+ local_dir = Path(local or ".")
2928
+ resolved = _resolve_file_target(config, computer_id, remote)
2929
+ if isinstance(resolved, int):
2930
+ return resolved
2931
+ client, resolved_id, remote_path = resolved
2932
+ try:
2933
+ local_dir.mkdir(parents=True, exist_ok=True)
2934
+ with tempfile.TemporaryFile() as archive:
2935
+ client.download_files_to(resolved_id, remote_path, archive)
2936
+ archive.seek(0)
2937
+ with tarfile.open(fileobj=archive, mode="r") as tar:
2938
+ members = tar.getmembers()
2939
+ for member in members:
2940
+ target = safe_local_extract_path(local_dir, member.name)
2941
+ if member.isdir():
2942
+ target.mkdir(parents=True, exist_ok=True)
2943
+ continue
2944
+ if not member.isfile():
2945
+ return report_error(f"unsupported archive entry: {member.name}")
2946
+ target.parent.mkdir(parents=True, exist_ok=True)
2947
+ source = tar.extractfile(member)
2948
+ if source is None:
2949
+ return report_error(f"missing archive data: {member.name}")
2950
+ target.write_bytes(source.read())
2951
+ except (OSError, tarfile.TarError) as exc:
2952
+ return report_error(f"failed to write downloaded files: {exc}")
2953
+ return report_success({"path": remote_path, "output": str(local_dir), "entries": len(members)}, lambda p: _UI.console().print(f"[green]✓[/green] downloaded [bold]{p['entries']}[/bold] entries to [bold]{p['output']}[/bold]"))
2954
+
2955
+
2956
+ def safe_local_extract_path(root: Path, name: str) -> Path:
2957
+ if not name or Path(name).is_absolute():
2958
+ raise RuntimeAPIError(f"unsafe archive entry: {name}")
2959
+ target = (root / name).resolve()
2960
+ root_resolved = root.resolve()
2961
+ if target != root_resolved and root_resolved not in target.parents:
2962
+ raise RuntimeAPIError(f"unsafe archive entry: {name}")
2963
+ return target
2964
+
2965
+
2628
2966
  def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | None, command_parts: list[str] | None = None) -> int:
2629
2967
  if (err := _require_api_key(config)) is not None:
2630
2968
  return err
@@ -2893,7 +3231,7 @@ def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_p
2893
3231
  conn.close()
2894
3232
 
2895
3233
 
2896
- def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
3234
+ def _enter_computer(config: RuntimeConfig, computer_id: str, *, command: str | None = None) -> int:
2897
3235
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2898
3236
  ticket_payload = _with_spinner(
2899
3237
  f"preparing console for {computer_id}…",
@@ -2906,7 +3244,7 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
2906
3244
  _UI.console().print("[cyan]●[/cyan] Connecting to console...")
2907
3245
 
2908
3246
  try:
2909
- return _run_terminal_session(ws_url)
3247
+ return _run_terminal_session(ws_url, bootstrap_command=command)
2910
3248
  except RuntimeAPIError as exc:
2911
3249
  return report_error(str(exc), status_code=exc.status_code)
2912
3250
  except (InvalidHandshake, TimeoutError, OSError) as exc:
@@ -2945,7 +3283,7 @@ def _parse_terminal_frame(raw: bytes) -> tuple[int, bytes]:
2945
3283
  return raw[0], raw[TERMINAL_FRAME_HEADER_SIZE:]
2946
3284
 
2947
3285
 
2948
- def _run_terminal_session(ws_url: str) -> int:
3286
+ def _run_terminal_session(ws_url: str, *, bootstrap_command: str | None = None) -> int:
2949
3287
  if not sys.stdin.isatty() or not sys.stdout.isatty():
2950
3288
  return report_error("enter requires an interactive terminal")
2951
3289
 
@@ -2954,10 +3292,12 @@ def _run_terminal_session(ws_url: str) -> int:
2954
3292
  old_settings = termios_mod.tcgetattr(fd)
2955
3293
  stop = threading.Event()
2956
3294
  result: dict[str, Any] = {"code": 0, "error": None}
2957
- 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}
2958
3297
 
2959
3298
  with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1) as ws:
2960
3299
  _send_terminal_resize(ws)
3300
+ _send_terminal_bootstrap(ws, bootstrap_command)
2961
3301
 
2962
3302
  def recv_loop() -> None:
2963
3303
  try:
@@ -3035,7 +3375,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3035
3375
  if not chunk:
3036
3376
  return ""
3037
3377
  if not state.get("suppressing_preamble", False):
3038
- return _strip_terminal_noise(chunk)
3378
+ return _strip_terminal_noise(chunk, str(state.get("bootstrap_echo") or ""))
3039
3379
 
3040
3380
  buffer = str(state.get("buffer") or "") + chunk
3041
3381
  marker = "\x1b[1;34mruntime@"
@@ -3043,9 +3383,9 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3043
3383
  if idx != -1:
3044
3384
  state["suppressing_preamble"] = False
3045
3385
  state["buffer"] = ""
3046
- return _strip_terminal_noise(buffer[idx:])
3386
+ return _strip_terminal_noise(buffer[idx:], str(state.get("bootstrap_echo") or ""))
3047
3387
 
3048
- cleaned = _strip_terminal_noise(buffer)
3388
+ cleaned = _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3049
3389
  if cleaned and _should_release_terminal_output(cleaned):
3050
3390
  state["suppressing_preamble"] = False
3051
3391
  state["buffer"] = ""
@@ -3054,7 +3394,7 @@ def _filter_terminal_output(chunk: str, state: dict[str, Any]) -> str:
3054
3394
  if len(buffer) > 8192:
3055
3395
  state["suppressing_preamble"] = False
3056
3396
  state["buffer"] = ""
3057
- return _strip_terminal_noise(buffer)
3397
+ return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3058
3398
 
3059
3399
  state["buffer"] = buffer
3060
3400
  return ""
@@ -3077,10 +3417,13 @@ def _should_release_terminal_output(text: str) -> bool:
3077
3417
  def _flush_terminal_output(state: dict[str, Any]) -> str:
3078
3418
  buffer = str(state.get("buffer") or "")
3079
3419
  state["buffer"] = ""
3080
- return _strip_terminal_noise(buffer)
3420
+ return _strip_terminal_noise(buffer, str(state.get("bootstrap_echo") or ""))
3421
+
3081
3422
 
3423
+ ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
3082
3424
 
3083
- def _strip_terminal_noise(text: str) -> str:
3425
+
3426
+ def _strip_terminal_noise(text: str, bootstrap_echo: str = "") -> str:
3084
3427
  if not text:
3085
3428
  return ""
3086
3429
 
@@ -3098,10 +3441,27 @@ def _strip_terminal_noise(text: str) -> str:
3098
3441
  continue
3099
3442
  if stripped == "* Docs: https://docs.slicervm.com":
3100
3443
  continue
3444
+ if _is_terminal_bootstrap_echo(stripped, bootstrap_echo):
3445
+ continue
3101
3446
  filtered.append(line)
3102
3447
  return "".join(filtered)
3103
3448
 
3104
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
+
3105
3465
  def _send_terminal_resize(ws: Any) -> None:
3106
3466
  size = shutil.get_terminal_size((100, 24))
3107
3467
  ws.send(_encode_terminal_frame(TERMINAL_FRAME_WINDOW_SIZE, struct.pack(">II", size.columns, size.lines)))
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+ import json
3
5
  from dataclasses import dataclass
4
- from typing import Any
6
+ from typing import Any, BinaryIO
7
+ from urllib.parse import urlencode
5
8
 
6
9
  import httpx
7
10
 
@@ -72,8 +75,11 @@ class RuntimeClient:
72
75
  def get_secret_set(self, slug: str) -> dict[str, Any]:
73
76
  return self._request("GET", f"/api/secrets/{slug}", auth_required=True)
74
77
 
75
- def put_secret_set(self, slug: str, values: dict[str, str]) -> dict[str, Any]:
76
- 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)
77
83
 
78
84
  def delete_secret_set(self, slug: str) -> dict[str, Any]:
79
85
  return self._request("DELETE", f"/api/secrets/{slug}", auth_required=True)
@@ -284,6 +290,138 @@ class RuntimeClient:
284
290
  body["shell"] = shell
285
291
  return self._request("POST", f"/api/computers/{computer_id}/exec", json=body, auth_required=True)
286
292
 
293
+ def stream_command(
294
+ self,
295
+ computer_id: str,
296
+ command: str,
297
+ *,
298
+ stdin: bytes | None = None,
299
+ uid: int | None = None,
300
+ gid: int | None = None,
301
+ cwd: str | None = None,
302
+ shell: str | None = None,
303
+ ):
304
+ body: dict[str, Any] = {"command": command}
305
+ if stdin:
306
+ body["stdin_base64"] = base64.b64encode(stdin).decode()
307
+ if uid is not None:
308
+ body["uid"] = uid
309
+ if gid is not None:
310
+ body["gid"] = gid
311
+ if cwd is not None:
312
+ body["cwd"] = cwd
313
+ if shell is not None:
314
+ body["shell"] = shell
315
+ headers = self._headers(True)
316
+ client = httpx.Client(base_url=self.base_url, timeout=None, transport=self.transport)
317
+ try:
318
+ with client.stream("POST", f"/api/computers/{computer_id}/exec/stream", json=body, headers=headers) as response:
319
+ if not response.is_success:
320
+ body_bytes = response.read()
321
+ payload: dict[str, Any] = {}
322
+ if body_bytes:
323
+ try:
324
+ decoded = json.loads(body_bytes.decode())
325
+ if isinstance(decoded, dict):
326
+ payload = decoded
327
+ except ValueError:
328
+ pass
329
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
330
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
331
+ for line in response.iter_lines():
332
+ if not line:
333
+ continue
334
+ try:
335
+ frame = json.loads(line)
336
+ except ValueError as exc:
337
+ raise RuntimeAPIError("server returned invalid NDJSON", status_code=response.status_code) from exc
338
+ if not isinstance(frame, dict):
339
+ raise RuntimeAPIError("server returned non-object NDJSON", status_code=response.status_code)
340
+ yield frame
341
+ except httpx.HTTPError as exc:
342
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
343
+ finally:
344
+ client.close()
345
+
346
+ def read_file(self, computer_id: str, path: str) -> bytes:
347
+ return self._request_bytes(
348
+ "GET",
349
+ f"/api/computers/{computer_id}/files/read",
350
+ params={"path": path},
351
+ auth_required=True,
352
+ )
353
+
354
+ def write_file(
355
+ self,
356
+ computer_id: str,
357
+ path: str,
358
+ data: bytes,
359
+ *,
360
+ uid: int | None = None,
361
+ gid: int | None = None,
362
+ mode: str | None = None,
363
+ ) -> dict[str, Any]:
364
+ params: dict[str, Any] = {"path": path}
365
+ if uid is not None:
366
+ params["uid"] = uid
367
+ if gid is not None:
368
+ params["gid"] = gid
369
+ if mode is not None:
370
+ params["mode"] = mode
371
+ return self._request(
372
+ "PUT",
373
+ f"/api/computers/{computer_id}/files/write",
374
+ params=params,
375
+ data=data,
376
+ auth_required=True,
377
+ )
378
+
379
+ def list_files(self, computer_id: str, path: str) -> dict[str, Any]:
380
+ return self._request("GET", f"/api/computers/{computer_id}/files/list", params={"path": path}, auth_required=True)
381
+
382
+ def stat_file(self, computer_id: str, path: str) -> dict[str, Any]:
383
+ return self._request("GET", f"/api/computers/{computer_id}/files/stat", params={"path": path}, auth_required=True)
384
+
385
+ def mkdir(self, computer_id: str, path: str, *, recursive: bool = False, mode: str | None = None) -> dict[str, Any]:
386
+ body: dict[str, Any] = {"path": path, "recursive": recursive}
387
+ if mode is not None:
388
+ body["mode"] = mode
389
+ return self._request("POST", f"/api/computers/{computer_id}/files/mkdir", json=body, auth_required=True)
390
+
391
+ def delete_file(self, computer_id: str, path: str, *, recursive: bool = False) -> dict[str, Any]:
392
+ return self._request(
393
+ "DELETE",
394
+ f"/api/computers/{computer_id}/files/delete",
395
+ params={"path": path, "recursive": str(recursive).lower()},
396
+ auth_required=True,
397
+ )
398
+
399
+ def upload_files(self, computer_id: str, path: str, archive: Any) -> dict[str, Any]:
400
+ return self._request(
401
+ "PUT",
402
+ f"/api/computers/{computer_id}/files/upload",
403
+ params={"path": path},
404
+ data=archive,
405
+ auth_required=True,
406
+ )
407
+
408
+ def download_files(self, computer_id: str, path: str) -> bytes:
409
+ return self._request_bytes(
410
+ "GET",
411
+ f"/api/computers/{computer_id}/files/download",
412
+ params={"path": path},
413
+ auth_required=True,
414
+ )
415
+
416
+ def download_files_to(self, computer_id: str, path: str, writer: BinaryIO) -> None:
417
+ self._request_bytes_to(
418
+ "GET",
419
+ f"/api/computers/{computer_id}/files/download",
420
+ writer,
421
+ params={"path": path},
422
+ auth_required=True,
423
+ )
424
+
287
425
  def publish_port(self, computer_id: str, port: int, *, command: str | None = None, cwd: str | None = None) -> dict[str, Any]:
288
426
  if port <= 0 or port > 65535:
289
427
  raise RuntimeAPIError("port must be between 1 and 65535")
@@ -297,14 +435,12 @@ class RuntimeClient:
297
435
  path: str,
298
436
  *,
299
437
  json: dict[str, Any] | None = None,
438
+ data: Any = None,
439
+ params: dict[str, Any] | None = None,
300
440
  auth_required: bool = False,
301
441
  timeout: float | None = None,
302
442
  ) -> dict[str, Any]:
303
- headers: dict[str, str] = {}
304
- if auth_required:
305
- if not self.api_key:
306
- raise RuntimeAPIError("missing api key")
307
- headers["Authorization"] = f"Bearer {self.api_key}"
443
+ headers = self._headers(auth_required)
308
444
 
309
445
  try:
310
446
  with httpx.Client(
@@ -312,7 +448,7 @@ class RuntimeClient:
312
448
  timeout=self.timeout if timeout is None else timeout,
313
449
  transport=self.transport,
314
450
  ) as client:
315
- response = client.request(method, path, json=json, headers=headers)
451
+ response = client.request(method, self._path(path, params), json=json, content=data, headers=headers)
316
452
  except httpx.HTTPError as exc:
317
453
  raise RuntimeAPIError(f"request failed: {exc}") from exc
318
454
 
@@ -323,6 +459,80 @@ class RuntimeClient:
323
459
  message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
324
460
  raise RuntimeAPIError(str(message), status_code=response.status_code)
325
461
 
462
+ def _request_bytes(
463
+ self,
464
+ method: str,
465
+ path: str,
466
+ *,
467
+ params: dict[str, Any] | None = None,
468
+ auth_required: bool = False,
469
+ timeout: float | None = None,
470
+ ) -> bytes:
471
+ headers = self._headers(auth_required)
472
+ try:
473
+ with httpx.Client(
474
+ base_url=self.base_url,
475
+ timeout=self.timeout if timeout is None else timeout,
476
+ transport=self.transport,
477
+ ) as client:
478
+ response = client.request(method, self._path(path, params), headers=headers)
479
+ except httpx.HTTPError as exc:
480
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
481
+
482
+ if response.is_success:
483
+ return response.content
484
+
485
+ payload = self._decode_response(response)
486
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
487
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
488
+
489
+ def _request_bytes_to(
490
+ self,
491
+ method: str,
492
+ path: str,
493
+ writer: BinaryIO,
494
+ *,
495
+ params: dict[str, Any] | None = None,
496
+ auth_required: bool = False,
497
+ timeout: float | None = None,
498
+ ) -> None:
499
+ headers = self._headers(auth_required)
500
+ try:
501
+ with httpx.Client(
502
+ base_url=self.base_url,
503
+ timeout=self.timeout if timeout is None else timeout,
504
+ transport=self.transport,
505
+ ) as client:
506
+ with client.stream(method, self._path(path, params), headers=headers) as response:
507
+ if response.is_success:
508
+ for chunk in response.iter_bytes():
509
+ writer.write(chunk)
510
+ return
511
+ response.read()
512
+ payload = self._decode_response(response)
513
+ except httpx.HTTPError as exc:
514
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
515
+
516
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
517
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
518
+
519
+ def _headers(self, auth_required: bool) -> dict[str, str]:
520
+ if not auth_required:
521
+ return {}
522
+ if not self.api_key:
523
+ raise RuntimeAPIError("missing api key")
524
+ return {"Authorization": f"Bearer {self.api_key}"}
525
+
526
+ @staticmethod
527
+ def _path(path: str, params: dict[str, Any] | None = None) -> str:
528
+ if not params:
529
+ return path
530
+ query = urlencode({key: value for key, value in params.items() if value is not None})
531
+ if not query:
532
+ return path
533
+ separator = "&" if "?" in path else "?"
534
+ return f"{path}{separator}{query}"
535
+
326
536
  @staticmethod
327
537
  def _decode_response(response: httpx.Response) -> dict[str, Any]:
328
538
  if not response.content:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.34
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>
@@ -84,6 +86,16 @@ runtime share private <id>
84
86
  runtime start <id>
85
87
  runtime run <id> "echo hello" # one-shot foreground command only
86
88
  runtime run <id> "apt install -y nodejs" --uid 0
89
+ runtime exec <id> -- bash -lc 'for i in 1 2 3; do echo $i; sleep 1; done'
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.
92
+ runtime files ls <id> /home/ubuntu
93
+ runtime files read <id> /home/ubuntu/app.py --output app.py
94
+ runtime files write <id> /home/ubuntu/app.py --input app.py --mode 0644
95
+ runtime files mkdir <id> /home/ubuntu/data --parents
96
+ runtime files upload <id> ./local-app /home/ubuntu/app
97
+ runtime files download <id> /home/ubuntu/app ./downloaded-app
98
+ runtime files rm <id> /home/ubuntu/data --recursive
87
99
  runtime startup show <id>
88
100
  runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
89
101
  runtime startup clear <id> # low-level durable service config
@@ -123,6 +135,10 @@ app = client.create_computer(
123
135
  result = client.run_command(computer["id"], "echo hello")
124
136
  print(result["stdout"])
125
137
 
138
+ client.write_file(computer["id"], "/home/ubuntu/hello.txt", b"hello\n")
139
+ print(client.read_file(computer["id"], "/home/ubuntu/hello.txt"))
140
+ print(client.list_files(computer["id"], "/home/ubuntu"))
141
+
126
142
  # Wake a cold computer explicitly
127
143
  client.start_computer(app["id"])
128
144
 
File without changes