runtime-sdk 0.4.24__tar.gz → 0.4.26__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.24
3
+ Version: 0.4.26
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
@@ -66,8 +66,9 @@ runtime login --api-key rt_live_...
66
66
  runtime api-keys list
67
67
  runtime api-keys create "my laptop"
68
68
  runtime api-keys revoke 123
69
- runtime integrations list
70
- runtime integrations connect github
69
+ runtime integrate github
70
+ runtime github list
71
+ runtime github disconnect acme
71
72
 
72
73
  # Computers
73
74
  runtime switch --repo owner/repo feature/login # open or create the branch workspace computer
@@ -170,13 +171,15 @@ that ad-hoc process is not.
170
171
  between parallel tasks without local worktrees.
171
172
 
172
173
  ```bash
173
- runtime integrations connect github
174
+ runtime integrate github
175
+ runtime github list
174
176
  runtime switch --repo owner/repo feature/login
175
177
  runtime switch -c feature/search --repo owner/repo --from main
176
178
  ```
177
179
 
178
180
  Rules:
179
- - GitHub must already be connected with `runtime integrations connect github`.
181
+ - GitHub must already be connected with `runtime integrate github`.
182
+ - Run `runtime integrate github` again when you want to add another personal account or org.
180
183
  - `runtime switch` opens an existing branch workspace, or creates the computer if
181
184
  the branch exists but the workspace does not yet.
182
185
  - `runtime switch -c` creates a new branch first, then opens that branch's
@@ -44,8 +44,9 @@ runtime login --api-key rt_live_...
44
44
  runtime api-keys list
45
45
  runtime api-keys create "my laptop"
46
46
  runtime api-keys revoke 123
47
- runtime integrations list
48
- runtime integrations connect github
47
+ runtime integrate github
48
+ runtime github list
49
+ runtime github disconnect acme
49
50
 
50
51
  # Computers
51
52
  runtime switch --repo owner/repo feature/login # open or create the branch workspace computer
@@ -148,13 +149,15 @@ that ad-hoc process is not.
148
149
  between parallel tasks without local worktrees.
149
150
 
150
151
  ```bash
151
- runtime integrations connect github
152
+ runtime integrate github
153
+ runtime github list
152
154
  runtime switch --repo owner/repo feature/login
153
155
  runtime switch -c feature/search --repo owner/repo --from main
154
156
  ```
155
157
 
156
158
  Rules:
157
- - GitHub must already be connected with `runtime integrations connect github`.
159
+ - GitHub must already be connected with `runtime integrate github`.
160
+ - Run `runtime integrate github` again when you want to add another personal account or org.
158
161
  - `runtime switch` opens an existing branch workspace, or creates the computer if
159
162
  the branch exists but the workspace does not yet.
160
163
  - `runtime switch -c` creates a new branch first, then opens that branch's
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.24"
3
+ version = "0.4.26"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -192,14 +192,16 @@ def build_parser() -> argparse.ArgumentParser:
192
192
  secrets_delete = secrets_subparsers.add_parser("delete", aliases=["rm"], help="Delete a secret set")
193
193
  secrets_delete.add_argument("slug", nargs="?", default=None, help="Secret set slug")
194
194
 
195
- integrations_cmd = subparsers.add_parser("integrations", help="Connect account-level integrations")
196
- integrations_subparsers = integrations_cmd.add_subparsers(dest="integrations_action", required=True)
197
- integrations_subparsers.add_parser("list", aliases=["ls"], help="List integrations")
198
- integrations_connect = integrations_subparsers.add_parser("connect", help="Connect an integration")
199
- integrations_connect.add_argument("provider", choices=["github"])
200
- integrations_connect.add_argument("--slug", default="github", help="Integration slug to create (default: github)")
201
- integrations_connect.add_argument("--owner", default=None, help="GitHub username or org where the app is installed")
202
- integrations_connect.add_argument("--no-open", action="store_true", help="Print the install URL instead of opening a browser")
195
+ github_cmd = subparsers.add_parser("github", help="Manage connected GitHub App installations")
196
+ github_subparsers = github_cmd.add_subparsers(dest="github_action", required=True)
197
+ github_subparsers.add_parser("list", aliases=["ls"], help="List connected GitHub installations")
198
+ github_disconnect = github_subparsers.add_parser("disconnect", aliases=["rm"], help="Disconnect a GitHub installation from Runtime")
199
+ github_disconnect.add_argument("ref", nargs="?", default=None, help="GitHub owner or installation id")
200
+
201
+ integrate_cmd = subparsers.add_parser("integrate", help="Connect the RuntimeVM GitHub App")
202
+ integrate_cmd.add_argument("provider", choices=["github"])
203
+ integrate_cmd.add_argument("--no-open", action="store_true", help="Print the install URL instead of opening a browser")
204
+ integrate_cmd.add_argument("--timeout", type=int, default=180, help="Seconds to wait for the browser install to finish (default: 180)")
203
205
 
