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,436 @@
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
+ from typing import Optional
16
+
17
+ import typer
18
+ from typing_extensions import Annotated
19
+
20
+ from app.colab_cli import auto_update
21
+ from app.colab_cli.auto_update import get_app_version
22
+ from app.colab_cli.common import state
23
+
24
+
25
+ def pay():
26
+ """Open the Colab signup page to manage compute units"""
27
+ import webbrowser
28
+
29
+ url = "https://colab.research.google.com/signup"
30
+ typer.echo(f"[colab] Opening {url}...")
31
+ webbrowser.open(url)
32
+
33
+
34
+ def url(
35
+ session: Annotated[
36
+ Optional[str], typer.Option("-s", "--session", help="Session name")
37
+ ] = None,
38
+ host: Annotated[
39
+ str,
40
+ typer.Option(
41
+ "--host",
42
+ help=(
43
+ "Colab frontend host (origin) to use for the URL. The Colab "
44
+ "frontend resolves `dbu` against `window.location.origin`, "
45
+ "so this only changes the page origin, not the embedded "
46
+ "backend path."
47
+ ),
48
+ ),
49
+ ] = "https://colab.research.google.com",
50
+ open_browser: Annotated[
51
+ bool,
52
+ typer.Option(
53
+ "--open",
54
+ help=(
55
+ "After printing the URL, also open it in the system browser. "
56
+ "Off by default so the command remains pipeable "
57
+ "(e.g. `colab url -s s1 | xclip`)."
58
+ ),
59
+ ),
60
+ ] = False,
61
+ ):
62
+ """Print a browser URL that connects to an existing session.
63
+
64
+ Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>#datalabBackendUrl=<host>/tun/m/<endpoint>``,
65
+ where the path is ``/tun/m/<endpoint>``. When opened, the Colab frontend
66
+ skips ``/tun/m/assign`` and attaches the kernel to our existing VM.
67
+
68
+ Two backend-URL signals are embedded:
69
+
70
+ - ``?dbu=<urlencoded path>`` — the ``datalab_backend_url`` development
71
+ query flag. The frontend resolves the value against
72
+ ``window.location.origin``.
73
+
74
+ - ``#datalabBackendUrl=<full URL>`` — the hash-fragment form. Some
75
+ frontend code paths consult this first and ignore ``dbu``, so we
76
+ emit both for robustness. The fragment value is a FULL URL (with
77
+ scheme + host) and is intentionally NOT URL-encoded — browsers do
78
+ not decode the fragment before passing ``location.hash`` to page
79
+ JS, and Colab's hash parser expects the raw string.
80
+
81
+ The fragment's host always matches ``--host`` (the page origin), so
82
+ Colab's same-origin enforcement on the embedded backend URL doesn't
83
+ block the connection, and sandbox/dev users get a sandbox fragment
84
+ automatically.
85
+ """
86
+ # Imported here (not at module top) to mirror the lazy-state pattern used
87
+ # elsewhere in this module and avoid a circular import via colab_cli.common.
88
+ from urllib.parse import quote
89
+
90
+ from app.colab_cli.common import state
91
+
92
+ name = state.resolve_session(session)
93
+ s = state.store.get(name)
94
+ if not s:
95
+ typer.echo(f"[colab] Session '{name}' not found.", err=True)
96
+ raise typer.Exit(code=1)
97
+
98
+ # Strip a trailing slash so we don't produce `https://host//notebooks/...`
99
+ # or `https://host//tun/m/...` in the fragment URL.
100
+ host_clean = host.rstrip("/")
101
+ backend_path = f"/tun/m/{s.endpoint}"
102
+ # `dbu` value is the backend path. URL-encode it (incl. the slashes via
103
+ # `safe=""`) so the value survives any downstream non-strict query-string
104
+ # re-parsing — this is also the form shown in real Colab connect URLs.
105
+ dbu_value = quote(backend_path, safe="")
106
+ # `#datalabBackendUrl=` value is the FULL backend URL, raw (un-encoded):
107
+ # the browser does not decode the fragment before passing it to page JS,
108
+ # and Colab's hash parser calls `new URL(rawString)` directly. Pinning
109
+ # the host to `host_clean` (not hardcoding research.google.com) keeps
110
+ # this aligned with the page origin so same-origin enforcement passes
111
+ # for sandbox / dev hosts too.
112
+ fragment_value = f"{host_clean}{backend_path}"
113
+ connect_url = (
114
+ f"{host_clean}/notebooks/empty.ipynb"
115
+ f"?dbu={dbu_value}"
116
+ f"#datalabBackendUrl={fragment_value}"
117
+ )
118
+
119
+ # Print the URL on its own line with no `[colab]` prefix so the output
120
+ # is pipeable (`colab url -s s1 | xclip`, etc.).
121
+ typer.echo(connect_url)
122
+
123
+ if open_browser:
124
+ import webbrowser
125
+
126
+ webbrowser.open(connect_url)
127
+
128
+
129
+ def log(
130
+ session: Annotated[
131
+ Optional[str],
132
+ typer.Option(
133
+ "-s",
134
+ "--session",
135
+ help="Session name (if omitted, lists all sessions with logs)",
136
+ ),
137
+ ] = None,
138
+ lines: Annotated[
139
+ Optional[int],
140
+ typer.Option(
141
+ "-n", "--lines", help="Number of lines to show/export (default: all)"
142
+ ),
143
+ ] = None,
144
+ type: Annotated[
145
+ Optional[str],
146
+ typer.Option(
147
+ "-t",
148
+ "--type",
149
+ help="Filter by event type (e.g., execution, file_operation)",
150
+ ),
151
+ ] = None,
152
+ output: Annotated[
153
+ Optional[str],
154
+ typer.Option(
155
+ "-o",
156
+ "--output",
157
+ help="Output file path (suffix determines format: .ipynb, .md, .txt, .jsonl)",
158
+ ),
159
+ ] = None,
160
+ ):
161
+ """Manage and view session history logs"""
162
+ if not session:
163
+ sessions_with_logs = state.history.list_sessions()
164
+ if not sessions_with_logs:
165
+ typer.echo("[colab] No session history found.")
166
+ else:
167
+ typer.echo("[colab] Sessions with history logs:")
168
+ for n in sorted(sessions_with_logs):
169
+ typer.echo(f" {n}")
170
+ return
171
+
172
+ events = state.history.get_history(session)
173
+ if not events:
174
+ typer.echo(f"[colab] No history found for session '{session}'.")
175
+ return
176
+
177
+ if type:
178
+ events = [e for e in events if e.get("event_type") == type]
179
+
180
+ if lines:
181
+ events = events[-lines:]
182
+
183
+ if output:
184
+ from app.colab_cli.converter import export_history
185
+
186
+ export_history(events, session, output)
187
+ else:
188
+ for event in events:
189
+ ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
190
+ etype = event.get("event_type", "unknown")
191
+
192
+ if etype == "execution":
193
+ preview = event.get("code", "").strip().split("\n")[0][:60]
194
+ typer.echo(f"[{ts}] EXEC: {preview}...")
195
+ elif etype == "file_operation":
196
+ typer.echo(
197
+ f"[{ts}] FILE: {event.get('op')} {event.get('path', event.get('remote', ''))}"
198
+ )
199
+ elif etype == "automation":
200
+ typer.echo(f"[{ts}] AUTO: {event.get('op')}")
201
+ elif etype == "stdin_request":
202
+ typer.echo(f"[{ts}] INPT: {event.get('prompt', '').strip()}")
203
+ elif etype == "input_reply":
204
+ typer.echo(f"[{ts}] RPLY: {event.get('value', '').strip()}")
205
+ elif etype == "keep_alive_started":
206
+ typer.echo(
207
+ f"[{ts}] KEEP: started endpoint={event.get('endpoint')} pid={event.get('pid')}"
208
+ )
209
+ elif etype == "keep_alive_error":
210
+ msg = (
211
+ f"[{ts}] KEEP: error iter={event.get('iteration')} "
212
+ f"status={event.get('status_code')} "
213
+ f"type={event.get('error_type')} "
214
+ f"msg={event.get('error', '')[:120]}"
215
+ )
216
+ body = event.get("response_body")
217
+ if body:
218
+ msg += f" body={body[:300]}"
219
+ typer.echo(msg)
220
+ elif etype == "keep_alive_stopped":
221
+ msg = (
222
+ f"[{ts}] KEEP: stopped reason={event.get('reason')} "
223
+ f"iters={event.get('iterations')} "
224
+ f"duration={event.get('duration_seconds')}s"
225
+ )
226
+ last_err = event.get("last_error")
227
+ if last_err:
228
+ msg += (
229
+ f" last_error=[status={last_err.get('status_code')} "
230
+ f"type={last_err.get('error_type')} "
231
+ f"msg={str(last_err.get('error', ''))[:120]}]"
232
+ )
233
+ if event.get("expected_endpoint") or event.get("actual_endpoint"):
234
+ msg += (
235
+ f" expected={event.get('expected_endpoint')} "
236
+ f"actual={event.get('actual_endpoint')}"
237
+ )
238
+ typer.echo(msg)
239
+ else:
240
+ typer.echo(f"[{ts}] EVENT: {etype}")
241
+
242
+
243
+ def whoami():
244
+ """[debug] Print the active credentials' identity, scopes, and expiry.
245
+
246
+ Mints an access token using the same path the rest of the CLI uses
247
+ (`auth.get_credentials(...)` honoring the global `--auth=...` flag),
248
+ then queries Google's tokeninfo endpoint and prints a human-readable
249
+ summary. Useful when debugging "why is my call to
250
+ colab.pa.googleapis.com 403-ing" — the answer is almost always a
251
+ missing scope or a token whose `email` doesn't match what you
252
+ expected.
253
+
254
+ Hidden from `colab --help` because end users shouldn't need it; reach
255
+ it via `colab whoami --help` or by knowing the name.
256
+ """
257
+ import json
258
+ import urllib.error
259
+ import urllib.parse
260
+ import urllib.request
261
+
262
+ from app.colab_cli.auth import get_credentials
263
+
264
+ provider = state.auth_provider
265
+
266
+ # Mint a fresh token. Some credential types (service-account, GCE, some
267
+ # impersonated creds) don't populate `.token` until refresh() is called,
268
+ # so we always refresh — cheap, ~1 RPC, and avoids a confusing
269
+ # `creds.token is None` failure mode for valid credentials.
270
+ sess = get_credentials(state.client_oauth_config, provider=provider)
271
+ creds = sess.credentials
272
+ try:
273
+ from google.auth.transport.requests import Request as _GoogleAuthRequest
274
+
275
+ creds.refresh(_GoogleAuthRequest())
276
+ except Exception as e:
277
+ typer.echo(f"[colab] whoami: failed to refresh credentials: {e}", err=True)
278
+ raise typer.Exit(code=1)
279
+
280
+ token = creds.token
281
+ if not token:
282
+ typer.echo(
283
+ "[colab] whoami: credentials have no access token after refresh; "
284
+ "the auth provider may have failed silently.",
285
+ err=True,
286
+ )
287
+ raise typer.Exit(code=1)
288
+
289
+ # Hit Google's tokeninfo endpoint. We use stdlib urllib (rather than the
290
+ # already-authorized `sess`) deliberately: tokeninfo accepts the token as
291
+ # a query parameter and does NOT want a Bearer header alongside it.
292
+ qs = urllib.parse.urlencode({"access_token": token})
293
+ url = f"https://oauth2.googleapis.com/tokeninfo?{qs}"
294
+ try:
295
+ with urllib.request.urlopen(url, timeout=10) as resp:
296
+ body = resp.read().decode("utf-8")
297
+ info = json.loads(body)
298
+ except urllib.error.HTTPError as e:
299
+ # tokeninfo returns 400 for invalid/expired/revoked tokens with a
300
+ # JSON body like {"error":"invalid_token","error_description":"..."}.
301
+ # Surface that body so the developer can see *why* it was rejected.
302
+ try:
303
+ err_body = e.read().decode("utf-8")
304
+ except Exception:
305
+ err_body = ""
306
+ typer.echo(
307
+ f"[colab] whoami: tokeninfo returned HTTP {e.code}: {err_body or e.reason}",
308
+ err=True,
309
+ )
310
+ raise typer.Exit(code=1)
311
+ except Exception as e:
312
+ typer.echo(f"[colab] whoami: tokeninfo request failed: {e}", err=True)
313
+ raise typer.Exit(code=1)
314
+
315
+ # Format. Provider name from the AuthProvider enum (e.g. "adc"); email
316
+ # may be missing for tokens scoped without `userinfo.email`, in which
317
+ # case we say so explicitly rather than printing "Email: None".
318
+ email = info.get("email") or "<unavailable: token has no userinfo.email scope>"
319
+ expires_in = info.get("expires_in")
320
+ try:
321
+ expires_min = int(expires_in) // 60
322
+ expires_str = f"{expires_min}m"
323
+ except (TypeError, ValueError):
324
+ expires_str = str(expires_in) if expires_in else "<unknown>"
325
+
326
+ audience = info.get("audience") or info.get("aud") or "<none>"
327
+ scopes = (info.get("scope") or "").split()
328
+
329
+ typer.echo(f"Auth provider: {provider.value}")
330
+ typer.echo(f"Email: {email}")
331
+ typer.echo(f"Audience: {audience}")
332
+ typer.echo(f"Expires in: {expires_str}")
333
+ if scopes:
334
+ typer.echo("Scopes:")
335
+ for s in sorted(scopes):
336
+ typer.echo(f" - {s}")
337
+ else:
338
+ typer.echo("Scopes: <none>")
339
+
340
+
341
+ def version_command():
342
+ """Show the version of the Colab CLI"""
343
+ typer.echo(f"Version: {get_app_version()}")
344
+
345
+
346
+ def update_command(
347
+ install: Annotated[
348
+ bool,
349
+ typer.Option(
350
+ "--install",
351
+ help=(
352
+ "After checking, run 'pip install -U google-colab-cli' to "
353
+ "upgrade the CLI in place. No-op if already up to date. "
354
+ "Linux only."
355
+ ),
356
+ ),
357
+ ] = False,
358
+ ):
359
+ """Check for latest version and print if an update is available"""
360
+ auto_update.check_for_updates(quiet=False)
361
+ if not install:
362
+ return
363
+
364
+ if not auto_update.is_self_install_supported():
365
+ typer.echo(
366
+ "[colab] '--install' self-install is only supported on Linux and macOS.",
367
+ err=True,
368
+ )
369
+ raise typer.Exit(code=1)
370
+
371
+ # Skip the install when the current version already matches (or exceeds)
372
+ # the latest known version, to avoid an unnecessary subprocess call.
373
+ settings = state.settings_store.load()
374
+ if settings.latest_version and not auto_update._is_newer(
375
+ settings.latest_version, auto_update.get_app_version()
376
+ ):
377
+ return
378
+
379
+ auto_update.self_install()
380
+
381
+
382
+ def _print_resource(filename: str) -> None:
383
+ import importlib.resources
384
+ import os
385
+
386
+ content = None
387
+ try:
388
+ # Try reading from package resources
389
+ ref = importlib.resources.files("colab_cli").joinpath(filename)
390
+ if ref.is_file():
391
+ content = ref.read_text(encoding="utf-8")
392
+ except Exception:
393
+ pass
394
+
395
+ if not content:
396
+ # Fallback to local file for development
397
+ local_path = os.path.abspath(
398
+ os.path.join(os.path.dirname(__file__), f"../../../{filename}")
399
+ )
400
+ if os.path.exists(local_path):
401
+ try:
402
+ with open(local_path, "r", encoding="utf-8") as f:
403
+ content = f.read()
404
+ except Exception:
405
+ pass
406
+
407
+ if content:
408
+ typer.echo(content)
409
+ else:
410
+ typer.echo(f"[colab] {filename} content not available.", err=True)
411
+ raise typer.Exit(code=1)
412
+
413
+
414
+ def readme():
415
+ """Print the bundled README.md file"""
416
+ _print_resource("README.md")
417
+
418
+
419
+ def skill():
420
+ """Print the bundled COLAB_SKILL.md file"""
421
+ _print_resource("COLAB_SKILL.md")
422
+
423
+
424
+ def register(app: typer.Typer):
425
+ app.command()(pay)
426
+ app.command()(log)
427
+ app.command(name="url")(url)
428
+ app.command(name="version")(version_command)
429
+ app.command(name="update")(update_command)
430
+ # Developer-only debugging aid; hidden from `colab --help` but still
431
+ # reachable via `colab whoami` / `colab whoami --help`.
432
+ app.command(name="whoami", hidden=True)(whoami)
433
+ app.command(name="readme")(readme)
434
+ app.command(name="README", hidden=True)(readme)
435
+ app.command(name="skill")(skill)
436
+ app.command(name="SKILL", hidden=True)(skill)
@@ -0,0 +1,185 @@
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 logging
16
+ import os
17
+ import signal
18
+ import sys
19
+ import time
20
+ from typing import Optional
21
+
22
+ import typer
23
+
24
+ from app.colab_cli.auth import AuthProvider, get_credentials
25
+ from app.colab_cli.client import Client, Prod
26
+ from app.colab_cli.history import HistoryLogger
27
+ from app.colab_cli.state import StateStore, SettingsStore
28
+
29
+
30
+ class State:
31
+ def __init__(self):
32
+ self.client_oauth_config = os.path.expanduser("~/.colab-cli-oauth-config.json")
33
+ self.config_path = None
34
+ self.logtostderr = False
35
+ self.auth_provider = AuthProvider.OAUTH2
36
+ self._client = None
37
+ self._store = None
38
+ self._settings_store = None
39
+ self._history = None
40
+ self._sessions = None
41
+
42
+ @property
43
+ def store(self):
44
+ if self._store is None:
45
+ self._store = StateStore(self.config_path)
46
+ return self._store
47
+
48
+ @property
49
+ def settings_store(self):
50
+ if self._settings_store is None:
51
+ # We don't currently allow overriding settings path via CLI,
52
+ # but we could if needed. For now, use default.
53
+ self._settings_store = SettingsStore()
54
+ return self._settings_store
55
+
56
+ @property
57
+ def history(self):
58
+ if self._history is None:
59
+ self._history = HistoryLogger()
60
+ return self._history
61
+
62
+ @property
63
+ def client(self):
64
+ if self._client is None:
65
+ creds = get_credentials(
66
+ self.client_oauth_config, provider=self.auth_provider
67
+ )
68
+ self._client = Client(Prod(), creds)
69
+ return self._client
70
+
71
+ def prune_session(self, name: str):
72
+ """Removes a session from local state and kills its keep-alive process."""
73
+ s = self.store.get(name)
74
+ if s and s.keep_alive_pid:
75
+ kill_process(s.keep_alive_pid)
76
+ self.store.remove(name)
77
+ if self._sessions and name in self._sessions:
78
+ del self._sessions[name]
79
+ self.history.log_event(name, "session_terminated", {"reason": "pruned"})
80
+
81
+ def sync_sessions(self):
82
+ if self._sessions is not None:
83
+ return self._sessions, self.client.list_assignments()
84
+
85
+ # Check local store first. If it's empty, we don't necessarily need to hit the backend
86
+ # unless we are specifically looking for server-side assignments (e.g. 'colab sessions').
87
+ local_sessions = self.store.list()
88
+ if not local_sessions:
89
+ self._sessions = {}
90
+ # We still need to return assignments for 'colab sessions' to work
91
+ # But we only trigger client creation (and thus auth) if we have to.
92
+ try:
93
+ assignments = self.client.list_assignments()
94
+ except SystemExit:
95
+ # If auth fails, we just return empty assignments
96
+ assignments = []
97
+ return self._sessions, assignments
98
+
99
+ assignments = self.client.list_assignments()
100
+ active_endpoints = {a.endpoint for a in assignments}
101
+
102
+ self._sessions = local_sessions
103
+ pruned = 0
104
+ for name, s in list(self._sessions.items()):
105
+ if s.endpoint not in active_endpoints:
106
+ self.prune_session(name)
107
+ pruned += 1
108
+
109
+ if pruned > 0:
110
+ typer.echo(f"[colab] Pruned {pruned} stale local session(s).")
111
+
112
+ return self._sessions, assignments
113
+
114
+ def resolve_session(self, session_name: Optional[str]) -> str:
115
+ if session_name:
116
+ return session_name
117
+
118
+ # Check local store first to avoid hitting the backend (and triggering auth) if we don't have to
119
+ local_sessions = self.store.list()
120
+ if not local_sessions:
121
+ typer.echo(
122
+ "[colab] Error: No active sessions found. Create one with 'colab new'."
123
+ )
124
+ raise typer.Exit(1)
125
+
126
+ # If we have local sessions, we need to sync to make sure they are still valid.
127
+ # This will trigger auth if valid credentials are not present.
128
+ sessions, _ = self.sync_sessions()
129
+ active_names = list(sessions.keys())
130
+
131
+ if len(active_names) == 1:
132
+ name = active_names[0]
133
+ typer.echo(f"[colab] Using unique session '{name}'.")
134
+ return name
135
+ elif len(active_names) > 1:
136
+ typer.echo(
137
+ f"[colab] Error: Multiple active sessions found. Specify one with -s: {', '.join(active_names)}"
138
+ )
139
+ raise typer.Exit(1)
140
+ else:
141
+ typer.echo(
142
+ "[colab] Error: No active sessions found. Create one with 'colab new'."
143
+ )
144
+ raise typer.Exit(1)
145
+
146
+
147
+ state = State()
148
+
149
+
150
+ def kill_process(pid: int):
151
+ """Safely terminates a process by PID."""
152
+ if not pid:
153
+ return
154
+ try:
155
+ os.kill(pid, signal.SIGTERM)
156
+ # Give it a moment to exit
157
+ for _ in range(5):
158
+ time.sleep(0.1)
159
+ os.kill(pid, 0)
160
+ except OSError:
161
+ # Already dead
162
+ pass
163
+ except Exception:
164
+ logging.debug(f"Failed to kill process {pid}")
165
+
166
+
167
+ def setup_logging(log_to_stderr: bool):
168
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
169
+ logger = logging.getLogger()
170
+ logger.setLevel(logging.DEBUG)
171
+
172
+ requests_log = logging.getLogger("urllib3")
173
+ requests_log.setLevel(logging.DEBUG)
174
+ requests_log.propagate = True
175
+
176
+ log_dir = os.path.expanduser("~/.config/colab-cli")
177
+ os.makedirs(log_dir, exist_ok=True)
178
+ file_handler = logging.FileHandler(os.path.join(log_dir, "colab.log"))
179
+ file_handler.setFormatter(logging.Formatter(log_format))
180
+ logger.addHandler(file_handler)
181
+
182
+ if log_to_stderr:
183
+ stream_handler = logging.StreamHandler(sys.stderr)
184
+ stream_handler.setFormatter(logging.Formatter(log_format))
185
+ logger.addHandler(stream_handler)