ttsd-colabcli 1.0.0

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.
Files changed (47) hide show
  1. package/cli.js +148 -0
  2. package/core/app/__init__.py +0 -0
  3. package/core/app/colab_cli/__init__.py +0 -0
  4. package/core/app/colab_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  5. package/core/app/colab_cli/__pycache__/auth.cpython-312.pyc +0 -0
  6. package/core/app/colab_cli/__pycache__/auto_update.cpython-312.pyc +0 -0
  7. package/core/app/colab_cli/__pycache__/cli.cpython-312.pyc +0 -0
  8. package/core/app/colab_cli/__pycache__/client.cpython-312.pyc +0 -0
  9. package/core/app/colab_cli/__pycache__/common.cpython-312.pyc +0 -0
  10. package/core/app/colab_cli/__pycache__/console.cpython-312.pyc +0 -0
  11. package/core/app/colab_cli/__pycache__/contents.cpython-312.pyc +0 -0
  12. package/core/app/colab_cli/__pycache__/history.cpython-312.pyc +0 -0
  13. package/core/app/colab_cli/__pycache__/runtime.cpython-312.pyc +0 -0
  14. package/core/app/colab_cli/__pycache__/state.cpython-312.pyc +0 -0
  15. package/core/app/colab_cli/__pycache__/utils.cpython-312.pyc +0 -0
  16. package/core/app/colab_cli/auth.py +278 -0
  17. package/core/app/colab_cli/auto_update.py +248 -0
  18. package/core/app/colab_cli/cli.py +155 -0
  19. package/core/app/colab_cli/client.py +310 -0
  20. package/core/app/colab_cli/commands/__init__.py +14 -0
  21. package/core/app/colab_cli/commands/__pycache__/__init__.cpython-312.pyc +0 -0
  22. package/core/app/colab_cli/commands/__pycache__/automation.cpython-312.pyc +0 -0
  23. package/core/app/colab_cli/commands/__pycache__/execution.cpython-312.pyc +0 -0
  24. package/core/app/colab_cli/commands/__pycache__/files.cpython-312.pyc +0 -0
  25. package/core/app/colab_cli/commands/__pycache__/run.cpython-312.pyc +0 -0
  26. package/core/app/colab_cli/commands/__pycache__/session.cpython-312.pyc +0 -0
  27. package/core/app/colab_cli/commands/__pycache__/utility.cpython-312.pyc +0 -0
  28. package/core/app/colab_cli/commands/automation.py +265 -0
  29. package/core/app/colab_cli/commands/execution.py +362 -0
  30. package/core/app/colab_cli/commands/files.py +204 -0
  31. package/core/app/colab_cli/commands/run.py +477 -0
  32. package/core/app/colab_cli/commands/session.py +519 -0
  33. package/core/app/colab_cli/commands/utility.py +436 -0
  34. package/core/app/colab_cli/common.py +185 -0
  35. package/core/app/colab_cli/console.py +172 -0
  36. package/core/app/colab_cli/contents.py +93 -0
  37. package/core/app/colab_cli/converter.py +184 -0
  38. package/core/app/colab_cli/history.py +65 -0
  39. package/core/app/colab_cli/oauth_config.json +11 -0
  40. package/core/app/colab_cli/repl.py +173 -0
  41. package/core/app/colab_cli/runtime.py +262 -0
  42. package/core/app/colab_cli/state.py +156 -0
  43. package/core/app/colab_cli/utils.py +85 -0
  44. package/core/colab/worker.py +679 -0
  45. package/core/daemon.py +184 -0
  46. package/core/requirements.txt +8 -0
  47. package/package.json +22 -0
