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,172 @@
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 json
16
+ import logging
17
+ import os
18
+ import signal
19
+ import sys
20
+ import termios
21
+ import threading
22
+ import time
23
+ import tty
24
+ from urllib.parse import urlparse
25
+
26
+ import websocket
27
+
28
+ from app.colab_cli.state import SessionState
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Global flag to stop the read thread when the websocket closes
33
+ _is_running = False
34
+ _last_error = None
35
+
36
+ # When stdin is piped and reaches EOF, we send "exit\n" to the remote shell and
37
+ # then wait this many seconds for any remaining output (the shell's goodbye,
38
+ # tmux teardown messages, etc.) to flush before closing the websocket from the
39
+ # client side. Empirically 0.5s is enough for the typical /colab/tty backend
40
+ # wrapped in tmux + bash; bumping it just delays exit, lowering it risks
41
+ # truncating tail output.
42
+ PIPED_EOF_GRACE_SECONDS = 0.5
43
+
44
+
45
+ def on_message(ws, message):
46
+ """Callback for when a message is received from the server."""
47
+ try:
48
+ data = json.loads(message)
49
+ if "data" in data:
50
+ # The backend sends raw ANSI escape sequences and string content.
51
+ # We write it directly to stdout buffer to avoid python print() formatting.
52
+ sys.stdout.buffer.write(data["data"].encode("utf-8"))
53
+ sys.stdout.buffer.flush()
54
+ except Exception as e:
55
+ logger.debug(f"Error parsing message: {e}")
56
+
57
+
58
+ def on_error(ws, error):
59
+ """Callback for when a websocket error occurs."""
60
+ global _last_error
61
+ _last_error = error
62
+ logger.error(f"WebSocket Error: {error}")
63
+
64
+
65
+ def on_close(ws, close_status_code, close_msg):
66
+ """Callback for when the websocket is closed."""
67
+ global _is_running
68
+ _is_running = False
69
+
70
+
71
+ def send_terminal_size(ws):
72
+ """Sends the current terminal size to the remote backend."""
73
+ try:
74
+ size = os.get_terminal_size()
75
+ payload = json.dumps({"cols": size.columns, "rows": size.lines})
76
+ ws.send(payload)
77
+ except Exception as e:
78
+ logger.debug(f"Failed to send terminal size: {e}")
79
+
80
+
81
+ def on_open(ws):
82
+ """Callback for when the websocket connection is opened."""
83
+ global _is_running
84
+ _is_running = True
85
+
86
+ # Send initial terminal size
87
+ send_terminal_size(ws)
88
+
89
+ # Setup the background thread to read from stdin
90
+ def read_stdin():
91
+ is_tty = sys.stdin.isatty()
92
+ while _is_running:
93
+ try:
94
+ # Read a single character (or escape sequence byte)
95
+ char = sys.stdin.read(1)
96
+ if not char:
97
+ if not is_tty:
98
+ # Piped input has reached EOF. The remote /colab/tty
99
+ # endpoint wraps bash in tmux which intercepts \x04
100
+ # (Ctrl-D) as a literal character, so it never exits.
101
+ # Instead send "exit\n" so bash voluntarily terminates,
102
+ # wait a short grace period for the shell's goodbye
103
+ # output to drain back to us, then close the websocket
104
+ # ourselves to guarantee the client unblocks.
105
+ try:
106
+ ws.send(json.dumps({"data": "exit\n"}))
107
+ except Exception:
108
+ pass
109
+ time.sleep(PIPED_EOF_GRACE_SECONDS)
110
+ try:
111
+ ws.close()
112
+ except Exception:
113
+ pass
114
+ break
115
+ ws.send(json.dumps({"data": char}))
116
+ except Exception:
117
+ break
118
+
119
+ thread = threading.Thread(target=read_stdin, daemon=True)
120
+ thread.start()
121
+
122
+
123
+ def connect_console(session: SessionState):
124
+ """
125
+ Connects to the Colab TTY endpoint and sets up a raw terminal session.
126
+ """
127
+ global _is_running, _last_error
128
+ _last_error = None
129
+
130
+ # Construct the WebSocket URL from the base URL
131
+ parsed = urlparse(session.url)
132
+ ws_scheme = "wss" if parsed.scheme == "https" else "ws"
133
+ ws_url = f"{ws_scheme}://{parsed.netloc}/colab/tty?colab-runtime-proxy-token={session.token}"
134
+
135
+ is_tty = sys.stdin.isatty()
136
+ fd = sys.stdin.fileno() if is_tty else None
137
+ old_settings = termios.tcgetattr(fd) if is_tty else None
138
+
139
+ ws = websocket.WebSocketApp(
140
+ url=ws_url,
141
+ on_open=on_open,
142
+ on_message=on_message,
143
+ on_error=on_error,
144
+ on_close=on_close,
145
+ )
146
+
147
+ def handle_sigwinch(signum, frame):
148
+ """Handle window resize events."""
149
+ if _is_running:
150
+ send_terminal_size(ws)
151
+
152
+ try:
153
+ if is_tty:
154
+ tty.setraw(fd, termios.TCSANOW)
155
+ signal.signal(signal.SIGWINCH, handle_sigwinch)
156
+
157
+ # This is a blocking call until the connection is closed
158
+ ws.run_forever()
159
+
160
+ if _last_error:
161
+ # Re-raise or wrap terminal errors
162
+ err_msg = str(_last_error)
163
+ if "404" in err_msg or "401" in err_msg:
164
+ # We raise a standard exception that the caller can recognize
165
+ raise RuntimeError(f"Connection failed: {err_msg}")
166
+ finally:
167
+ if is_tty:
168
+ # Always ensure the terminal is restored to its original state
169
+ termios.tcsetattr(fd, termios.TCSANOW, old_settings)
170
+ # Restore the default signal handler for resize
171
+ signal.signal(signal.SIGWINCH, signal.SIG_DFL)
172
+ print("\r\nConnection closed.")
@@ -0,0 +1,93 @@
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 base64
16
+ from urllib.parse import quote
17
+
18
+ import requests
19
+
20
+ from app.colab_cli.state import SessionState
21
+ from app.colab_cli.utils import get_status_code
22
+
23
+
24
+ class ContentsClient:
25
+ def __init__(self, session_state: SessionState):
26
+ self.base_url = session_state.url.rstrip("/")
27
+ self.token = session_state.token
28
+
29
+ def _request(
30
+ self, method: str, path: str, params: dict = None, json_data: dict = None
31
+ ):
32
+ # Quote the path, but don't encode slashes so directory paths stay intact
33
+ quoted_path = quote(path.strip("/"), safe="/")
34
+ url = f"{self.base_url}/api/contents/{quoted_path}"
35
+
36
+ req_params = {"authuser": "0", "colab-runtime-proxy-token": self.token}
37
+ if params:
38
+ req_params.update(params)
39
+
40
+ response = requests.request(method, url, params=req_params, json=json_data)
41
+
42
+ if get_status_code(response) == 404:
43
+ raise FileNotFoundError(f"File or directory not found: {path}")
44
+
45
+ response.raise_for_status()
46
+
47
+ # DELETE doesn't return JSON
48
+ if method == "DELETE":
49
+ return None
50
+
51
+ return response.json()
52
+
53
+ def list_dir(self, path: str):
54
+ return self._request("GET", path)
55
+
56
+ def upload(self, local_path: str, remote_path: str):
57
+ with open(local_path, "rb") as f:
58
+ content = f.read()
59
+
60
+ b64_content = base64.b64encode(content).decode("ascii")
61
+ filename = remote_path.split("/")[-1]
62
+
63
+ payload = {
64
+ "name": filename,
65
+ "path": remote_path,
66
+ "type": "file",
67
+ "format": "base64",
68
+ "content": b64_content,
69
+ "chunk": 1,
70
+ }
71
+
72
+ return self._request("PUT", remote_path, json_data=payload)
73
+
74
+ def download(self, remote_path: str, local_path: str):
75
+ data = self._request("GET", remote_path, params={"content": "1"})
76
+
77
+ if data.get("type") == "directory":
78
+ raise IsADirectoryError(f"Cannot download a directory: {remote_path}")
79
+
80
+ content = data.get("content", "")
81
+ fmt = data.get("format")
82
+
83
+ if fmt == "base64":
84
+ content_bytes = base64.b64decode(content)
85
+ else:
86
+ # Assume text if it's not base64 explicitly encoded
87
+ content_bytes = str(content).encode("utf-8")
88
+
89
+ with open(local_path, "wb") as f:
90
+ f.write(content_bytes)
91
+
92
+ def rm(self, remote_path: str):
93
+ self._request("DELETE", remote_path)
@@ -0,0 +1,184 @@
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 nbformat
16
+ from typing import Any, Dict, List
17
+ import json
18
+ import os
19
+ import uuid
20
+
21
+
22
+ def export_history(events: List[Dict[str, Any]], session_name: str, output_path: str):
23
+ """
24
+ Exports history based on file extension.
25
+ """
26
+ ext = os.path.splitext(output_path)[1].lower()
27
+
28
+ if ext == ".ipynb":
29
+ nb = convert_history_to_ipynb(events, session_name)
30
+ with open(output_path, "w", encoding="utf-8") as f:
31
+ nbformat.write(nb, f)
32
+
33
+ elif ext == ".jsonl":
34
+ with open(output_path, "w", encoding="utf-8") as f:
35
+ for event in events:
36
+ f.write(json.dumps(event) + "\n")
37
+
38
+ elif ext == ".md":
39
+ with open(output_path, "w", encoding="utf-8") as f:
40
+ f.write(f"# Colab Session: {session_name}\n\n")
41
+ for event in events:
42
+ ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
43
+ etype = event.get("event_type")
44
+ if etype == "execution":
45
+ code = event.get("code", "")
46
+ f.write(f"### Execution ({ts})\n```python\n{code}\n```\n\n")
47
+ for o in event.get("outputs", []):
48
+ if "text" in o:
49
+ f.write(f"**Output**:\n```\n{o['text']}```\n\n")
50
+ elif etype == "session_created":
51
+ f.write(
52
+ f"## Session Created: {ts}\n- Endpoint: `{event.get('endpoint')}`\n\n"
53
+ )
54
+ elif etype == "file_operation":
55
+ f.write(
56
+ f"*File Operation*: `{event.get('op')}` on `{event.get('path', event.get('remote', ''))}`\n\n"
57
+ )
58
+
59
+ elif ext == ".txt":
60
+ with open(output_path, "w", encoding="utf-8") as f:
61
+ f.write(f"Colab Session: {session_name}\n" + "=" * 20 + "\n\n")
62
+ for event in events:
63
+ ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
64
+ etype = event.get("event_type", "unknown")
65
+ f.write(f"[{ts}] {etype.upper()}: ")
66
+ if etype == "execution":
67
+ f.write(event.get("code", "").strip() + "\n")
68
+ else:
69
+ f.write(str(event) + "\n")
70
+
71
+ else:
72
+ print(f"[colab] Unsupported export format: {ext}")
73
+ return
74
+
75
+ print(f"[colab] Exported history to '{output_path}'.")
76
+
77
+
78
+ def convert_history_to_ipynb(
79
+ events: List[Dict[str, Any]], session_name: str
80
+ ) -> nbformat.NotebookNode:
81
+ """
82
+ Converts a list of session events to a Jupyter Notebook (v4).
83
+ """
84
+ nb = nbformat.v4.new_notebook()
85
+ nb.metadata.kernelspec = {
86
+ "display_name": "Python 3 (Google Colab)",
87
+ "language": "python",
88
+ "name": "python3",
89
+ }
90
+
91
+ title = f"# Colab Session: {session_name}\nGenerated from colab-cli history log."
92
+ cell = nbformat.v4.new_markdown_cell(title)
93
+ cell.id = str(uuid.uuid4())
94
+ nb.cells.append(cell)
95
+
96
+ for event in events:
97
+ etype = event.get("event_type")
98
+ ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
99
+
100
+ if etype == "session_created":
101
+ meta = f"**Session Created**: {ts}\n- Endpoint: `{event.get('endpoint')}`\n- Hardware: `{event.get('accelerator')}`"
102
+ cell = nbformat.v4.new_markdown_cell(meta)
103
+ cell.id = str(uuid.uuid4())
104
+ nb.cells.append(cell)
105
+
106
+ elif etype == "execution":
107
+ code = event.get("code", "")
108
+ # Check for shell commands (starting with ! or from piped console)
109
+ # If it's a raw shell command from a console pipe, wrap it in %%bash if it doesn't have !
110
+ if event.get("source") == "piped" and not code.startswith("!"):
111
+ code = "%%bash\n" + code
112
+
113
+ outputs = _map_outputs(event.get("outputs", []))
114
+ cell = nbformat.v4.new_code_cell(code, outputs=outputs)
115
+ cell.id = str(uuid.uuid4())
116
+ nb.cells.append(cell)
117
+
118
+ elif etype == "automation":
119
+ op = event.get("op")
120
+ code = event.get("code", "")
121
+ cell = nbformat.v4.new_markdown_cell(f"### Automation: {op} ({ts})")
122
+ cell.id = str(uuid.uuid4())
123
+ nb.cells.append(cell)
124
+ if code:
125
+ # Get the result from the next event if it's automation_result
126
+ cell = nbformat.v4.new_code_cell(code)
127
+ cell.id = str(uuid.uuid4())
128
+ nb.cells.append(cell)
129
+
130
+ elif etype == "automation_result":
131
+ # We can attach these outputs to the previous automation cell if we were more clever,
132
+ # but for now let's just ensure we capture the output.
133
+ if event.get("outputs"):
134
+ cell = nbformat.v4.new_code_cell(
135
+ "# Result of previous automation",
136
+ outputs=_map_outputs(event.get("outputs")),
137
+ )
138
+ cell.id = str(uuid.uuid4())
139
+ nb.cells.append(cell)
140
+
141
+ elif etype == "file_operation":
142
+ cell = nbformat.v4.new_markdown_cell(
143
+ f"*File Operation*: `{event.get('op')}` on `{event.get('path', event.get('remote', ''))}`"
144
+ )
145
+ cell.id = str(uuid.uuid4())
146
+ nb.cells.append(cell)
147
+
148
+ elif etype == "stdin_request":
149
+ cell = nbformat.v4.new_markdown_cell(
150
+ f"> **Input Requested**: {event.get('prompt')}"
151
+ )
152
+ cell.id = str(uuid.uuid4())
153
+ nb.cells.append(cell)
154
+
155
+ elif etype == "input_reply":
156
+ cell = nbformat.v4.new_markdown_cell(
157
+ f"> **User Input**: `{event.get('value')}`"
158
+ )
159
+ cell.id = str(uuid.uuid4())
160
+ nb.cells.append(cell)
161
+
162
+ return nb
163
+
164
+
165
+ def _map_outputs(outputs: List[Dict[str, Any]]) -> List[nbformat.NotebookNode]:
166
+ nb_outputs = []
167
+ for o in outputs:
168
+ otype = o.get("output_type")
169
+ if "text" in o:
170
+ nb_outputs.append(
171
+ nbformat.v4.new_output("stream", name="stdout", text=o["text"])
172
+ )
173
+ elif "data" in o:
174
+ nb_outputs.append(nbformat.v4.new_output("display_data", data=o["data"]))
175
+ elif otype == "error":
176
+ nb_outputs.append(
177
+ nbformat.v4.new_output(
178
+ "error",
179
+ ename=o.get("ename", "Error"),
180
+ evalue=o.get("evalue", ""),
181
+ traceback=o.get("traceback", []),
182
+ )
183
+ )
184
+ return nb_outputs
@@ -0,0 +1,65 @@
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 json
17
+ import os
18
+ from typing import Any, Dict, List
19
+
20
+
21
+ class HistoryLogger:
22
+ def __init__(self, log_dir: str = "~/.config/colab-cli/history"):
23
+ self.log_dir = os.path.expanduser(log_dir)
24
+ os.makedirs(self.log_dir, exist_ok=True)
25
+
26
+ def _get_log_path(self, session_name: str) -> str:
27
+ return os.path.join(self.log_dir, f"{session_name}.jsonl")
28
+
29
+ def log_event(self, session_name: str, event_type: str, data: Dict[str, Any]):
30
+ """
31
+ Appends a structured event to the session's history file.
32
+
33
+ event_types:
34
+ - session_created
35
+ - session_terminated
36
+ - execution (code + outputs)
37
+ - input_requested (stdin prompts/replies)
38
+ - file_operation (ls, rm, upload, download)
39
+ - automation (auth, install, drivemount)
40
+ """
41
+ log_path = self._get_log_path(session_name)
42
+ event = {
43
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
44
+ "event_type": event_type,
45
+ **data,
46
+ }
47
+ with open(log_path, "a", encoding="utf-8") as f:
48
+ f.write(json.dumps(event) + "\n")
49
+
50
+ def list_sessions(self) -> List[str]:
51
+ if not os.path.exists(self.log_dir):
52
+ return []
53
+ return [f[:-6] for f in os.listdir(self.log_dir) if f.endswith(".jsonl")]
54
+
55
+ def get_history(self, session_name: str) -> List[Dict[str, Any]]:
56
+ log_path = self._get_log_path(session_name)
57
+ if not os.path.exists(log_path):
58
+ return []
59
+
60
+ history = []
61
+ with open(log_path, "r", encoding="utf-8") as f:
62
+ for line in f:
63
+ if line.strip():
64
+ history.append(json.loads(line))
65
+ return history
@@ -0,0 +1,11 @@
1
+ {
2
+ "installed": {
3
+ "client_id": "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
4
+ "project_id": "colab-cli",
5
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
6
+ "token_uri": "https://oauth2.googleapis.com/token",
7
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
8
+ "client_secret": "d-FL95Q19q7MQmFpd7hHD0Ty",
9
+ "redirect_uris": ["http://localhost"]
10
+ }
11
+ }