runtime-sdk 0.4.49__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runtime_sdk/__init__.py +18 -0
- runtime_sdk/account.py +243 -0
- runtime_sdk/arguments.py +10 -0
- runtime_sdk/auth.py +175 -0
- runtime_sdk/cli.py +985 -0
- runtime_sdk/client.py +616 -0
- runtime_sdk/completion.py +348 -0
- runtime_sdk/computers.py +578 -0
- runtime_sdk/config.py +201 -0
- runtime_sdk/console.py +118 -0
- runtime_sdk/files.py +233 -0
- runtime_sdk/github.py +229 -0
- runtime_sdk/goals.py +368 -0
- runtime_sdk/output.py +69 -0
- runtime_sdk/providers.py +422 -0
- runtime_sdk/proxy.py +600 -0
- runtime_sdk/runtime-tui.exe +0 -0
- runtime_sdk/secrets.py +155 -0
- runtime_sdk/services.py +173 -0
- runtime_sdk/ssh.py +197 -0
- runtime_sdk/terminal.py +330 -0
- runtime_sdk-0.4.49.dist-info/METADATA +273 -0
- runtime_sdk-0.4.49.dist-info/RECORD +26 -0
- runtime_sdk-0.4.49.dist-info/WHEEL +5 -0
- runtime_sdk-0.4.49.dist-info/entry_points.txt +2 -0
- runtime_sdk-0.4.49.dist-info/top_level.txt +1 -0
runtime_sdk/cli.py
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
from runtime_sdk import __version__, account, arguments, completion, computers, files as file_commands, github as github_commands, goals, output as cli_output, providers, proxy, secrets as secret_commands, services, ssh as ssh_commands
|
|
15
|
+
from runtime_sdk.auth import authenticated_client, has_credentials
|
|
16
|
+
from runtime_sdk.client import RuntimeAPIError
|
|
17
|
+
from runtime_sdk.config import (
|
|
18
|
+
DEFAULT_BASE_URL,
|
|
19
|
+
RuntimeConfig,
|
|
20
|
+
RuntimeConfigError,
|
|
21
|
+
load_config,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
# argparse #
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="runtime",
|
|
33
|
+
description="Manage RuntimeVM computers — run code, publish apps, share URLs.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--base-url", help="Override the Runtime API base URL")
|
|
36
|
+
parser.add_argument("--version", action="version", version=f"runtime-sdk {__version__}")
|
|
37
|
+
|
|
38
|
+
# Bare TTY `runtime` opens the dashboard; non-interactive use requires an
|
|
39
|
+
# explicit command so invoking the CLI can never create a computer by accident.
|
|
40
|
+
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
41
|
+
|
|
42
|
+
login = subparsers.add_parser("login", help="Sign in with WorkOS device authorization")
|
|
43
|
+
login.add_argument(
|
|
44
|
+
"--no-open",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="Print the verification URL without opening a browser",
|
|
47
|
+
)
|
|
48
|
+
login.add_argument(
|
|
49
|
+
"--api-key",
|
|
50
|
+
nargs="?",
|
|
51
|
+
const="",
|
|
52
|
+
default=None,
|
|
53
|
+
help="Import an API key explicitly (prompts if omitted in interactive mode)",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
subparsers.add_parser("whoami")
|
|
57
|
+
subparsers.add_parser("logout")
|
|
58
|
+
|
|
59
|
+
api_keys_cmd = subparsers.add_parser("api-keys", help="Manage WorkOS API keys")
|
|
60
|
+
api_keys_subparsers = api_keys_cmd.add_subparsers(dest="api_keys_action", required=True)
|
|
61
|
+
api_keys_subparsers.add_parser("list", aliases=["ls"], help="List API keys")
|
|
62
|
+
api_keys_create = api_keys_subparsers.add_parser("create", help="Create a new API key")
|
|
63
|
+
api_keys_create.add_argument("name", nargs="?", default=None, help="Human-friendly name for the new API key")
|
|
64
|
+
api_keys_revoke = api_keys_subparsers.add_parser("revoke", help="Revoke an API key")
|
|
65
|
+
api_keys_revoke.add_argument("id", nargs="?", default=None, help="API key id")
|
|
66
|
+
|
|
67
|
+
secrets_cmd = subparsers.add_parser("secrets", help="Manage account-level secret sets")
|
|
68
|
+
secrets_subparsers = secrets_cmd.add_subparsers(dest="secrets_action", required=True)
|
|
69
|
+
secrets_subparsers.add_parser("list", aliases=["ls"], help="List secret sets")
|
|
70
|
+
secrets_show = secrets_subparsers.add_parser("show", help="Show one secret set")
|
|
71
|
+
secrets_show.add_argument("slug", nargs="?", default=None, help="Secret set slug")
|
|
72
|
+
secrets_set = secrets_subparsers.add_parser("set", help="Create or replace a secret set from KEY=VALUE pairs")
|
|
73
|
+
secrets_set.add_argument("slug", nargs="?", default=None, help="Secret set slug")
|
|
74
|
+
secrets_set.add_argument("pairs", nargs="*", help="KEY=VALUE pairs")
|
|
75
|
+
secrets_set.add_argument("--proxy", action="append", default=[], metavar="KEY=HOST", help="Deliver KEY through the egress proxy and inject it only for HOST")
|
|
76
|
+
secrets_import = secrets_subparsers.add_parser("import", help="Create or replace a secret set from a .env file")
|
|
77
|
+
secrets_import.add_argument("slug", nargs="?", default=None, help="Secret set slug")
|
|
78
|
+
secrets_import.add_argument("--env-file", required=True, help="Path to .env file")
|
|
79
|
+
secrets_import.add_argument("--proxy", action="append", default=[], metavar="KEY=HOST", help="Deliver KEY through the egress proxy and inject it only for HOST")
|
|
80
|
+
secrets_delete = secrets_subparsers.add_parser("delete", aliases=["rm"], help="Delete a secret set")
|
|
81
|
+
secrets_delete.add_argument("slug", nargs="?", default=None, help="Secret set slug")
|
|
82
|
+
|
|
83
|
+
codex_cmd = subparsers.add_parser("codex", help="Manage Runtime's saved Codex credential")
|
|
84
|
+
codex_subparsers = codex_cmd.add_subparsers(dest="codex_action", required=True)
|
|
85
|
+
codex_login = codex_subparsers.add_parser("login", help="Open Codex login and save it to Runtime")
|
|
86
|
+
codex_login.add_argument("--device", action="store_true", help="Use Codex device-code login")
|
|
87
|
+
codex_subparsers.add_parser("status", help="Show whether Runtime has a saved Codex credential")
|
|
88
|
+
codex_subparsers.add_parser("logout", help="Remove Runtime's saved Codex login")
|
|
89
|
+
|
|
90
|
+
claude_cmd = subparsers.add_parser("claude", help="Manage Runtime's saved Claude Code credential")
|
|
91
|
+
claude_subparsers = claude_cmd.add_subparsers(dest="claude_action", required=True)
|
|
92
|
+
claude_subparsers.add_parser("login", help="Open Claude login and save it to Runtime")
|
|
93
|
+
claude_subparsers.add_parser("status", help="Show whether Runtime has a saved Claude Code credential")
|
|
94
|
+
claude_subparsers.add_parser("logout", help="Remove Runtime's saved Claude login")
|
|
95
|
+
|
|
96
|
+
grok_cmd = subparsers.add_parser("grok", help="Manage Runtime's saved Grok credential")
|
|
97
|
+
grok_subparsers = grok_cmd.add_subparsers(dest="grok_action", required=True)
|
|
98
|
+
grok_subparsers.add_parser("login", help="Import the local Grok login into Runtime")
|
|
99
|
+
grok_subparsers.add_parser("status", help="Show whether Runtime has a saved Grok credential")
|
|
100
|
+
grok_subparsers.add_parser("logout", help="Remove Runtime's saved Grok login")
|
|
101
|
+
|
|
102
|
+
xai_cmd = subparsers.add_parser("xai", help="Manage Runtime's saved xAI API key")
|
|
103
|
+
xai_subparsers = xai_cmd.add_subparsers(dest="xai_action", required=True)
|
|
104
|
+
xai_login = xai_subparsers.add_parser("login", help="Save an xAI API key to Runtime")
|
|
105
|
+
xai_login.add_argument("--api-key", default=None, help="xAI API key; defaults to XAI_API_KEY or an interactive prompt")
|
|
106
|
+
xai_subparsers.add_parser("status", help="Show whether Runtime has a saved xAI API key")
|
|
107
|
+
xai_subparsers.add_parser("logout", help="Remove Runtime's saved xAI login")
|
|
108
|
+
|
|
109
|
+
github_cmd = subparsers.add_parser("github", help="Manage connected GitHub App installations")
|
|
110
|
+
github_subparsers = github_cmd.add_subparsers(dest="github_action", required=True)
|
|
111
|
+
github_subparsers.add_parser("list", aliases=["ls"], help="List connected GitHub installations")
|
|
112
|
+
github_disconnect = github_subparsers.add_parser("disconnect", aliases=["rm"], help="Disconnect a GitHub installation from Runtime")
|
|
113
|
+
github_disconnect.add_argument("ref", nargs="?", default=None, help="GitHub owner or installation id")
|
|
114
|
+
|
|
115
|
+
integrate_cmd = subparsers.add_parser("integrate", help="Connect the RuntimeVM GitHub App")
|
|
116
|
+
integrate_cmd.add_argument("provider", choices=["github"])
|
|
117
|
+
integrate_cmd.add_argument("--no-open", action="store_true", help="Print the install URL instead of opening a browser")
|
|
118
|
+
integrate_cmd.add_argument("--timeout", type=int, default=180, help="Seconds to wait for the browser install to finish (default: 180)")
|
|
119
|
+
|
|
120
|
+
switch_cmd = subparsers.add_parser(
|
|
121
|
+
"switch",
|
|
122
|
+
aliases=["checkout"],
|
|
123
|
+
help="Open a GitHub branch workspace in its own computer",
|
|
124
|
+
)
|
|
125
|
+
switch_cmd.add_argument("branch", nargs="?", default=None, help="Git branch to open")
|
|
126
|
+
switch_cmd.add_argument("--repo", default=None, help="GitHub repository in owner/name format")
|
|
127
|
+
switch_cmd.add_argument("-c", "--create", action="store_true", help="Create a new branch before opening it")
|
|
128
|
+
switch_cmd.add_argument("--from", dest="base_branch", default=None, help="Base branch for -c (defaults to the repo default branch)")
|
|
129
|
+
|
|
130
|
+
create_cmd = subparsers.add_parser("create", help="Create a new computer with the starter app published by default")
|
|
131
|
+
create_cmd.add_argument(
|
|
132
|
+
"name",
|
|
133
|
+
nargs="?",
|
|
134
|
+
default=None,
|
|
135
|
+
help="Subdomain name (e.g. redsox → redsox.runruntime.dev). Random if skipped.",
|
|
136
|
+
)
|
|
137
|
+
create_cmd.add_argument("--command", dest="startup_command", help="Initial service command to save on create")
|
|
138
|
+
create_cmd.add_argument("--cwd", help="Working directory for the initial service command")
|
|
139
|
+
create_cmd.add_argument("--port", type=int, help="App port to publish for the initial service")
|
|
140
|
+
create_cmd.add_argument("--network", choices=["open", "allowlist", "none"], help="Override the account network default")
|
|
141
|
+
create_cmd.add_argument("--allow-host", action="append", default=[], help="Allowed host; repeat with --network allowlist")
|
|
142
|
+
|
|
143
|
+
network_cmd = subparsers.add_parser("network", help="Manage default and per-computer outbound network access")
|
|
144
|
+
network_subparsers = network_cmd.add_subparsers(dest="network_action", required=True)
|
|
145
|
+
network_default = network_subparsers.add_parser("default", help="Manage the default for newly created computers")
|
|
146
|
+
network_default_subparsers = network_default.add_subparsers(dest="network_default_action", required=True)
|
|
147
|
+
network_default_subparsers.add_parser("show", help="Show the default network policy")
|
|
148
|
+
network_default_subparsers.add_parser("open", help="Allow public network access by default")
|
|
149
|
+
network_default_subparsers.add_parser("none", help="Block public network access by default")
|
|
150
|
+
network_default_allowlist = network_default_subparsers.add_parser("allowlist", help="Allow only listed HTTP/HTTPS hosts by default")
|
|
151
|
+
network_default_allowlist.add_argument("hosts", nargs="+", help="Exact hosts or wildcard subdomains such as *.example.com")
|
|
152
|
+
network_show = network_subparsers.add_parser("show", help="Show one computer's network policy")
|
|
153
|
+
network_show.add_argument("id", nargs="?", default=None, help="Computer ID or name")
|
|
154
|
+
network_denials = network_subparsers.add_parser("denials", help="Show hosts recently denied by one computer's policy")
|
|
155
|
+
network_denials.add_argument("id", nargs="?", default=None, help="Computer ID or name")
|
|
156
|
+
network_set = network_subparsers.add_parser("set", help="Set one computer's network policy")
|
|
157
|
+
network_set.add_argument("id", help="Computer ID or name")
|
|
158
|
+
network_set.add_argument("mode", choices=["open", "allowlist", "none"])
|
|
159
|
+
network_set.add_argument("hosts", nargs="*", help="Hosts required for allowlist mode")
|
|
160
|
+
list_cmd = subparsers.add_parser("list", aliases=["ls"], help="List computers")
|
|
161
|
+
list_cmd.add_argument(
|
|
162
|
+
"--json",
|
|
163
|
+
action="store_true",
|
|
164
|
+
help="Write machine-readable JSON instead of opening the interactive dashboard",
|
|
165
|
+
)
|
|
166
|
+
list_cmd.add_argument(
|
|
167
|
+
"-w",
|
|
168
|
+
"--watch",
|
|
169
|
+
nargs="?",
|
|
170
|
+
const=2,
|
|
171
|
+
type=int,
|
|
172
|
+
default=None,
|
|
173
|
+
metavar="SECONDS",
|
|
174
|
+
help="Refresh the TUI or NDJSON stream every N seconds (default 2)",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
enter_cmd = subparsers.add_parser(
|
|
178
|
+
"enter",
|
|
179
|
+
help="Enter a computer's console",
|
|
180
|
+
epilog="Use `runtime exec` for exact exit codes; use `runtime enter <id> -- <cmd>` for interactive TTY commands.",
|
|
181
|
+
)
|
|
182
|
+
enter_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
183
|
+
enter_cmd.add_argument("enter_command", nargs="*", help="Command to run in the interactive terminal")
|
|
184
|
+
|
|
185
|
+
ssh_cmd = subparsers.add_parser(
|
|
186
|
+
"ssh",
|
|
187
|
+
help="SSH into a computer through Runtime",
|
|
188
|
+
epilog="Extra arguments after the computer id are passed to ssh after the destination.",
|
|
189
|
+
)
|
|
190
|
+
ssh_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
191
|
+
ssh_cmd.add_argument("ssh_args", nargs=argparse.REMAINDER, help="Remote command or ssh arguments")
|
|
192
|
+
|
|
193
|
+
info_cmd = subparsers.add_parser("info", help="Get computer details")
|
|
194
|
+
info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
195
|
+
|
|
196
|
+
url_cmd = subparsers.add_parser("url", help="Show a computer's public URL and live published port")
|
|
197
|
+
url_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
198
|
+
|
|
199
|
+
share_cmd = subparsers.add_parser("share", help="Set whether a computer is public or private")
|
|
200
|
+
share_subparsers = share_cmd.add_subparsers(dest="share_action", required=True)
|
|
201
|
+
share_public = share_subparsers.add_parser("public", help="Make a computer publicly accessible")
|
|
202
|
+
share_public.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
203
|
+
share_private = share_subparsers.add_parser("private", help="Require owner auth before browser access")
|
|
204
|
+
share_private.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
205
|
+
|
|
206
|
+
start_cmd = subparsers.add_parser("start", help="Wake a cold computer")
|
|
207
|
+
start_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
208
|
+
|
|
209
|
+
delete_cmd = subparsers.add_parser("delete", aliases=["rm"], help="Delete a computer")
|
|
210
|
+
delete_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
211
|
+
delete_cmd.add_argument(
|
|
212
|
+
"--force",
|
|
213
|
+
"-y",
|
|
214
|
+
action="store_true",
|
|
215
|
+
help="Skip the typed-name confirmation prompt",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
run_cmd = subparsers.add_parser("run", help="Run a one-shot command in a computer")
|
|
219
|
+
run_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
220
|
+
run_cmd.add_argument(
|
|
221
|
+
"run_command",
|
|
222
|
+
metavar="command",
|
|
223
|
+
nargs="?",
|
|
224
|
+
default=None,
|
|
225
|
+
help="Command to run",
|
|
226
|
+
)
|
|
227
|
+
run_cmd.add_argument("--uid", type=int, help="User ID")
|
|
228
|
+
run_cmd.add_argument("--gid", type=int, help="Group ID")
|
|
229
|
+
run_cmd.add_argument("--cwd", help="Working directory")
|
|
230
|
+
run_cmd.add_argument("--shell", help="Shell to use")
|
|
231
|
+
|
|
232
|
+
exec_cmd = subparsers.add_parser("exec", help="Stream a command in a computer")
|
|
233
|
+
exec_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
234
|
+
exec_cmd.add_argument("exec_command", nargs="*", help="Command after --")
|
|
235
|
+
exec_cmd.add_argument("--stdin", action="store_true", help="Pipe local stdin to the command")
|
|
236
|
+
exec_cmd.add_argument("--uid", type=int, help="User ID")
|
|
237
|
+
exec_cmd.add_argument("--gid", type=int, help="Group ID")
|
|
238
|
+
exec_cmd.add_argument("--cwd", help="Working directory")
|
|
239
|
+
exec_cmd.add_argument("--shell", help="Shell to use")
|
|
240
|
+
|
|
241
|
+
files_cmd = subparsers.add_parser("files", aliases=["fs", "file"], help="Read, write, list, and delete files")
|
|
242
|
+
files_subparsers = files_cmd.add_subparsers(dest="files_action", required=True)
|
|
243
|
+
files_read = files_subparsers.add_parser("read", help="Read a file to stdout or --output")
|
|
244
|
+
files_read.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
245
|
+
files_read.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
246
|
+
files_read.add_argument("--output", "-o", help="Local output file")
|
|
247
|
+
files_write = files_subparsers.add_parser("write", help="Write stdin, --content, or --input into a file")
|
|
248
|
+
files_write.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
249
|
+
files_write.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
250
|
+
files_write.add_argument("--input", "-i", help="Local input file (defaults to stdin)")
|
|
251
|
+
files_write.add_argument("--content", help="Literal text to write")
|
|
252
|
+
files_write.add_argument("--mode", help="File mode, e.g. 0644")
|
|
253
|
+
files_write.add_argument("--uid", type=int, help="User ID")
|
|
254
|
+
files_write.add_argument("--gid", type=int, help="Group ID")
|
|
255
|
+
files_list = files_subparsers.add_parser("list", aliases=["ls"], help="List a directory")
|
|
256
|
+
files_list.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
257
|
+
files_list.add_argument("path", nargs="?", default=None, help="Directory path")
|
|
258
|
+
files_stat = files_subparsers.add_parser("stat", help="Show file metadata")
|
|
259
|
+
files_stat.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
260
|
+
files_stat.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
261
|
+
files_mkdir = files_subparsers.add_parser("mkdir", help="Create a directory")
|
|
262
|
+
files_mkdir.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
263
|
+
files_mkdir.add_argument("path", nargs="?", default=None, help="Directory path")
|
|
264
|
+
files_mkdir.add_argument("--parents", "-p", action="store_true", help="Create parent directories")
|
|
265
|
+
files_mkdir.add_argument("--mode", help="Directory mode, e.g. 0755")
|
|
266
|
+
files_rm = files_subparsers.add_parser("rm", aliases=["delete"], help="Delete a file or directory")
|
|
267
|
+
files_rm.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
268
|
+
files_rm.add_argument("path", nargs="?", default=None, help="Path inside the computer")
|
|
269
|
+
files_rm.add_argument("--recursive", "-r", action="store_true", help="Delete directories recursively")
|
|
270
|
+
files_upload = files_subparsers.add_parser("upload", help="Upload a local file or directory")
|
|
271
|
+
files_upload.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
272
|
+
files_upload.add_argument("local", nargs="?", default=None, help="Local file or directory")
|
|
273
|
+
files_upload.add_argument("remote", nargs="?", default=None, help="Remote destination directory")
|
|
274
|
+
files_download = files_subparsers.add_parser("download", help="Download a remote file or directory")
|
|
275
|
+
files_download.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
276
|
+
files_download.add_argument("remote", nargs="?", default=None, help="Remote file or directory")
|
|
277
|
+
files_download.add_argument("local", nargs="?", default=None, help="Local destination directory")
|
|
278
|
+
|
|
279
|
+
publish_cmd = subparsers.add_parser(
|
|
280
|
+
"publish", help="Promote the running app on a local port into the durable public app"
|
|
281
|
+
)
|
|
282
|
+
publish_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
283
|
+
publish_cmd.add_argument("port", nargs="?", type=int, default=None, help="Local app port")
|
|
284
|
+
publish_cmd.add_argument("publish_command", nargs="*", help="Command after --")
|
|
285
|
+
|
|
286
|
+
startup_cmd = subparsers.add_parser("startup", help=argparse.SUPPRESS)
|
|
287
|
+
startup_subparsers = startup_cmd.add_subparsers(dest="startup_action", required=True)
|
|
288
|
+
startup_show = startup_subparsers.add_parser("show", help="Show durable startup config")
|
|
289
|
+
startup_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
290
|
+
startup_set = startup_subparsers.add_parser("set", help="Set durable startup config")
|
|
291
|
+
startup_set.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
292
|
+
startup_set.add_argument("--command", dest="startup_value_command", required=True, help="Durable startup command")
|
|
293
|
+
startup_set.add_argument("--cwd", help="Working directory for the durable startup command")
|
|
294
|
+
startup_set.add_argument("--port", required=True, type=int, help="App port to publish for durable startup")
|
|
295
|
+
startup_clear = startup_subparsers.add_parser("clear", help="Clear durable startup config")
|
|
296
|
+
startup_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
297
|
+
|
|
298
|
+
service_cmd = subparsers.add_parser("service", help="Manage the durable published app service")
|
|
299
|
+
service_subparsers = service_cmd.add_subparsers(dest="service_action", required=True)
|
|
300
|
+
service_run = service_subparsers.add_parser("run", help="Start and publish a durable service command")
|
|
301
|
+
service_run.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
302
|
+
service_run.add_argument("--port", required=True, type=int, help="Service port")
|
|
303
|
+
service_run.add_argument("--cwd", help="Working directory for the service command")
|
|
304
|
+
service_run.add_argument("service_command", nargs="*", help="Command after --")
|
|
305
|
+
service_show = service_subparsers.add_parser("show", help="Show the durable published app service")
|
|
306
|
+
service_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
307
|
+
service_status = service_subparsers.add_parser("status", help="Show durable service status")
|
|
308
|
+
service_status.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
309
|
+
service_logs = service_subparsers.add_parser("logs", help="Stream durable service logs")
|
|
310
|
+
service_logs.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
311
|
+
service_logs.add_argument("--follow", action="store_true", help="Follow live logs")
|
|
312
|
+
service_restart = service_subparsers.add_parser("restart", help="Restart the durable service")
|
|
313
|
+
service_restart.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
314
|
+
service_stop = service_subparsers.add_parser("stop", help="Stop the durable service but keep its config")
|
|
315
|
+
service_stop.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
316
|
+
service_clear = service_subparsers.add_parser("clear", help="Clear the durable published app service")
|
|
317
|
+
service_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
318
|
+
|
|
319
|
+
goal_cmd = subparsers.add_parser("goal", help="Watch email-triggered Codex goal runs")
|
|
320
|
+
goal_subparsers = goal_cmd.add_subparsers(dest="goal_action", required=True)
|
|
321
|
+
goal_watch = goal_subparsers.add_parser("watch", help="Stream a goal run from inside a computer")
|
|
322
|
+
goal_watch.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
323
|
+
goal_watch.add_argument("--latest", action="store_true", help="Watch the latest known goal run")
|
|
324
|
+
goal_watch.add_argument("--next", action="store_true", help="Wait for and watch the next goal run")
|
|
325
|
+
goal_watch.add_argument("--email-id", help="Watch a specific Runtime email id, e.g. eml_...")
|
|
326
|
+
goal_watch.add_argument("--timeout", type=int, default=0, help="Stop after N seconds (default: no timeout)")
|
|
327
|
+
|
|
328
|
+
proxy_cmd = subparsers.add_parser("proxy", help="Manage background local port proxies")
|
|
329
|
+
proxy_subparsers = proxy_cmd.add_subparsers(dest="proxy_command", required=True)
|
|
330
|
+
proxy_start = proxy_subparsers.add_parser("start", help="Start one or more background local port proxies")
|
|
331
|
+
proxy_start.add_argument("id", help="Computer ID (hostname)")
|
|
332
|
+
proxy_start.add_argument("ports", nargs="+", help="Port mappings like 5432 or 15432:5432")
|
|
333
|
+
proxy_subparsers.add_parser("ls", help="List background local port proxies")
|
|
334
|
+
proxy_stop = proxy_subparsers.add_parser("stop", help="Stop one or more background local port proxies")
|
|
335
|
+
proxy_stop.add_argument("local_ports", nargs="*", type=int, help="Local ports to stop")
|
|
336
|
+
proxy_stop.add_argument("--all", action="store_true", help="Stop all running proxies")
|
|
337
|
+
|
|
338
|
+
proxy_agent = subparsers.add_parser("_proxy_agent", help=argparse.SUPPRESS)
|
|
339
|
+
proxy_agent.add_argument("--base-url", required=True)
|
|
340
|
+
proxy_agent.add_argument("--computer-id", required=True)
|
|
341
|
+
proxy_agent.add_argument("--display-name", required=True)
|
|
342
|
+
proxy_agent.add_argument("--local-port", type=int, required=True)
|
|
343
|
+
proxy_agent.add_argument("--remote-port", type=int, required=True)
|
|
344
|
+
|
|
345
|
+
help_cmd = subparsers.add_parser("help", help="Show extended help for a topic")
|
|
346
|
+
help_cmd.add_argument("topic", nargs="?", default=None, help="Topic to show (computer, share, account, all)")
|
|
347
|
+
|
|
348
|
+
completion_cmd = subparsers.add_parser(
|
|
349
|
+
"completion", help="Print shell completion script"
|
|
350
|
+
)
|
|
351
|
+
completion_cmd.add_argument(
|
|
352
|
+
"shell", choices=["bash", "zsh", "fish", "powershell"], help="Target shell"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Internal commands used by completion and the packaged TUI.
|
|
356
|
+
subparsers.add_parser("_slugs", help=argparse.SUPPRESS)
|
|
357
|
+
subparsers.add_parser("_tui_state", help=argparse.SUPPRESS)
|
|
358
|
+
|
|
359
|
+
parser.format_help = lambda: _format_top_help() # type: ignore[assignment]
|
|
360
|
+
return parser
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
_HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
|
|
364
|
+
(
|
|
365
|
+
"Computers",
|
|
366
|
+
(
|
|
367
|
+
("create [name]", "Create a new computer (starter app published by default)"),
|
|
368
|
+
("list, ls", "Open the computer dashboard (--json for scripts; -w refreshes)"),
|
|
369
|
+
("info [id]", "Show computer details"),
|
|
370
|
+
("url [id]", "Show a computer's public URL and live published port"),
|
|
371
|
+
("start [id]", "Wake a cold computer"),
|
|
372
|
+
("enter [id] [-- <cmd>]", "Enter a computer's console or interactive TTY command"),
|
|
373
|
+
("ssh [id] [-- <cmd>]", "SSH into a computer through Runtime's authenticated tunnel"),
|
|
374
|
+
("run [id] <cmd>", "Run a one-shot command"),
|
|
375
|
+
("exec [id] -- <cmd>", "Stream stdout/stderr and optionally pipe stdin"),
|
|
376
|
+
("publish [id] <port> -- <cmd>", "Run and publish a durable service command"),
|
|
377
|
+
("delete, rm [id]", "Delete a computer (typed-name confirm; --force / -y to skip)"),
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
(
|
|
381
|
+
"Files",
|
|
382
|
+
(
|
|
383
|
+
("files read [id] <path>", "Read a file to stdout or --output"),
|
|
384
|
+
("files write [id] <path>", "Write stdin, --input, or --content to a file"),
|
|
385
|
+
("files ls/stat/mkdir/rm", "List, inspect, create, and delete paths"),
|
|
386
|
+
),
|
|
387
|
+
),
|
|
388
|
+
(
|
|
389
|
+
"Sharing",
|
|
390
|
+
(
|
|
391
|
+
("share public [id]", "Make a computer publicly accessible"),
|
|
392
|
+
("share private [id]", "Require owner auth before browser access"),
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
(
|
|
396
|
+
"Network",
|
|
397
|
+
(
|
|
398
|
+
(
|
|
399
|
+
"network default show/open/none",
|
|
400
|
+
"Manage the network default for future computers",
|
|
401
|
+
),
|
|
402
|
+
(
|
|
403
|
+
"network default allowlist <hosts…>",
|
|
404
|
+
"Allow only listed HTTP/HTTPS hosts by default",
|
|
405
|
+
),
|
|
406
|
+
("network show [id]", "Show one computer's network policy"),
|
|
407
|
+
("network denials [id]", "Show policy-denied hosts since the egress service started"),
|
|
408
|
+
(
|
|
409
|
+
"network set <id> <mode> [hosts…]",
|
|
410
|
+
"Set one computer to open, allowlist, or none",
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
(
|
|
415
|
+
"Startup & service",
|
|
416
|
+
(
|
|
417
|
+
("service run [id] --port N -- <cmd>", "Run and publish a durable service command"),
|
|
418
|
+
("service status [id]", "Show durable service status"),
|
|
419
|
+
("service logs [id] --follow", "Stream service logs"),
|
|
420
|
+
("service restart [id]", "Restart the durable service"),
|
|
421
|
+
("service stop [id]", "Stop service but keep config"),
|
|
422
|
+
("service clear [id]", "Clear the durable published app service"),
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
(
|
|
426
|
+
"Goals",
|
|
427
|
+
(
|
|
428
|
+
("goal watch [id] --next", "Stream the next email-triggered Codex goal"),
|
|
429
|
+
("goal watch [id] --latest", "Stream the latest email-triggered Codex goal"),
|
|
430
|
+
),
|
|
431
|
+
),
|
|
432
|
+
(
|
|
433
|
+
"Proxy",
|
|
434
|
+
(
|
|
435
|
+
("proxy start <id> <ports…>", "Forward local → remote ports (e.g. 5432 or 15432:5432)"),
|
|
436
|
+
("proxy ls", "List running proxies"),
|
|
437
|
+
("proxy stop [ports…] [--all]", "Stop proxies (by local port or all)"),
|
|
438
|
+
),
|
|
439
|
+
),
|
|
440
|
+
(
|
|
441
|
+
"GitHub",
|
|
442
|
+
(
|
|
443
|
+
("switch, checkout [branch]", "Open a GitHub branch workspace in its own computer"),
|
|
444
|
+
("integrate github", "Install the RuntimeVM GitHub App and wait for completion"),
|
|
445
|
+
("github list", "List connected GitHub installations"),
|
|
446
|
+
("github disconnect, rm [owner|id]", "Disconnect one GitHub installation"),
|
|
447
|
+
),
|
|
448
|
+
),
|
|
449
|
+
(
|
|
450
|
+
"Account",
|
|
451
|
+
(
|
|
452
|
+
("login", "Sign in with WorkOS; use --api-key for headless access"),
|
|
453
|
+
("whoami", "Show current account"),
|
|
454
|
+
("logout", "Revoke the current credential and remove local config"),
|
|
455
|
+
("api-keys list/create/revoke", "Manage WorkOS API keys"),
|
|
456
|
+
("secrets list/show/set/import/rm", "Manage account-level secret sets"),
|
|
457
|
+
("codex login/status/logout", "Log Codex into Runtime computers"),
|
|
458
|
+
("claude login/status/logout", "Log Claude Code into Runtime computers"),
|
|
459
|
+
("grok login/status/logout", "Log Grok into Runtime computers"),
|
|
460
|
+
("xai login/status/logout", "Log xAI into Runtime computers"),
|
|
461
|
+
),
|
|
462
|
+
),
|
|
463
|
+
(
|
|
464
|
+
"Shell",
|
|
465
|
+
(
|
|
466
|
+
("help [topic]", "Show extended help — topics: computer, share, account, all"),
|
|
467
|
+
("completion <bash|zsh|fish>", "Print shell completion script"),
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
_HELP_EXAMPLES: tuple[str, ...] = (
|
|
474
|
+
"runtime # interactive dashboard",
|
|
475
|
+
"runtime list # open the computer dashboard",
|
|
476
|
+
"runtime list --json # stable JSON for scripts and agents",
|
|
477
|
+
"runtime ls -w # live-updating list",
|
|
478
|
+
"runtime create mybox --command 'npm start' --cwd /app --port 3000",
|
|
479
|
+
"runtime share private mybox # require owner auth",
|
|
480
|
+
"runtime ssh mybox # SSH through Runtime without exposing VM IPs",
|
|
481
|
+
"runtime integrate github # open GitHub and wait for the install to finish",
|
|
482
|
+
"runtime github list # show connected GitHub orgs/accounts",
|
|
483
|
+
"runtime codex login # open Codex login and save it to Runtime",
|
|
484
|
+
"runtime claude login # open Claude login and save it to Runtime",
|
|
485
|
+
"runtime grok login # import the local Grok login into Runtime",
|
|
486
|
+
"runtime xai login # save an xAI API key to Runtime",
|
|
487
|
+
"runtime proxy start mybox 5432 # forward localhost:5432 → mybox:5432",
|
|
488
|
+
"runtime completion zsh > ~/.zsh/completions/_runtime",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _format_top_help() -> str:
|
|
493
|
+
lines: list[str] = []
|
|
494
|
+
lines.append("runtime — manage RuntimeVM computers")
|
|
495
|
+
lines.append("")
|
|
496
|
+
lines.append("usage: runtime [--base-url URL] [--version] [-h] <command> [args…]")
|
|
497
|
+
lines.append("")
|
|
498
|
+
lines.append("Global options:")
|
|
499
|
+
lines.append(" --base-url URL Override the Runtime API base URL")
|
|
500
|
+
lines.append(" --version Show the CLI version")
|
|
501
|
+
lines.append(" -h, --help Show this help")
|
|
502
|
+
lines.append("")
|
|
503
|
+
left_width = 0
|
|
504
|
+
for _, rows in _HELP_SECTIONS:
|
|
505
|
+
for name, _ in rows:
|
|
506
|
+
left_width = max(left_width, len(name))
|
|
507
|
+
left_width = min(left_width, 38)
|
|
508
|
+
for title, rows in _HELP_SECTIONS:
|
|
509
|
+
lines.append(f"{title}:")
|
|
510
|
+
for name, desc in rows:
|
|
511
|
+
padded = name if len(name) >= left_width else name + " " * (left_width - len(name))
|
|
512
|
+
lines.append(f" {padded} {desc}")
|
|
513
|
+
lines.append("")
|
|
514
|
+
lines.append("Examples:")
|
|
515
|
+
for example in _HELP_EXAMPLES:
|
|
516
|
+
lines.append(f" {example}")
|
|
517
|
+
lines.append("")
|
|
518
|
+
lines.append("Run `runtime <command> --help` for flags and examples on one command.")
|
|
519
|
+
lines.append("Run `runtime help <topic>` for long-form docs.")
|
|
520
|
+
return "\n".join(lines) + "\n"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def main(argv: list[str] | None = None) -> int:
|
|
524
|
+
_configure_windows_output()
|
|
525
|
+
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
|
526
|
+
parser = build_parser()
|
|
527
|
+
args = parser.parse_args(raw_argv)
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
enter_id: str | None = None
|
|
531
|
+
enter_command: list[str] | None = None
|
|
532
|
+
if args.command == "enter":
|
|
533
|
+
enter_id, enter_command, enter_err = _parse_enter_invocation(raw_argv, args.id, args.enter_command)
|
|
534
|
+
if enter_err:
|
|
535
|
+
return cli_output.report_error(enter_err)
|
|
536
|
+
|
|
537
|
+
config = load_config()
|
|
538
|
+
if args.base_url:
|
|
539
|
+
config.base_url = args.base_url
|
|
540
|
+
elif os.environ.get("RUNTIME_BASE_URL"):
|
|
541
|
+
config.base_url = str(os.environ.get("RUNTIME_BASE_URL"))
|
|
542
|
+
else:
|
|
543
|
+
config.base_url = config.configured_base_url or DEFAULT_BASE_URL
|
|
544
|
+
|
|
545
|
+
# Bare TTY `runtime` owns the single interactive surface. Requiring an
|
|
546
|
+
# explicit non-TTY command prevents accidental resource creation.
|
|
547
|
+
if args.command is None:
|
|
548
|
+
if cli_output.interactive():
|
|
549
|
+
return _run_tui(config)
|
|
550
|
+
return cli_output.report_error("command is required; run runtime --help")
|
|
551
|
+
|
|
552
|
+
if args.command == "login":
|
|
553
|
+
return account.handle_login(
|
|
554
|
+
config, api_key=args.api_key, no_open=args.no_open
|
|
555
|
+
)
|
|
556
|
+
if args.command == "whoami":
|
|
557
|
+
return account.handle_whoami(config)
|
|
558
|
+
if args.command == "logout":
|
|
559
|
+
return account.handle_logout(config)
|
|
560
|
+
if args.command == "api-keys":
|
|
561
|
+
if args.api_keys_action in ("list", "ls"):
|
|
562
|
+
return account.handle_api_keys_list(config)
|
|
563
|
+
if args.api_keys_action == "create":
|
|
564
|
+
return account.handle_api_keys_create(config, args.name)
|
|
565
|
+
if args.api_keys_action == "revoke":
|
|
566
|
+
return account.handle_api_keys_revoke(config, args.id)
|
|
567
|
+
return cli_output.report_error("unknown api-keys command")
|
|
568
|
+
if args.command == "secrets":
|
|
569
|
+
if args.secrets_action in ("list", "ls"):
|
|
570
|
+
return secret_commands.handle_secrets_list(config)
|
|
571
|
+
if args.secrets_action == "show":
|
|
572
|
+
return secret_commands.handle_secrets_show(config, args.slug)
|
|
573
|
+
if args.secrets_action == "set":
|
|
574
|
+
return secret_commands.handle_secrets_set(config, args.slug, args.pairs, args.proxy)
|
|
575
|
+
if args.secrets_action == "import":
|
|
576
|
+
return secret_commands.handle_secrets_import(config, args.slug, args.env_file, args.proxy)
|
|
577
|
+
if args.secrets_action in ("delete", "rm"):
|
|
578
|
+
return secret_commands.handle_secrets_delete(config, args.slug)
|
|
579
|
+
return cli_output.report_error("unknown secrets command")
|
|
580
|
+
if args.command == "codex":
|
|
581
|
+
if args.codex_action == "login":
|
|
582
|
+
return providers.handle_codex_login(config, device=args.device)
|
|
583
|
+
if args.codex_action == "status":
|
|
584
|
+
return providers.handle_codex_status(config)
|
|
585
|
+
if args.codex_action == "logout":
|
|
586
|
+
return providers.handle_codex_logout(config)
|
|
587
|
+
return cli_output.report_error("unknown codex command")
|
|
588
|
+
if args.command == "claude":
|
|
589
|
+
if args.claude_action == "login":
|
|
590
|
+
return providers.handle_claude_login(config)
|
|
591
|
+
if args.claude_action == "status":
|
|
592
|
+
return providers.handle_claude_status(config)
|
|
593
|
+
if args.claude_action == "logout":
|
|
594
|
+
return providers.handle_claude_logout(config)
|
|
595
|
+
return cli_output.report_error("unknown claude command")
|
|
596
|
+
if args.command == "grok":
|
|
597
|
+
if args.grok_action == "login":
|
|
598
|
+
return providers.handle_grok_login(config)
|
|
599
|
+
if args.grok_action == "status":
|
|
600
|
+
return providers.handle_grok_status(config)
|
|
601
|
+
if args.grok_action == "logout":
|
|
602
|
+
return providers.handle_grok_logout(config)
|
|
603
|
+
return cli_output.report_error("unknown grok command")
|
|
604
|
+
if args.command == "xai":
|
|
605
|
+
if args.xai_action == "login":
|
|
606
|
+
return providers.handle_xai_login(config, api_key=args.api_key)
|
|
607
|
+
if args.xai_action == "status":
|
|
608
|
+
return providers.handle_xai_status(config)
|
|
609
|
+
if args.xai_action == "logout":
|
|
610
|
+
return providers.handle_xai_logout(config)
|
|
611
|
+
return cli_output.report_error("unknown xai command")
|
|
612
|
+
if args.command == "github":
|
|
613
|
+
if args.github_action in ("list", "ls"):
|
|
614
|
+
return github_commands.handle_github_list(config)
|
|
615
|
+
if args.github_action in ("disconnect", "rm"):
|
|
616
|
+
return github_commands.handle_github_disconnect(config, args.ref)
|
|
617
|
+
return cli_output.report_error("unknown github command")
|
|
618
|
+
if args.command == "integrate":
|
|
619
|
+
return github_commands.handle_integrate_github(config, args.provider, no_open=args.no_open, timeout_seconds=args.timeout)
|
|
620
|
+
if args.command in ("switch", "checkout"):
|
|
621
|
+
return github_commands.handle_switch(
|
|
622
|
+
config,
|
|
623
|
+
getattr(args, "repo", None),
|
|
624
|
+
getattr(args, "branch", None),
|
|
625
|
+
create_branch=getattr(args, "create", False),
|
|
626
|
+
base_branch=getattr(args, "base_branch", None),
|
|
627
|
+
)
|
|
628
|
+
if args.command == "create":
|
|
629
|
+
return computers.handle_create(
|
|
630
|
+
config,
|
|
631
|
+
args.name,
|
|
632
|
+
getattr(args, "startup_command", None),
|
|
633
|
+
getattr(args, "cwd", None),
|
|
634
|
+
getattr(args, "port", None),
|
|
635
|
+
getattr(args, "network", None),
|
|
636
|
+
getattr(args, "allow_host", []),
|
|
637
|
+
)
|
|
638
|
+
if args.command == "network":
|
|
639
|
+
return computers.handle_network(config, args)
|
|
640
|
+
if args.command in ("list", "ls"):
|
|
641
|
+
return handle_list(
|
|
642
|
+
config,
|
|
643
|
+
watch=getattr(args, "watch", None),
|
|
644
|
+
json_output=getattr(args, "json", False),
|
|
645
|
+
)
|
|
646
|
+
if args.command == "info":
|
|
647
|
+
return computers.handle_info(config, args.id)
|
|
648
|
+
if args.command == "url":
|
|
649
|
+
return computers.handle_url(config, args.id)
|
|
650
|
+
if args.command == "share":
|
|
651
|
+
return computers.handle_share(config, args.share_action, args.id)
|
|
652
|
+
if args.command == "start":
|
|
653
|
+
return computers.handle_start(config, args.id)
|
|
654
|
+
if args.command == "enter":
|
|
655
|
+
return computers.handle_enter(config, enter_id, enter_command)
|
|
656
|
+
if args.command == "ssh":
|
|
657
|
+
return ssh_commands.handle_ssh(config, args.id, args.ssh_args)
|
|
658
|
+
if args.command in ("delete", "rm"):
|
|
659
|
+
return computers.handle_delete(config, args.id, force=getattr(args, "force", False))
|
|
660
|
+
if args.command == "run":
|
|
661
|
+
return computers.handle_run(
|
|
662
|
+
config, args.id, args.run_command, args.uid, args.gid, args.cwd, args.shell
|
|
663
|
+
)
|
|
664
|
+
if args.command == "exec":
|
|
665
|
+
return handle_exec_stream(config, args.id, args.exec_command, args.stdin, args.uid, args.gid, args.cwd, args.shell)
|
|
666
|
+
if args.command in ("files", "fs", "file"):
|
|
667
|
+
return file_commands.handle_files(config, args)
|
|
668
|
+
if args.command == "publish":
|
|
669
|
+
return computers.handle_publish(config, args.id, args.port, args.publish_command)
|
|
670
|
+
if args.command == "startup":
|
|
671
|
+
if args.startup_action == "show":
|
|
672
|
+
return services.handle_startup_show(config, args.id)
|
|
673
|
+
if args.startup_action == "set":
|
|
674
|
+
return services.handle_startup_set(config, args.id, args.startup_value_command, args.cwd, args.port)
|
|
675
|
+
if args.startup_action == "clear":
|
|
676
|
+
return services.handle_startup_clear(config, args.id)
|
|
677
|
+
return cli_output.report_error("unknown startup command")
|
|
678
|
+
if args.command == "service":
|
|
679
|
+
if args.service_action == "run":
|
|
680
|
+
return services.handle_service_run(config, args.id, args.port, args.cwd, args.service_command)
|
|
681
|
+
if args.service_action in ("show", "status"):
|
|
682
|
+
return services.handle_service_show(config, args.id)
|
|
683
|
+
if args.service_action == "logs":
|
|
684
|
+
return services.handle_service_logs(config, args.id, args.follow)
|
|
685
|
+
if args.service_action == "restart":
|
|
686
|
+
return services.handle_service_restart(config, args.id)
|
|
687
|
+
if args.service_action == "stop":
|
|
688
|
+
return services.handle_service_stop(config, args.id)
|
|
689
|
+
if args.service_action == "clear":
|
|
690
|
+
return services.handle_service_clear(config, args.id)
|
|
691
|
+
return cli_output.report_error("unknown service command")
|
|
692
|
+
if args.command == "goal":
|
|
693
|
+
if args.goal_action == "watch":
|
|
694
|
+
return goals.handle_goal_watch(config, args.id, latest=args.latest, next_goal=args.next, email_id=args.email_id, timeout=args.timeout)
|
|
695
|
+
return cli_output.report_error("unknown goal command")
|
|
696
|
+
if args.command == "proxy":
|
|
697
|
+
if args.proxy_command == "start":
|
|
698
|
+
return proxy.handle_proxy_start(config, args.id, args.ports)
|
|
699
|
+
if args.proxy_command == "ls":
|
|
700
|
+
return proxy.handle_proxy_ls(config)
|
|
701
|
+
if args.proxy_command == "stop":
|
|
702
|
+
return proxy.handle_proxy_stop(config, args.local_ports, stop_all=args.all)
|
|
703
|
+
return cli_output.report_error("unknown proxy command")
|
|
704
|
+
if args.command == "help":
|
|
705
|
+
return handle_help(args.topic)
|
|
706
|
+
if args.command == "completion":
|
|
707
|
+
return completion.handle_completion(args.shell)
|
|
708
|
+
if args.command == "_slugs":
|
|
709
|
+
return handle_slugs(config)
|
|
710
|
+
if args.command == "_tui_state":
|
|
711
|
+
return account.handle_tui_state(config)
|
|
712
|
+
if args.command == "_proxy_agent":
|
|
713
|
+
return proxy.run_proxy_agent(
|
|
714
|
+
config,
|
|
715
|
+
args.computer_id,
|
|
716
|
+
args.display_name,
|
|
717
|
+
args.local_port,
|
|
718
|
+
args.remote_port,
|
|
719
|
+
)
|
|
720
|
+
except RuntimeAPIError as exc:
|
|
721
|
+
return cli_output.report_error(str(exc), status_code=exc.status_code)
|
|
722
|
+
except RuntimeConfigError as exc:
|
|
723
|
+
return cli_output.report_error(str(exc))
|
|
724
|
+
except KeyboardInterrupt:
|
|
725
|
+
if cli_output.interactive():
|
|
726
|
+
print("\ncancelled", file=sys.stderr)
|
|
727
|
+
return 130
|
|
728
|
+
return cli_output.report_error("cancelled")
|
|
729
|
+
|
|
730
|
+
return cli_output.report_error("unknown command")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _configure_windows_output() -> None:
|
|
734
|
+
if sys.platform != "win32":
|
|
735
|
+
return
|
|
736
|
+
for stream in (sys.stdout, sys.stderr):
|
|
737
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
738
|
+
if reconfigure is not None:
|
|
739
|
+
reconfigure(encoding="utf-8")
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# --------------------------------------------------------------------------- #
|
|
743
|
+
# Shared helpers #
|
|
744
|
+
# --------------------------------------------------------------------------- #
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _parse_enter_invocation(raw_argv: list[str], parsed_id: str | None, parsed_command: list[str]) -> tuple[str | None, list[str], str | None]:
|
|
748
|
+
enter_index = raw_argv.index("enter")
|
|
749
|
+
enter_args = raw_argv[enter_index + 1:]
|
|
750
|
+
if "--" not in enter_args:
|
|
751
|
+
if parsed_command:
|
|
752
|
+
return None, [], "enter command must be separated from the computer id with --"
|
|
753
|
+
return parsed_id, parsed_command, None
|
|
754
|
+
|
|
755
|
+
separator_index = enter_args.index("--")
|
|
756
|
+
before_separator = enter_args[:separator_index]
|
|
757
|
+
if len(before_separator) > 1:
|
|
758
|
+
return None, [], "enter accepts at most one computer id before --"
|
|
759
|
+
computer_id = before_separator[0] if before_separator else None
|
|
760
|
+
return computer_id, enter_args[separator_index + 1:], None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _run_tui(config: RuntimeConfig, *, refresh_seconds: int | None = None) -> int:
|
|
764
|
+
binary = _tui_binary_path()
|
|
765
|
+
if not binary.is_file():
|
|
766
|
+
return cli_output.report_error(
|
|
767
|
+
"Runtime's interactive interface requires a native macOS, Linux, or Windows wheel; "
|
|
768
|
+
"scriptable subcommands remain available"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
with tempfile.TemporaryDirectory(prefix="runtime-tui-") as directory:
|
|
772
|
+
action_path = Path(directory) / "action.json"
|
|
773
|
+
while True:
|
|
774
|
+
with contextlib.suppress(FileNotFoundError):
|
|
775
|
+
action_path.unlink()
|
|
776
|
+
command = [
|
|
777
|
+
str(binary),
|
|
778
|
+
"--runtime-bin",
|
|
779
|
+
sys.argv[0],
|
|
780
|
+
"--action-file",
|
|
781
|
+
str(action_path),
|
|
782
|
+
"--base-url",
|
|
783
|
+
config.base_url,
|
|
784
|
+
]
|
|
785
|
+
if refresh_seconds is not None:
|
|
786
|
+
command.extend(["--refresh-ms", str(refresh_seconds * 1000)])
|
|
787
|
+
result = subprocess.run(command, check=False)
|
|
788
|
+
if result.returncode != 0:
|
|
789
|
+
return result.returncode
|
|
790
|
+
if not action_path.exists():
|
|
791
|
+
return 0
|
|
792
|
+
|
|
793
|
+
action = _read_tui_action(action_path)
|
|
794
|
+
code = main(["--base-url", config.base_url, *action])
|
|
795
|
+
if code != 0:
|
|
796
|
+
return code
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _tui_binary_path() -> Path:
|
|
800
|
+
default_binary = "runtime-tui.exe" if sys.platform == "win32" else "runtime-tui"
|
|
801
|
+
return Path(
|
|
802
|
+
os.environ.get("RUNTIME_TUI_BIN") or Path(__file__).with_name(default_binary)
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _read_tui_action(path: Path) -> list[str]:
|
|
807
|
+
try:
|
|
808
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
809
|
+
except (OSError, ValueError) as exc:
|
|
810
|
+
raise RuntimeConfigError(f"invalid Runtime interface action: {exc}") from exc
|
|
811
|
+
action = payload.get("argv") if isinstance(payload, dict) else None
|
|
812
|
+
if not isinstance(action, list) or not all(isinstance(value, str) for value in action):
|
|
813
|
+
raise RuntimeConfigError("invalid Runtime interface action")
|
|
814
|
+
allowed = (
|
|
815
|
+
action in (
|
|
816
|
+
["codex", "login"],
|
|
817
|
+
["claude", "login"],
|
|
818
|
+
["grok", "login"],
|
|
819
|
+
["xai", "login"],
|
|
820
|
+
["integrate", "github"],
|
|
821
|
+
)
|
|
822
|
+
or (len(action) == 2 and action[0] == "enter" and bool(action[1].strip()))
|
|
823
|
+
)
|
|
824
|
+
if not allowed:
|
|
825
|
+
raise RuntimeConfigError("unsupported Runtime interface action")
|
|
826
|
+
return action
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# --------------------------------------------------------------------------- #
|
|
830
|
+
# Authentication commands #
|
|
831
|
+
# --------------------------------------------------------------------------- #
|
|
832
|
+
|
|
833
|
+
def handle_list(
|
|
834
|
+
config: RuntimeConfig,
|
|
835
|
+
*,
|
|
836
|
+
watch: int | None = None,
|
|
837
|
+
json_output: bool = False,
|
|
838
|
+
) -> int:
|
|
839
|
+
if watch is not None and watch < 1:
|
|
840
|
+
return cli_output.report_error("--watch interval must be at least 1 second")
|
|
841
|
+
|
|
842
|
+
if cli_output.interactive() and not json_output:
|
|
843
|
+
return _run_tui(config, refresh_seconds=watch)
|
|
844
|
+
|
|
845
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
846
|
+
return err
|
|
847
|
+
|
|
848
|
+
if watch is not None:
|
|
849
|
+
if not json_output:
|
|
850
|
+
return cli_output.report_error("--watch requires an interactive terminal or --json")
|
|
851
|
+
return _watch_list(config, watch)
|
|
852
|
+
|
|
853
|
+
client = authenticated_client(config)
|
|
854
|
+
return cli_output.report_success(client.list_computers())
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _watch_list(config: RuntimeConfig, interval: int) -> int:
|
|
858
|
+
client = authenticated_client(config)
|
|
859
|
+
print(f"watching every {interval}s; press Ctrl+C to exit", file=sys.stderr)
|
|
860
|
+
try:
|
|
861
|
+
while True:
|
|
862
|
+
cli_output.write_json({"success": True, **client.list_computers()})
|
|
863
|
+
time.sleep(interval)
|
|
864
|
+
except KeyboardInterrupt:
|
|
865
|
+
print("watch stopped", file=sys.stderr)
|
|
866
|
+
return 0
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def handle_exec_stream(
|
|
870
|
+
config: RuntimeConfig,
|
|
871
|
+
computer_id: str | None,
|
|
872
|
+
command_parts: list[str] | None,
|
|
873
|
+
pipe_stdin: bool,
|
|
874
|
+
uid: int | None,
|
|
875
|
+
gid: int | None,
|
|
876
|
+
cwd: str | None,
|
|
877
|
+
shell: str | None,
|
|
878
|
+
) -> int:
|
|
879
|
+
if (err := cli_output.require_auth(config)) is not None:
|
|
880
|
+
return err
|
|
881
|
+
if not computer_id:
|
|
882
|
+
return cli_output.report_error("computer id is required")
|
|
883
|
+
command = arguments.command_from_remainder(command_parts)
|
|
884
|
+
if not command:
|
|
885
|
+
return cli_output.report_error("exec requires -- <command...>")
|
|
886
|
+
stdin_data = None
|
|
887
|
+
if pipe_stdin:
|
|
888
|
+
stdin_buffer = getattr(sys.stdin, "buffer", None)
|
|
889
|
+
stdin_data = stdin_buffer.read() if stdin_buffer is not None else sys.stdin.read().encode()
|
|
890
|
+
client = authenticated_client(config)
|
|
891
|
+
computer_id = computers.resolve_computer_ref(client, computer_id)
|
|
892
|
+
exit_code = 0
|
|
893
|
+
for frame in client.stream_command(computer_id, command, stdin=stdin_data, uid=uid, gid=gid, cwd=cwd, shell=shell):
|
|
894
|
+
stdout = frame.get("stdout") or frame.get("data") or ""
|
|
895
|
+
stderr = frame.get("stderr") or ""
|
|
896
|
+
error = frame.get("error") or ""
|
|
897
|
+
if stdout:
|
|
898
|
+
print(stdout, end="")
|
|
899
|
+
if stderr:
|
|
900
|
+
print(stderr, end="", file=sys.stderr)
|
|
901
|
+
if error:
|
|
902
|
+
print(error, file=sys.stderr)
|
|
903
|
+
exit_code = 1
|
|
904
|
+
if frame.get("type") == "exit" or frame.get("exit_code") is not None:
|
|
905
|
+
try:
|
|
906
|
+
exit_code = int(frame.get("exit_code") or 0)
|
|
907
|
+
except (TypeError, ValueError):
|
|
908
|
+
exit_code = 1
|
|
909
|
+
return exit_code
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
# --------------------------------------------------------------------------- #
|
|
913
|
+
# Help browser + shell completion #
|
|
914
|
+
# --------------------------------------------------------------------------- #
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
_HELP_TOPICS: dict[str, tuple[str, ...]] = {
|
|
918
|
+
"computer": ("Computers",),
|
|
919
|
+
"computers": ("Computers",),
|
|
920
|
+
"ssh": ("Computers",),
|
|
921
|
+
"share": ("Sharing",),
|
|
922
|
+
"sharing": ("Sharing",),
|
|
923
|
+
"network": ("Network",),
|
|
924
|
+
"service": ("Startup & service",),
|
|
925
|
+
"goal": ("Goals",),
|
|
926
|
+
"goals": ("Goals",),
|
|
927
|
+
"proxy": ("Proxy",),
|
|
928
|
+
"github": ("GitHub",),
|
|
929
|
+
"codex": ("Account",),
|
|
930
|
+
"claude": ("Account",),
|
|
931
|
+
"xai": ("Account",),
|
|
932
|
+
"account": ("Account",),
|
|
933
|
+
"shell": ("Shell",),
|
|
934
|
+
"all": tuple(title for title, _ in _HELP_SECTIONS),
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def handle_help(topic: str | None) -> int:
|
|
939
|
+
if topic is None:
|
|
940
|
+
sys.stdout.write(_format_top_help())
|
|
941
|
+
return 0
|
|
942
|
+
key = topic.strip().lower()
|
|
943
|
+
if key not in _HELP_TOPICS:
|
|
944
|
+
matches = computers.close_matches(key, list(_HELP_TOPICS))
|
|
945
|
+
if matches:
|
|
946
|
+
return cli_output.report_error(f"unknown help topic {topic!r}. Did you mean {', '.join(repr(m) for m in matches)}?")
|
|
947
|
+
return cli_output.report_error(f"unknown help topic {topic!r}. Try: {', '.join(sorted(_HELP_TOPICS))}")
|
|
948
|
+
|
|
949
|
+
wanted = set(_HELP_TOPICS[key])
|
|
950
|
+
lines: list[str] = []
|
|
951
|
+
for title, rows in _HELP_SECTIONS:
|
|
952
|
+
if title not in wanted:
|
|
953
|
+
continue
|
|
954
|
+
lines.append(f"{title}:")
|
|
955
|
+
left = max((len(name) for name, _ in rows), default=0)
|
|
956
|
+
for name, desc in rows:
|
|
957
|
+
padded = name + " " * max(0, left - len(name))
|
|
958
|
+
lines.append(f" {padded} {desc}")
|
|
959
|
+
lines.append("")
|
|
960
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
961
|
+
return 0
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def handle_slugs(config: RuntimeConfig) -> int:
|
|
965
|
+
"""Print one slug per line. Used by shell completion scripts. Silent on errors."""
|
|
966
|
+
if not has_credentials(config):
|
|
967
|
+
return 0
|
|
968
|
+
try:
|
|
969
|
+
client = authenticated_client(config)
|
|
970
|
+
payload = client.list_computers()
|
|
971
|
+
except Exception:
|
|
972
|
+
return 0
|
|
973
|
+
computers = payload.get("computers") or []
|
|
974
|
+
if not isinstance(computers, list):
|
|
975
|
+
return 0
|
|
976
|
+
for c in computers:
|
|
977
|
+
if isinstance(c, dict):
|
|
978
|
+
slug = str(c.get("slug") or "").strip()
|
|
979
|
+
if slug:
|
|
980
|
+
sys.stdout.write(slug + "\n")
|
|
981
|
+
return 0
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
if __name__ == "__main__":
|
|
985
|
+
raise SystemExit(main())
|