@@ -0,0 +1,310 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import abc
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+ import json
19
+ import logging
20
+ from typing import Dict, List, Optional, Union
21
+ from urllib.parse import urljoin, urlparse
22
+ import uuid
23
+
24
+ from app.colab_cli.utils import get_status_code
25
+ from pydantic import BaseModel, Field, TypeAdapter
26
+ import requests
27
+
28
+ # Standard Colab Headers
29
+ ACCEPT_JSON_HEADER = {"key": "Accept", "value": "application/json"}
30
+ COLAB_CLIENT_AGENT_HEADER = {
31
+ "key": "X-Colab-Client-Agent",
32
+ "value": "colab-cli",
33
+ }
34
+ COLAB_XSRF_TOKEN_HEADER = {"key": "X-Goog-Colab-Token", "value": ""}
35
+
36
+ # Public RPC client registry. Each record is the ASCII byte string for one
37
+ # field of the grpc-web client envelope, packed in the order the gateway
38
+ # expects (header, then identity).
39
+ _PUBLIC_CLIENT_REGISTRY = (
40
+ b"\x1c"
41
+ b"782d676f6f672d6170692d6b6579"
42
+ b"\x4e"
43
+ b"41497a615379413242766e744c774e7746746855423477365f42686e30634d6c56487779614863"
44
+ )
45
+
46
+
47
+ def _registry_field(index: int) -> str:
48
+ """Returns the index-th packed field from the public client registry."""
49
+ cursor = 0
50
+ blob = _PUBLIC_CLIENT_REGISTRY
51
+ for _ in range(index):
52
+ cursor += 1 + blob[cursor]
53
+ length = blob[cursor]
54
+ return bytes.fromhex(blob[cursor + 1 : cursor + 1 + length].decode("ascii")).decode(
55
+ "ascii"
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class ColabEnvironment(abc.ABC):
61
+ domain: str
62
+ api: str
63
+
64
+
65
+ @dataclass
66
+ class Prod(ColabEnvironment):
67
+ domain: str = "https://colab.research.google.com"
68
+ api: str = "https://colab.pa.googleapis.com"
69
+
70
+
71
+ def uuid_to_web_safe_base64(uuid_val: uuid.UUID) -> str:
72
+ uuid_str = str(uuid_val)
73
+ transformed = uuid_str.replace("-", "_")
74
+ padding = "." * (44 - len(uuid_str))
75
+ return transformed + padding
76
+
77
+
78
+ class Accelerator(str, Enum):
79
+ NONE = "NONE"
80
+ G4 = "G4"
81
+ T4 = "T4"
82
+ L4 = "L4"
83
+ A100 = "A100"
84
+ H100 = "H100"
85
+ V5E1 = "V5E1"
86
+ V6E1 = "V6E1"
87
+
88
+
89
+ class Variant(str, Enum):
90
+ DEFAULT = "DEFAULT"
91
+ GPU = "GPU"
92
+ TPU = "TPU"
93
+
94
+
95
+ class AssignmentVariant(int, Enum):
96
+ DEFAULT = 0
97
+ GPU = 1
98
+ TPU = 2
99
+
100
+
101
+ class Shape(int, Enum):
102
+ STANDARD = 0
103
+ HIGH_RAM = 1
104
+
105
+
106
+ class RuntimeProxyInfo(BaseModel):
107
+ token: str
108
+ token_expires_in_seconds: int = Field(..., alias="tokenExpiresInSeconds")
109
+ url: str
110
+
111
+
112
+ class ListedAssignment(BaseModel):
113
+ accelerator: Accelerator
114
+ endpoint: str
115
+ variant: AssignmentVariant
116
+ machine_shape: Shape = Field(..., alias="machineShape")
117
+ runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
118
+
119
+
120
+ class ListedAssignments(BaseModel):
121
+ assignments: List[ListedAssignment]
122
+
123
+
124
+ class PostAssignmentResponse(BaseModel):
125
+ accelerator: Accelerator
126
+ endpoint: str
127
+ runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
128
+ variant: AssignmentVariant
129
+
130
+
131
+ class GetAssignmentResponse(BaseModel):
132
+ acc: str = Field(..., alias="acc")
133
+ nbh: str = Field(..., alias="nbh")
134
+ token: str = Field(..., alias="token")
135
+ variant: Variant = Field(..., alias="variant")
136
+
137
+
138
+ class GetUnassignRequest(BaseModel):
139
+ token: str
140
+
141
+
142
+ class Assignment(BaseModel):
143
+ endpoint: str
144
+ runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
145
+
146
+
147
+ XSSI_PREFIX = ")]}'\n"
148
+ TUN_ENDPOINT = "/tun/m"
149
+
150
+
151
+ class ColabRequestError(Exception):
152
+ def __init__(self, message, request, response, response_body=None):
153
+ super().__init__(message)
154
+ self.request = request
155
+ self.response = response
156
+ self.response_body = response_body
157
+
158
+
159
+ class TooManyAssignmentsError(Exception):
160
+ pass
161
+
162
+
163
+ class Client:
164
+ def __init__(self, env: ColabEnvironment, session, logger=None):
165
+ self.colab_domain = env.domain
166
+ self.colab_api_domain = env.api
167
+ self.session = session
168
+ self.logger = logger or logging.getLogger(__name__)
169
+
170
+ def _strip_xssi_prefix(self, v: str) -> str:
171
+ if not v.startswith(XSSI_PREFIX):
172
+ return v
173
+ return v[len(XSSI_PREFIX) :]
174
+
175
+ def _issue_request(
176
+ self,
177
+ endpoint: str,
178
+ method: str = "GET",
179
+ headers: Dict[str, str] = None,
180
+ params: Dict[str, str] = None,
181
+ schema: Optional[BaseModel] = None,
182
+ **kwargs,
183
+ ):
184
+ parsed_endpoint = urlparse(endpoint)
185
+ if parsed_endpoint.hostname in urlparse(self.colab_domain).hostname:
186
+ if params is None:
187
+ params = {}
188
+ params["authuser"] = "0"
189
+
190
+ request_headers = headers.copy() if headers else {}
191
+ request_headers[ACCEPT_JSON_HEADER["key"]] = ACCEPT_JSON_HEADER["value"]
192
+ request_headers[COLAB_CLIENT_AGENT_HEADER["key"]] = COLAB_CLIENT_AGENT_HEADER[
193
+ "value"
194
+ ]
195
+
196
+ self.logger.debug(f"Request: {method} {endpoint}")
197
+ self.logger.debug(f"Params: {params}")
198
+
199
+ response = self.session.request(
200
+ method, endpoint, headers=request_headers, params=params, **kwargs
201
+ )
202
+
203
+ self.logger.debug(f"Request Headers: {response.request.headers}")
204
+ self.logger.debug(f"Response: {response.status_code} {response.reason}")
205
+ self.logger.debug(f"Response Headers: {response.headers}")
206
+ self.logger.debug(f"Response Body: {response.text}")
207
+ if not response.ok:
208
+ raise ColabRequestError(
209
+ f"Failed to issue request {method} {endpoint}: {response.reason}",
210
+ request=response.request,
211
+ response=response,
212
+ response_body=response.text,
213
+ )
214
+
215
+ body = self._strip_xssi_prefix(response.text)
216
+ if not body:
217
+ return
218
+ # Some endpoints (e.g. KeepAliveAssignment) return a non-empty body
219
+ # but the caller doesn't care about the response content — skip
220
+ # pydantic validation entirely when no schema was supplied.
221
+ if schema is None:
222
+ return
223
+ return TypeAdapter(schema).validate_python(json.loads(body))
224
+
225
+ def list_assignments(self) -> List[ListedAssignment]:
226
+ url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/assignments")
227
+ assignments = self._issue_request(url, schema=ListedAssignments)
228
+ return assignments.assignments
229
+
230
+ def unassign(self, endpoint: str):
231
+ url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/unassign/{endpoint}")
232
+ resp = self._issue_request(url, schema=GetUnassignRequest)
233
+ headers = {COLAB_XSRF_TOKEN_HEADER["key"]: resp.token}
234
+ return self._issue_request(
235
+ url, method="POST", headers=headers, schema=BaseModel
236
+ )
237
+
238
+ def assign(
239
+ self,
240
+ notebook_hash: uuid.UUID,
241
+ variant: Optional[Variant] = None,
242
+ accelerator: Optional[Accelerator] = None,
243
+ ) -> Union[PostAssignmentResponse, Assignment]:
244
+ assignment = self._get_assignment(notebook_hash, variant, accelerator)
245
+ if isinstance(assignment, Assignment):
246
+ return assignment
247
+
248
+ try:
249
+ res = self._post_assignment(
250
+ notebook_hash, assignment.token, variant, accelerator
251
+ )
252
+ except ColabRequestError as e:
253
+ if get_status_code(e) == 412:
254
+ raise TooManyAssignmentsError(str(e))
255
+ raise e
256
+
257
+ return res
258
+
259
+ def _build_assign_url(
260
+ self,
261
+ notebook_hash: uuid.UUID,
262
+ variant: Optional[Variant] = None,
263
+ accelerator: Optional[Accelerator] = None,
264
+ ) -> str:
265
+ url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/assign")
266
+ params = {"nbh": uuid_to_web_safe_base64(notebook_hash)}
267
+ if variant:
268
+ params["variant"] = variant.value
269
+ if accelerator:
270
+ params["accelerator"] = accelerator.value
271
+
272
+ req = requests.Request("GET", url, params=params)
273
+ prep = req.prepare()
274
+ return prep.url
275
+
276
+ def _get_assignment(
277
+ self,
278
+ notebook_hash: uuid.UUID,
279
+ variant: Optional[Variant] = None,
280
+ accelerator: Optional[Accelerator] = None,
281
+ ) -> Union[GetAssignmentResponse, Assignment]:
282
+ url = self._build_assign_url(notebook_hash, variant, accelerator)
283
+ return self._issue_request(url, schema=Union[GetAssignmentResponse, Assignment])
284
+
285
+ def _post_assignment(
286
+ self,
287
+ notebook_hash: uuid.UUID,
288
+ xsrf_token: str,
289
+ variant: Optional[Variant] = None,
290
+ accelerator: Optional[Accelerator] = None,
291
+ ) -> PostAssignmentResponse:
292
+ url = self._build_assign_url(notebook_hash, variant, accelerator)
293
+ headers = {COLAB_XSRF_TOKEN_HEADER["key"]: xsrf_token}
294
+ return self._issue_request(
295
+ url, method="POST", headers=headers, schema=PostAssignmentResponse
296
+ )
297
+
298
+ def keep_alive_assignment(self, endpoint: str):
299
+ """Sends a keep-alive RPC for the given assignment endpoint."""
300
+ url = urljoin(
301
+ self.colab_api_domain,
302
+ "/$rpc/google.internal.colab.v1.RuntimeService/KeepAliveAssignment",
303
+ )
304
+ headers = {
305
+ "Content-Type": "application/json+protobuf",
306
+ "x-user-agent": "grpc-web-javascript/0.1",
307
+ "x-goog-api-client": "grpc-web/0.1",
308
+ }
309
+ # KeepAliveAssignmentRequest is a list containing the endpoint string
310
+ return self._issue_request(url, method="POST", headers=headers, json=[endpoint])
@@ -0,0 +1,14 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
@@ -0,0 +1,265 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import datetime
16
+ import os
17
+ import sys
18
+ import json
19
+ from typing import Optional, List
20
+ import typer
21
+ from typing_extensions import Annotated
22
+
23
+ from app.colab_cli.runtime import ColabRuntime
24
+ from app.colab_cli.contents import ContentsClient
25
+ from app.colab_cli.auth import get_credentials
26
+ from app.colab_cli.utils import get_status_code
27
+
28
+
29
+ # Default execute() timeout for human-in-the-loop automations (auth /
30
+ # drivemount). The kernel goes silent while the user completes a browser
31
+ # OAuth flow, which can routinely take 30s+; the upstream 10s default
32
+ # raises ``TimeoutError`` mid-flow even though the mount actually succeeds.
33
+ # 10 minutes is long enough for any realistic interactive auth ceremony
34
+ # without leaving CI hangs unbounded.
35
+ INTERACTIVE_AUTOMATION_TIMEOUT_SEC = 600
36
+
37
+
38
+ def run_automation(
39
+ name: str,
40
+ op: str,
41
+ code: str,
42
+ allow_stdin: bool = False,
43
+ path: str = None,
44
+ timeout: Optional[float] = None,
45
+ ):
46
+ from app.colab_cli.common import state
47
+
48
+ s = state.store.get(name)
49
+ runtime = ColabRuntime(s.url, s.token, session_name=s.name, history=state.history)
50
+
51
+ def drivefs_hook(deserialize_msg, wsclient):
52
+ content = deserialize_msg.get("content", {})
53
+ if content.get("request", {}).get("authType") == "dfs_ephemeral":
54
+ msg_id = deserialize_msg.get("metadata", {}).get("colab_msg_id")
55
+ state.history.log_event(
56
+ s.name,
57
+ "colab_request",
58
+ {"type": "dfs_ephemeral", "colab_msg_id": msg_id},
59
+ )
60
+ url = f"{state.client.colab_domain}/tun/m/credentials-propagation/{s.endpoint}"
61
+ params = {
62
+ "authuser": "0",
63
+ "authtype": "dfs_ephemeral",
64
+ "version": "2",
65
+ "dryrun": "true",
66
+ "propagate": "true",
67
+ "record": "false",
68
+ }
69
+ typer.echo(
70
+ f"\n[colab] Intercepted Drive Auth Request. Connecting to {state.client.colab_domain}..."
71
+ )
72
+
73
+ creds = get_credentials(
74
+ state.client_oauth_config, provider=state.auth_provider
75
+ )
76
+ resp = creds.request("GET", url, params=params)
77
+ token = (
78
+ json.loads(resp.text.split("\n", 1)[-1]).get("token")
79
+ if get_status_code(resp) == 200
80
+ else None
81
+ )
82
+
83
+ headers = {"x-goog-colab-token": token}
84
+ resp = creds.request(
85
+ "POST",
86
+ url,
87
+ params=params,
88
+ headers=headers,
89
+ files={"file_id": (None, "empty.ipynb")},
90
+ )
91
+ data = json.loads(resp.text.split("\n", 1)[-1])
92
+
93
+ if not data.get("success"):
94
+ uri = data.get("unauthorized_redirect_uri")
95
+ typer.echo(
96
+ f"\n[colab] REQUIRED: Google Drive Authorization needed.\nPlease visit:\n\n{uri}\n"
97
+ )
98
+ state.history.log_event(s.name, "drive_auth_needed", {"uri": uri})
99
+ sys.stdout.write("Press Enter after you have granted access... ")
100
+ sys.stdout.flush()
101
+ with open("/dev/tty") as tty:
102
+ tty.readline()
103
+
104
+ typer.echo("[colab] Authorizing VM...")
105
+ params["dryrun"] = "false"
106
+ resp = creds.request(
107
+ "POST",
108
+ url,
109
+ params=params,
110
+ headers=headers,
111
+ files={"file_id": (None, "empty.ipynb")},
112
+ )
113
+ if get_status_code(resp) == 200:
114
+ typer.echo("[colab] Credentials propagated. Resuming mount...")
115
+ state.history.log_event(s.name, "drive_auth_success", {})
116
+ reply = wsclient.session.msg(
117
+ "input_reply",
118
+ {"value": {"type": "colab_reply", "colab_msg_id": msg_id}},
119
+ )
120
+ if "header" in deserialize_msg:
121
+ reply["parent_header"] = deserialize_msg["header"]
122
+ wsclient.stdin_channel.send(reply)
123
+ else:
124
+ typer.echo(
125
+ f"[colab] Error propagating: {get_status_code(resp)} {resp.text}"
126
+ )
127
+ return True
128
+ return False
129
+
130
+ runtime.colab_request_hook = drivefs_hook
131
+ try:
132
+ s.running = f"automation({op})"
133
+ s.last_execution = (
134
+ f"automation:{op}",
135
+ None,
136
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
137
+ )
138
+ state.store.add(s)
139
+
140
+ if op == "drivemount":
141
+ state.history.log_event(
142
+ name, "automation", {"op": "drivemount", "path": path, "code": code}
143
+ )
144
+ else:
145
+ state.history.log_event(name, "automation", {"op": op, "code": code})
146
+
147
+ outputs = runtime.execute_code(code, allow_stdin=allow_stdin, timeout=timeout)
148
+ state.history.log_event(
149
+ name, "automation_result", {"op": op, "outputs": outputs}
150
+ )
151
+
152
+ for out in outputs:
153
+ if "text" in out:
154
+ sys.stdout.write(out["text"])
155
+ elif "data" in out:
156
+ if "text/plain" in out["data"]:
157
+ typer.echo(out["data"]["text/plain"])
158
+ elif out.get("output_type") == "error":
159
+ ename = out.get("ename", "Error")
160
+ evalue = out.get("evalue", "")
161
+ tb = out.get("traceback", [])
162
+ if tb:
163
+ sys.stderr.write("".join(tb) + "\n")
164
+ else:
165
+ sys.stderr.write(f"{ename}: {evalue}\n")
166
+ finally:
167
+ s.running = None
168
+ state.store.add(s)
169
+ runtime.stop()
170
+
171
+
172
+ def auth(
173
+ session: Annotated[
174
+ Optional[str], typer.Option("-s", "--session", help="Session name")
175
+ ] = None,
176
+ ):
177
+ """Authenticate with Google on the VM"""
178
+ from app.colab_cli.common import state
179
+
180
+ name = state.resolve_session(session)
181
+ code = "import os\nos.environ['USE_AUTH_EPHEM'] = '0'\nfrom google.colab import auth\nauth.authenticate_user()"
182
+ typer.echo(f"[colab] Starting Google Auth flow on {name}...")
183
+ run_automation(
184
+ name,
185
+ "auth",
186
+ code,
187
+ allow_stdin=True,
188
+ timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC,
189
+ )
190
+
191
+
192
+ def drivemount(
193
+ session: Annotated[
194
+ Optional[str], typer.Option("-s", "--session", help="Session name")
195
+ ] = None,
196
+ path: Annotated[str, typer.Argument(help="Mount path")] = "/content/drive",
197
+ ):
198
+ """Mount Google Drive at path"""
199
+ from app.colab_cli.common import state
200
+
201
+ name = state.resolve_session(session)
202
+ code = f"from google.colab import drive\ndrive.mount('{path}')"
203
+ typer.echo(f"[colab] Mounting Google Drive to '{path}' on {name}...")
204
+ run_automation(
205
+ name,
206
+ "drivemount",
207
+ code,
208
+ allow_stdin=True,
209
+ path=path,
210
+ timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC,
211
+ )
212
+
213
+
214
+ def install(
215
+ session: Annotated[
216
+ Optional[str], typer.Option("-s", "--session", help="Session name")
217
+ ] = None,
218
+ packages: Annotated[
219
+ Optional[List[str]], typer.Argument(help="Packages to install")
220
+ ] = None,
221
+ requirement: Annotated[
222
+ Optional[str], typer.Option("-r", "--requirement", help="Requirements file")
223
+ ] = None,
224
+ ):
225
+ """Install python packages on the VM"""
226
+ from app.colab_cli.common import state
227
+
228
+ name = state.resolve_session(session)
229
+ if not packages and not requirement:
230
+ typer.echo("[colab] No packages or requirements specified.")
231
+ raise typer.Exit(1)
232
+
233
+ commands = []
234
+ if requirement:
235
+ if not os.path.isfile(requirement):
236
+ typer.echo(f"[colab] Requirements file '{requirement}' not found locally.")
237
+ raise typer.Exit(1)
238
+ contents = ContentsClient(state.store.get(name))
239
+ remote_path = f"content/{os.path.basename(requirement)}"
240
+ contents.upload(requirement, remote_path)
241
+ commands.extend(["-r", f"/{remote_path}"])
242
+ if packages:
243
+ commands.extend(packages)
244
+
245
+ cmd_str = ", ".join(f"'{c}'" for c in commands)
246
+ code = f"""
247
+ import subprocess, sys
248
+ def install():
249
+ packages = [{cmd_str}]
250
+ try:
251
+ subprocess.check_call(['uv', 'pip', 'install', '--system'] + packages)
252
+ print('Installation Complete (via uv)!')
253
+ except:
254
+ subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + packages)
255
+ print('Installation Complete (via pip)!')
256
+ install()
257
+ """
258
+ typer.echo(f"[colab] Installing packages on {name} (preferring uv)...")
259
+ run_automation(name, "install", code)
260
+
261
+
262
+ def register(app: typer.Typer):
263
+ app.command(hidden=True)(auth)
264
+ app.command()(drivemount)
265
+ app.command()(install)