204
206
  switch_cmd = subparsers.add_parser(
205
207
  "switch",
@@ -376,8 +378,9 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
376
378
  "GitHub",
377
379
  (
378
380
  ("switch, checkout [branch]", "Open a GitHub branch workspace in its own computer"),
379
- ("integrations list", "List account-level integrations"),
380
- ("integrations connect github", "Install the RuntimeVM GitHub App"),
381
+ ("integrate github", "Install the RuntimeVM GitHub App and wait for completion"),
382
+ ("github list", "List connected GitHub installations"),
383
+ ("github disconnect, rm [owner|id]", "Disconnect one GitHub installation"),
381
384
  ),
382
385
  ),
383
386
  (
@@ -407,6 +410,8 @@ _HELP_EXAMPLES: tuple[str, ...] = (
407
410
  "runtime ls -w # live-updating list",
408
411
  "runtime create mybox --command 'npm start' --cwd /app --port 3000",
409
412
  "runtime share private mybox # require owner auth",
413
+ "runtime integrate github # open GitHub and wait for the install to finish",
414
+ "runtime github list # show connected GitHub orgs/accounts",
410
415
  "runtime proxy start mybox 5432 # forward localhost:5432 → mybox:5432",
411
416
  "runtime completion zsh > ~/.zsh/completions/_runtime",
412
417
  )
@@ -506,12 +511,14 @@ def main(argv: list[str] | None = None) -> int:
506
511
  if args.secrets_action in ("delete", "rm"):
507
512
  return handle_secrets_delete(config, args.slug)
508
513
  return report_error("unknown secrets command")
509
- if args.command == "integrations":
510
- if args.integrations_action in ("list", "ls"):
511
- return handle_integrations_list(config)
512
- if args.integrations_action == "connect":
513
- return handle_integrations_connect(config, args.provider, args.slug, args.owner, no_open=args.no_open)
514
- return report_error("unknown integrations command")
514
+ if args.command == "github":
515
+ if args.github_action in ("list", "ls"):
516
+ return handle_github_list(config)
517
+ if args.github_action in ("disconnect", "rm"):
518
+ return handle_github_disconnect(config, args.ref)
519
+ return report_error("unknown github command")
520
+ if args.command == "integrate":
521
+ return handle_integrate_github(config, args.provider, no_open=args.no_open, timeout_seconds=args.timeout)
515
522
  if args.command in ("switch", "checkout"):
516
523
  return handle_switch(
517
524
  config,
@@ -889,22 +896,6 @@ def _prompt_typed_confirm(message: str, expected: str) -> bool:
889
896
  return typed.strip().lower() == expected.strip().lower()
890
897
 
891
898
 
892
- def _display_width(value: Any, minimum: int, maximum: int) -> int:
893
- text = str(value or "")
894
- return max(minimum, min(len(text), maximum))
895
-
896
-
897
- def _fit_cell(value: Any, width: int) -> str:
898
- text = str(value or "")
899
- if width <= 0:
900
- return ""
901
- if len(text) > width:
902
- if width == 1:
903
- return "…"
904
- text = text[: width - 1] + "…"
905
- return text.ljust(width)
906
-
907
-
908
899
  def _computer_power_state(c: dict[str, Any]) -> str:
909
900
  power = c.get("power_state")
910
901
  if power is not None:
@@ -1123,157 +1114,97 @@ def _computer_sort_key(c: dict[str, Any]) -> tuple[int, str, str]:
1123
1114
 
1124
1115
 
1125
1116
 
1126
- def _computer_table_layout(computers: list[dict[str, Any]]) -> dict[str, int]:
1127
- term_width = shutil.get_terminal_size((100, 20)).columns
1128
- slug_width = max(_display_width(_computer_name(c), len("name"), 24) for c in computers)
1129
- status_width = max(
1130
- _display_width(_computer_status_label(c), len("state"), 20)
1131
- for c in computers
1132
- )
1133
- created_width = max(
1134
- _display_width(_computer_created_label(c), len("age"), 8)
1135
- for c in computers
1136
- )
1137
- reserved = slug_width + status_width + created_width + len(" ") * 3
1138
- url_budget = max(16, term_width - reserved)
1139
- url_width = max(
1140
- len("url"),
1141
- min(max(len(_computer_url_label(c)) for c in computers), url_budget),
1142
- )
1143
- return {
1144
- "slug": slug_width,
1145
- "status": status_width,
1146
- "public_url": url_width,
1147
- "created_at": created_width,
1148
- }
1149
-
1150
-
1151
- def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
1152
- return " │ ".join(
1153
- [
1154
- _fit_cell(_computer_name(c), widths["slug"]),
1155
- _fit_cell(_computer_status_label(c), widths["status"]),
1156
- _fit_cell(_computer_url_label(c), widths["public_url"]),
1157
- _fit_cell(_computer_created_label(c), widths["created_at"]),
1158
- ]
1159
- )
1160
-
1161
-
1162
- def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tuple[str, dict[str, int]]:
1163
- widths = _computer_table_layout(computers)
1164
- header = " │ ".join(
1165
- [
1166
- _fit_cell("name", widths["slug"]),
1167
- _fit_cell("state", widths["status"]),
1168
- _fit_cell("url", widths["public_url"]),
1169
- _fit_cell("age", widths["created_at"]),
1170
- ]
1171
- )
1172
- divider = "─" * len(header)
1173
- return f"{prompt}\n{header}\n{divider}", widths
1117
+ def _picker_status_color(status: str) -> str:
1118
+ if "cold" in status:
1119
+ return "fg:#60a5fa" # cyan-blue
1120
+ if "warm" in status or "running" in status:
1121
+ return "fg:#22c55e" # green
1122
+ if "archiving" in status or "restoring" in status:
1123
+ return "fg:#eab308" # yellow
1124
+ if "creating" in status:
1125
+ return "fg:#a855f7" # magenta
1126
+ if "orphaned" in status:
1127
+ return "fg:#ef4444" # red
1128
+ return "fg:#e5e7eb"
1174
1129
 
1175
1130
 
1131
+ def _picker_widths(computers: list[dict[str, Any]]) -> dict[str, int]:
1132
+ """Compute aligned column widths for the interactive picker rows.
1176
1133
 
1177
- def _computer_choice_title(c: dict[str, Any], widths: dict[str, int]) -> str:
1178
- del widths
1134
+ We budget total width to leave room for questionary's pointer + indicator.
1135
+ """
1136
+ term_width = shutil.get_terminal_size((100, 20)).columns
1137
+ name_w = max(len("name"), max((len(_computer_name(c)) for c in computers), default=0))
1138
+ name_w = min(name_w, 24)
1139
+ status_w = max(len("state"), max((len(_computer_status_label(c)) for c in computers), default=0))
1140
+ status_w = min(status_w, 14)
1141
+ age_w = max(len("age"), max((len(_computer_created_label(c)) for c in computers), default=0))
1142
+ age_w = min(age_w, 6)
1143
+ # pointer + indicator + 2 gaps * 3 + status dot (2 chars) + padding
1144
+ reserved = 6 + name_w + status_w + age_w + 2 * 3 + 2
1145
+ url_w = max(len("url"), min(max((len(_computer_url_label(c)) for c in computers), default=16), term_width - reserved))
1146
+ url_w = max(10, url_w)
1147
+ return {"name": name_w, "status": status_w, "url": url_w, "age": age_w}
1148
+
1149
+
1150
+ def _computer_choice_title(c: dict[str, Any], widths: dict[str, int]) -> list[tuple[str, str]]:
1151
+ """Return a styled list-of-tuples title so the picker rows look tabular.
1152
+
1153
+ Columns: name · state (colored dot + label) · url · age.
1154
+ """
1155
+ name = _computer_name(c)
1179
1156
  status = _computer_status_label(c)
1180
- url = _computer_url_label(c)
1181
- created = _computer_created_label(c)
1182
- parts = [f"{_computer_name(c)}", status]
1183
- if url:
1184
- parts.append(url)
1185
- if created:
1186
- parts.append(created)
1187
- return " · ".join(parts)
1188
-
1189
-
1190
-
1191
- def _render_computer_summary(computers: list[dict[str, Any]]) -> None:
1192
- mods = _UI.rich()
1193
- Panel, Text = mods["Panel"], mods["Text"]
1194
- counts: dict[str, int] = {}
1195
- for computer in computers:
1196
- status = _computer_status_label(computer)
1197
- counts[status] = counts.get(status, 0) + 1
1198
-
1199
- body = Text()
1200
- body.append(f"total {len(computers)}", style="bold white")
1201
- if counts:
1202
- body.append(" ")
1203
- first = True
1204
- status_styles = {
1205
- "warm": "bold green",
1206
- "cold": "bold cyan",
1207
- "running": "bold green",
1208
- "archiving": "bold yellow",
1209
- "restoring": "bold yellow",
1210
- "creating": "bold magenta",
1211
- "orphaned": "bold red",
1212
- }
1213
- for status in sorted(counts):
1214
- if not first:
1215
- body.append(" • ", style="dim")
1216
- first = False
1217
- status_style = "bold white"
1218
- for token, style in status_styles.items():
1219
- if token in status:
1220
- status_style = style
1221
- break
1222
- body.append(status, style=status_style)
1223
- body.append(f" {counts[status]}", style="white")
1224
-
1225
- _UI.console().print(
1226
- Panel(body, title="runtime", border_style="blue", expand=False, padding=(0, 1))
1227
- )
1228
-
1157
+ url = _computer_url_label(c) or "—"
1158
+ age = _computer_created_label(c) or ""
1159
+ color = _picker_status_color(status)
1160
+ dot = _computer_status_dot(status)
1161
+ # Keep each column within its budget; truncate with ellipsis on overflow.
1162
+ def fit(text: str, width: int, align: str = "<") -> str:
1163
+ text = str(text or "")
1164
+ if len(text) > width:
1165
+ return text[: max(1, width - 1)] + "…"
1166
+ return format(text, f"{align}{width}")
1167
+ return [
1168
+ ("", fit(name, widths["name"]) + " "),
1169
+ (color, f"{dot} "),
1170
+ (color, fit(status, widths["status"])),
1171
+ ("", " "),
1172
+ ("fg:#cbd5e1", fit(url, widths["url"])),
1173
+ ("", " "),
1174
+ ("fg:#6b7280", fit(age, widths["age"], ">")),
1175
+ ]
1229
1176
 
1230
1177
 
1231
- def _render_computer_table(computers: list[dict[str, Any]], *, title: str | None = None) -> None:
1232
- mods = _UI.rich()
1233
- Table, box = mods["Table"], mods["box"]
1234
- table = Table(
1235
- title=title,
1236
- box=box.MINIMAL_DOUBLE_HEAD,
1237
- expand=True,
1238
- show_lines=False,
1239
- header_style="bold #7dd3fc",
1240
- title_style="bold white",
1241
- pad_edge=False,
1242
- collapse_padding=True,
1243
- row_styles=["", "dim"],
1244
- )
1245
- table.add_column("Name", style="bold white", no_wrap=True, ratio=2)
1246
- table.add_column("State", no_wrap=True, ratio=2)
1247
- table.add_column("URL", style="white", overflow="fold", ratio=4)
1248
- table.add_column("Age", style="dim", no_wrap=True, justify="right", ratio=1)
1249
-
1250
- for computer in sorted(computers, key=_computer_sort_key):
1251
- status = _computer_status_label(computer)
1252
- status_style = _computer_status_style(status)
1253
- table.add_row(
1254
- _computer_name(computer),
1255
- f"[{status_style}]{_computer_status_dot(status)} {status}[/{status_style}]",
1256
- _computer_url_label(computer),
1257
- _computer_created_label(computer),
1258
- )
1259
1178
 
1260
- _UI.console().print(table)
1261
1179
 
1262
1180
 
1263
1181
  def _select_prompt(message: str, choices: list[Any], *, instruction: str | None = None) -> Any:
1264
1182
  # questionary rejects use_jk_keys together with use_search_filter because
1265
1183
  # j/k would be swallowed by the search prefix. We pick search over vim keys.
1266
- return _UI.q().select(
1184
+ question = _UI.q().select(
1267
1185
  message,
1268
1186
  choices=choices,
1269
1187
  qmark="●",
1270
1188
  pointer="❯",
1271
- instruction=instruction or "↑↓ navigate · type to search · enter · esc cancel",
1189
+ instruction=instruction or "↑↓ · type to search · enter select · esc back",
1272
1190
  use_indicator=True,
1273
1191
  use_jk_keys=False,
1274
1192
  use_search_filter=True,
1275
1193
  style=_UI.style(),
1276
1194
  )
1195
+ # Bind ESC explicitly — questionary's select prompt does not bind it by
1196
+ # default. With eager=True the binding fires before prompt_toolkit's
1197
+ # meta-key timeout. .unsafe_ask() returns the `result` we pass to exit().
1198
+ with contextlib.suppress(Exception):
1199
+ from prompt_toolkit.keys import Keys
1200
+
1201
+ bindings = question.application.key_bindings
1202
+
1203
+ @bindings.add(Keys.Escape, eager=True)
1204
+ def _escape(event: Any) -> None:
1205
+ event.app.exit(result=None)
1206
+
1207
+ return question
1277
1208
 
1278
1209
 
1279
1210
  def _pick_computer(
@@ -1283,8 +1214,8 @@ def _pick_computer(
1283
1214
  ) -> dict[str, Any] | None:
1284
1215
  """Fetch computers and let the user arrow-key through them.
1285
1216
 
1286
- Returns the picked computer dict, or None if the list is empty or the user
1287
- explicitly chose "← cancel". Ctrl-C propagates as KeyboardInterrupt.
1217
+ Returns the picked computer dict, or None on ESC / "← cancel".
1218
+ Ctrl-C propagates as KeyboardInterrupt.
1288
1219
  """
1289
1220
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1290
1221
  payload = client.list_computers()
@@ -1299,8 +1230,7 @@ def _pick_computer(
1299
1230
  return None
1300
1231
 
1301
1232
  questionary = _UI.q()
1302
- widths = _computer_table_layout(computers)
1303
- _render_computer_table(computers)
1233
+ widths = _picker_widths(computers)
1304
1234
  choices = [
1305
1235
  questionary.Choice(title=_computer_choice_title(c, widths), value=c) for c in computers
1306
1236
  ]
@@ -1770,30 +1700,31 @@ def handle_api_keys_revoke(config: RuntimeConfig, key_id: str | None) -> int:
1770
1700
 
1771
1701
 
1772
1702
 
1773
- def _render_integrations_table(payload: dict[str, Any]) -> None:
1703
+ def _render_github_installations_table(payload: dict[str, Any]) -> None:
1774
1704
  mods = _UI.rich()
1775
1705
  Table = mods["Table"]
1776
- integrations = payload.get("integrations") or []
1777
- if not isinstance(integrations, list) or not integrations:
1778
- _UI.console().print("[yellow]no integrations found[/yellow]")
1706
+ installations = payload.get("installations") or []
1707
+ if not isinstance(installations, list) or not installations:
1708
+ _UI.console().print("[yellow]no GitHub installations found[/yellow]")
1779
1709
  return
1780
1710
 
1781
- table = Table(title="Integrations", expand=True, box=mods["box"].SIMPLE_HEAVY)
1782
- table.add_column("Slug", style="bold white")
1783
- table.add_column("Provider", style="white")
1711
+ table = Table(title="GitHub", expand=True, box=mods["box"].SIMPLE_HEAVY)
1712
+ table.add_column("ID", style="bold white")
1784
1713
  table.add_column("Owner", style="white")
1785
- table.add_column("Transport", style="white")
1786
- table.add_column("Backend", style="white")
1714
+ table.add_column("Type", style="white")
1715
+ table.add_column("Repos", style="white")
1716
+ table.add_column("Status", style="white")
1787
1717
 
1788
- for entry in integrations:
1718
+ for entry in installations:
1789
1719
  if not isinstance(entry, dict):
1790
1720
  continue
1721
+ status = "suspended" if entry.get("suspended") else "connected"
1791
1722
  table.add_row(
1792
- str(entry.get("slug") or "—"),
1793
- str(entry.get("provider") or "—"),
1794
- str(entry.get("credential_ref") or "—"),
1795
- str(entry.get("transport") or "—"),
1796
- str(entry.get("backend") or "—"),
1723
+ str(entry.get("id") or "—"),
1724
+ str(entry.get("github_account_login") or "—"),
1725
+ str(entry.get("github_account_type") or "—"),
1726
+ str(entry.get("repository_selection") or "—"),
1727
+ status,
1797
1728
  )
1798
1729
  _UI.console().print(table)
1799
1730
 
@@ -1931,21 +1862,68 @@ def handle_secrets_delete(config: RuntimeConfig, slug: str | None) -> int:
1931
1862
 
1932
1863
 
1933
1864
 
1934
- def handle_integrations_list(config: RuntimeConfig) -> int:
1865
+ def handle_github_list(config: RuntimeConfig) -> int:
1935
1866
  if (err := _require_api_key(config)) is not None:
1936
1867
  return err
1937
1868
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1938
- return report_success(client.list_integrations(), _render_integrations_table)
1869
+ return report_success(client.list_github_installations(), _render_github_installations_table)
1870
+
1939
1871
 
1940
1872
 
1873
+ def handle_github_disconnect(config: RuntimeConfig, ref: str | None) -> int:
1874
+ if (err := _require_api_key(config)) is not None:
1875
+ return err
1876
+ resolved_ref = str(ref or "").strip()
1877
+ if not resolved_ref:
1878
+ if not _interactive():
1879
+ return report_error("github owner or installation id is required")
1880
+ resolved_ref = _prompt_text("GitHub owner or installation id")
1881
+ if not resolved_ref:
1882
+ return report_error("github owner or installation id is required")
1883
+
1884
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1885
+ installation_id: int | None = None
1886
+ owner = resolved_ref
1941
1887
 
1942
- def handle_integrations_connect(
1888
+ try:
1889
+ installation_id = int(resolved_ref)
1890
+ except ValueError:
1891
+ payload = client.list_github_installations()
1892
+ installations = payload.get("installations") or []
1893
+ target_owner = resolved_ref.casefold()
1894
+ match = next(
1895
+ (
1896
+ entry
1897
+ for entry in installations
1898
+ if isinstance(entry, dict) and str(entry.get("github_account_login") or "").strip().casefold() == target_owner
1899
+ ),
1900
+ None,
1901
+ )
1902
+ if match is None:
1903
+ return report_error(f"github installation not found: {resolved_ref}")
1904
+ try:
1905
+ installation_id = int(match.get("id"))
1906
+ except (TypeError, ValueError):
1907
+ return report_error(f"github installation is missing an id: {resolved_ref}")
1908
+ owner = str(match.get("github_account_login") or resolved_ref)
1909
+
1910
+ if installation_id is None or installation_id <= 0:
1911
+ return report_error("installation id must be a positive number")
1912
+
1913
+ client.disconnect_github_installation(installation_id)
1914
+ return report_success(
1915
+ {"id": installation_id, "owner": owner, "deleted": True},
1916
+ lambda payload: _UI.console().print(f"[green]✓[/green] disconnected [bold]{payload['owner']}[/bold]"),
1917
+ )
1918
+
1919
+
1920
+
1921
+ def handle_integrate_github(
1943
1922
  config: RuntimeConfig,
1944
1923
  provider: str,
1945
- slug: str | None,
1946
- owner: str | None,
1947
1924
  *,
1948
1925
  no_open: bool = False,
1926
+ timeout_seconds: int = 180,
1949
1927
  ) -> int:
1950
1928
  if (err := _require_api_key(config)) is not None:
1951
1929
  return err
@@ -1953,11 +1931,11 @@ def handle_integrations_connect(
1953
1931
  return report_error(f"unsupported integration provider: {provider}")
1954
1932
 
1955
1933
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1956
- payload = _with_spinner("getting GitHub install link…", lambda: client.start_github_connect(slug=slug))
1934
+ payload = _with_spinner("getting GitHub install link…", client.start_github_connect)
1957
1935
  install_url = str(payload.get("install_url") or "").strip()
1958
- resolved_slug = str(payload.get("slug") or slug or "github").strip() or "github"
1959
- if not install_url:
1960
- return report_error("server did not return a GitHub install URL")
1936
+ session_id = int(payload.get("session_id") or 0)
1937
+ if not install_url or session_id <= 0:
1938
+ return report_error("server did not return a GitHub install session")
1961
1939
 
1962
1940
  opened = False
1963
1941
  if not no_open:
@@ -1967,37 +1945,26 @@ def handle_integrations_connect(
1967
1945
  if _interactive():
1968
1946
  action = "opened in your browser" if opened else "copy this URL into your browser"
1969
1947
  _UI.console().print(f"[cyan]→[/cyan] {action}: [link={install_url}]{install_url}[/link]")
1970
- _UI.console().print("[dim]Install the RuntimeVM GitHub App, then come back here.[/dim]")
1971
- input("Press Enter after the GitHub App is installed... ")
1972
- if not owner:
1973
- owner = _prompt_text("GitHub owner (username or org)")
1974
- elif not owner:
1975
- return report_error("--owner is required without a TTY")
1976
-
1977
- resolved_owner = str(owner or "").strip()
1978
- if not resolved_owner:
1979
- return report_error("GitHub owner is required")
1980
-
1981
- integration = _with_spinner(
1982
- "connecting GitHub…",
1983
- lambda: client.create_integration(
1984
- slug=resolved_slug,
1985
- provider="github",
1986
- transport="scoped_credential",
1987
- backend="native",
1988
- credential_source="external_account",
1989
- credential_ref=resolved_owner,
1990
- config={},
1991
- ),
1992
- )
1948
+ _UI.console().print("[dim]Choose the GitHub account/org and repo access in GitHub. Waiting for completion…[/dim]")
1993
1949
 
1994
- def render(result: dict[str, Any]) -> None:
1995
- connected = result.get("integration") or {}
1996
- _UI.console().print(
1997
- f"[green]✓[/green] connected [bold]{connected.get('slug', resolved_slug)}[/bold] → [bold]{connected.get('credential_ref', resolved_owner)}[/bold]"
1998
- )
1950
+ deadline = time.time() + max(1, int(timeout_seconds))
1951
+ last_status = "pending"
1952
+ while time.time() < deadline:
1953
+ session = client.get_github_connect_session(session_id)
1954
+ status = str(session.get("status") or "pending")
1955
+ last_status = status
1956
+ if status == "completed":
1957
+ installation = session.get("installation") or {}
1958
+ owner = str(installation.get("github_account_login") or "github")
1959
+ return report_success(
1960
+ {"installation": installation, "install_url": install_url, "session_id": session_id},
1961
+ lambda result: _UI.console().print(f"[green]✓[/green] connected [bold]{(result.get('installation') or {}).get('github_account_login', owner)}[/bold]"),
1962
+ )
1963
+ if status in {"failed", "expired"}:
1964
+ return report_error(str(session.get("error") or f"github connect {status}"))
1965
+ time.sleep(2)
1999
1966
 
2000
- return report_success({"integration": integration, "install_url": install_url}, render)
1967
+ return report_error(f"timed out waiting for GitHub install to finish (last status: {last_status})")
2001
1968
 
2002
1969
 
2003
1970
 
@@ -2977,7 +2944,11 @@ def _send_terminal_resize(ws: Any) -> None:
2977
2944
 
2978
2945
 
2979
2946
  def _interactive_list(config: RuntimeConfig) -> int:
2980
- """Nested menu: table pick VM action → back."""
2947
+ """Interactive top-level menu: picker is the display, no separate table.
2948
+
2949
+ ESC / Ctrl+C / "✕ quit" exit. "+ new computer" creates one. "↻ refresh"
2950
+ re-fetches. Picking a VM drops into its action submenu.
2951
+ """
2981
2952
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2982
2953
  questionary = _UI.q()
2983
2954
 
@@ -2992,9 +2963,19 @@ def _interactive_list(config: RuntimeConfig) -> int:
2992
2963
  return handle_create(config, None, None, None, None)
2993
2964
 
2994
2965
  computers = sorted(computers, key=_computer_sort_key)
2995
- _render_computer_summary(computers)
2996
- widths = _computer_table_layout(computers)
2997
- _render_computer_table(computers, title="computers")
2966
+ widths = _picker_widths(computers)
2967
+
2968
+ # Title summarises counts so the picker alone carries enough context.
2969
+ total = len(computers)
2970
+ warm = sum(1 for c in computers if "warm" in _computer_status_label(c) or "running" in _computer_status_label(c))
2971
+ cold = sum(1 for c in computers if "cold" in _computer_status_label(c))
2972
+ bits = [f"{total} total"]
2973
+ if warm:
2974
+ bits.append(f"{warm} warm")
2975
+ if cold:
2976
+ bits.append(f"{cold} cold")
2977
+ summary = " · ".join(bits)
2978
+
2998
2979
  choices: list[Any] = [
2999
2980
  questionary.Choice(title=_computer_choice_title(c, widths), value=c)
3000
2981
  for c in computers
@@ -3004,9 +2985,9 @@ def _interactive_list(config: RuntimeConfig) -> int:
3004
2985
  choices.append(questionary.Choice(title="↻ refresh", value="__refresh__"))
3005
2986
  choices.append(questionary.Choice(title="✕ quit", value="__quit__"))
3006
2987
 
3007
- picked = _select_prompt("Pick a computer", choices).unsafe_ask()
2988
+ picked = _select_prompt(f"Pick a computer ({summary})", choices).unsafe_ask()
3008
2989
 
3009
- if picked == "__quit__":
2990
+ if picked is None or picked == "__quit__":
3010
2991
  return 0
3011
2992
  if picked == "__refresh__":
3012
2993
  continue
@@ -3270,7 +3251,7 @@ _runtime_complete() {
3270
3251
  COMPREPLY=()
3271
3252
  cur="${COMP_WORDS[COMP_CWORD]}"
3272
3253
  if [[ ${COMP_CWORD} -eq 1 ]]; then
3273
- COMPREPLY=( $(compgen -W "create list ls info url start enter run publish delete rm share startup service proxy switch checkout integrations secrets api-keys signup verify login whoami logout completion help" -- "$cur") )
3254
+ COMPREPLY=( $(compgen -W "create list ls info url start enter run publish delete rm share startup service proxy switch checkout github integrate secrets api-keys signup verify login whoami logout completion help" -- "$cur") )
3274
3255
  return
3275
3256
  fi
3276
3257
  sub="${COMP_WORDS[1]}"
@@ -3319,7 +3300,8 @@ _runtime() {
3319
3300
  'proxy:Port proxies'
3320
3301
  'switch:Open a GitHub branch workspace'
3321
3302
  'checkout:Open a GitHub branch workspace'
3322
- 'integrations:Integrations'
3303
+ 'github:GitHub connections'
3304
+ 'integrate:Connect GitHub'
3323
3305
  'secrets:Secret sets'
3324
3306
  'api-keys:API keys'
3325
3307
  'signup:Send verification code'
@@ -3384,6 +3366,8 @@ complete -c runtime -n '__fish_use_subcommand' -a share -d 'Set visibility'
3384
3366
  complete -c runtime -n '__fish_use_subcommand' -a startup -d 'Startup config'
3385
3367
  complete -c runtime -n '__fish_use_subcommand' -a service -d 'Service config'
3386
3368
  complete -c runtime -n '__fish_use_subcommand' -a proxy -d 'Port proxies'
3369
+ complete -c runtime -n '__fish_use_subcommand' -a github -d 'GitHub connections'
3370
+ complete -c runtime -n '__fish_use_subcommand' -a integrate -d 'Connect GitHub'
3387
3371
  complete -c runtime -n '__fish_use_subcommand' -a help -d 'Extended help'
3388
3372
  complete -c runtime -n '__fish_use_subcommand' -a completion -d 'Completion'
3389
3373
 
@@ -52,38 +52,19 @@ class RuntimeClient:
52
52
  def revoke_api_key(self, key_id: int) -> dict[str, Any]:
53
53
  return self._request("POST", f"/api/api-keys/{key_id}/revoke", auth_required=True)
54
54
 
55
- # --- Integrations ---
55
+ # --- GitHub App ---
56
56
 
57
- def list_integrations(self) -> dict[str, Any]:
58
- return self._request("GET", "/api/integrations", auth_required=True)
57
+ def start_github_connect(self) -> dict[str, Any]:
58
+ return self._request("POST", "/api/github/connect", auth_required=True)
59
59
 
60
- def start_github_connect(self, *, slug: str | None = None) -> dict[str, Any]:
61
- body: dict[str, Any] = {}
62
- if slug:
63
- body["slug"] = slug
64
- return self._request("POST", "/api/integrations/github/connect", json=body, auth_required=True)
60
+ def get_github_connect_session(self, session_id: int) -> dict[str, Any]:
61
+ return self._request("GET", f"/api/github/connect/{session_id}", auth_required=True)
65
62
 
66
- def create_integration(
67
- self,
68
- *,
69
- slug: str,
70
- provider: str,
71
- transport: str,
72
- backend: str,
73
- credential_source: str,
74
- credential_ref: str,
75
- config: dict[str, Any] | None = None,
76
- ) -> dict[str, Any]:
77
- body: dict[str, Any] = {
78
- "slug": slug,
79
- "provider": provider,
80
- "transport": transport,
81
- "backend": backend,
82
- "credential_source": credential_source,
83
- "credential_ref": credential_ref,
84
- "config": config or {},
85
- }
86
- return self._request("POST", "/api/integrations", json=body, auth_required=True)
63
+ def list_github_installations(self) -> dict[str, Any]:
64
+ return self._request("GET", "/api/github/installations", auth_required=True)
65
+
66
+ def disconnect_github_installation(self, installation_id: int) -> dict[str, Any]:
67
+ return self._request("DELETE", f"/api/github/installations/{installation_id}", auth_required=True)
87
68
 
88
69
  def list_secret_sets(self) -> dict[str, Any]:
89
70
  return self._request("GET", "/api/secrets", auth_required=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.24
3
+ Version: 0.4.26
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
@@ -66,8 +66,9 @@ runtime login --api-key rt_live_...
66
66
  runtime api-keys list
67
67
  runtime api-keys create "my laptop"
68
68
  runtime api-keys revoke 123
69
- runtime integrations list
70
- runtime integrations connect github
69
+ runtime integrate github
70
+ runtime github list
71
+ runtime github disconnect acme
71
72
 
72
73
  # Computers
73
74
  runtime switch --repo owner/repo feature/login # open or create the branch workspace computer
@@ -170,13 +171,15 @@ that ad-hoc process is not.
170
171
  between parallel tasks without local worktrees.
171
172
 
172
173
  ```bash
173
- runtime integrations connect github
174
+ runtime integrate github
175
+ runtime github list
174
176
  runtime switch --repo owner/repo feature/login
175
177
  runtime switch -c feature/search --repo owner/repo --from main
176
178
  ```
177
179
 
178
180
  Rules:
179
- - GitHub must already be connected with `runtime integrations connect github`.
181
+ - GitHub must already be connected with `runtime integrate github`.
182
+ - Run `runtime integrate github` again when you want to add another personal account or org.
180
183
  - `runtime switch` opens an existing branch workspace, or creates the computer if
181
184
  the branch exists but the workspace does not yet.
182
185
  - `runtime switch -c` creates a new branch first, then opens that branch's
File without changes