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.
- package/cli.js +148 -0
- package/core/app/__init__.py +0 -0
- package/core/app/colab_cli/__init__.py +0 -0
- package/core/app/colab_cli/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auth.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auto_update.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/cli.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/client.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/common.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/console.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/contents.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/history.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/runtime.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/state.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/utils.cpython-312.pyc +0 -0
- package/core/app/colab_cli/auth.py +278 -0
- package/core/app/colab_cli/auto_update.py +248 -0
- package/core/app/colab_cli/cli.py +155 -0
- package/core/app/colab_cli/client.py +310 -0
- package/core/app/colab_cli/commands/__init__.py +14 -0
- package/core/app/colab_cli/commands/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/automation.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/execution.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/files.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/run.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/session.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/utility.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/automation.py +265 -0
- package/core/app/colab_cli/commands/execution.py +362 -0
- package/core/app/colab_cli/commands/files.py +204 -0
- package/core/app/colab_cli/commands/run.py +477 -0
- package/core/app/colab_cli/commands/session.py +519 -0
- package/core/app/colab_cli/commands/utility.py +436 -0
- package/core/app/colab_cli/common.py +185 -0
- package/core/app/colab_cli/console.py +172 -0
- package/core/app/colab_cli/contents.py +93 -0
- package/core/app/colab_cli/converter.py +184 -0
- package/core/app/colab_cli/history.py +65 -0
- package/core/app/colab_cli/oauth_config.json +11 -0
- package/core/app/colab_cli/repl.py +173 -0
- package/core/app/colab_cli/runtime.py +262 -0
- package/core/app/colab_cli/state.py +156 -0
- package/core/app/colab_cli/utils.py +85 -0
- package/core/colab/worker.py +679 -0
- package/core/daemon.py +184 -0
- package/core/requirements.txt +8 -0
- 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
|
+
}
|