Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ below corresponds to a tag of the same name.

### Added

- **exec:** `colab exec --json` prints one JSON object per executed cell on
stdout (`status`, `stdout`, `stderr`, `data`, `images`, `error`) instead of
streaming output, for scripts and AI agents. Tracebacks are plain text
without ANSI colour codes, images are saved to disk and reported by path,
`[colab]` progress lines go to stderr, and the exit status is 1 when any
cell failed. Without `--json`, output and exit status are unchanged.
- **ssh:** `colab ssh [-s NAME] -- CMD` runs one command on an existing runtime and exits with its status. Output is byte-exact when piped (a PTY only when stdin and stdout are terminals), and it never creates a runtime. Its handshake is tagged `ssh-mode-cmd`.

### Removed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Run `colab <command> --help` to view specific options, defaults, and detailed he
| Command | Description |
| --- | --- |
| `colab run [--gpu GPU] [--tpu TPU] [--high-mem] [--keep] SCRIPT [ARGS...]` | Run a local script on a fresh VM, forwarding arguments, then release it |
| `colab exec [-s NAME] [-f FILE] [--output-image PATH]` | Execute Python code from stdin, a local `.py` file, or a `.ipynb` notebook |
| `colab exec [-s NAME] [-f FILE] [--output-image PATH] [--json]` | Execute Python code from stdin, a local `.py` file, or a `.ipynb` notebook (`--json` prints one machine-readable result per cell) |
| `colab repl [-s NAME] [--output-image PATH]` | Start an interactive Python REPL on the VM (exits cleanly on piped EOF) |
| `colab ssh [-s NAME] [--proxy-mode] [-i KEY] [--gpu GPU] [--tpu TPU] [--high-mem] [-- CMD ...]` | Open an SSH shell to the runtime over WebSocket, run a one-shot remote command (`-- CMD`), or act as an OpenSSH `ProxyCommand` bridge for IDE remote-dev |

Expand Down
8 changes: 8 additions & 0 deletions docs/02_execution_and_interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ log:
2026-10-24: `--timeout` for `colab exec` (and `colab run`) is now an idle limit: execution fails only after that many seconds without output, so code that keeps printing is never cut off, and `--timeout 0` means no limit. On 0.8.0/0.9.0 the old deadline was never enforced, so code that is silent for longer than `--timeout` now fails where it used to complete. `colab repl` and `colab install`, which pass no timeout, wait until execution finishes, or until the kernel dies or restarts. None of them busy-loop while waiting. `colab auth` and `colab drivemount` are unchanged. See #183.

2026-10-29: Removed the `colab console` subcommand and its `/colab/tty` transport (`console.py`, its tests, and `integration/repro_piped_console/`). Use `colab ssh -s NAME` for an interactive shell and `colab ssh -s NAME -- CMD` for one-shot commands, or pipe `!`-prefixed shell commands to `colab exec` where ssh can't work.

2026-11-11: Added `colab exec --json`. Each executed cell becomes one JSON object on stdout (NDJSON) instead of streamed text, `[colab]` progress lines move to stderr, and the exit status is 1 when any cell failed. Without the flag, `colab exec` is unchanged (it still exits 0 after a kernel error).
---

# Design: Execution and Interactive Interaction (`repl`, `exec`)
Expand All @@ -33,6 +35,11 @@ Execution involves sending Python code (or shell commands) to the Jupyter kernel
- If file path is remote: Execute `!python <path>`.
- **Multi-Modal Output**: Handle `display_data` messages (e.g., `image/png`, `text/html`). For the CLI, we'll save images to temporary files and print their paths, or if the terminal supports it (e.g., iTerm2), inline them.
- **Timeout Configuration**: Exposes a `--timeout` flag (default 30s): the longest silence allowed before execution fails. Raise it so long-running silent tasks (like model compilation or data downloading) are not prematurely killed; `0` means no limit.
- **Machine-Readable Output (`--json`)**: For scripts and AI agents that must decide what to do next from the result. Plain `colab exec` exits 0 after a kernel error and prints an ANSI-coloured traceback, so a caller can only guess at failure by scraping text. With `--json`:
- stdout carries one JSON object per executed cell, one per line (NDJSON), written when the cell finishes. Nothing else is written to stdout; `[colab]` progress lines go to stderr.
- Fields: `cell_index`, `cell_id` (notebook cell id, else `null`), `status` (`"ok"` or `"error"`), `stdout`, `stderr` (the kernel's streams, concatenated), `data` (one object per `execute_result` / `display_data` output: its `output_type` plus its non-image MIME entries such as `text/plain`), `images` (paths of the PNG/JPEG outputs saved to disk, honouring `--output-image`; never inline base64), and `error` (`null`, or `ename`, `evalue` and `traceback` as plain text with ANSI escapes removed).
- The exit status is 1 when any cell has `status: "error"`. Notebook cells after a failing cell still run, as without the flag.
- A client-side failure (idle `--timeout`, lost connection) is reported as a final record whose `error.ename` is the exception type, before the command fails as it does without the flag.

## Implementation Details
- **Kernel Management**: `ColabRuntime` (from `colab-agent`) already handles message signing and message types.
Expand All @@ -48,6 +55,7 @@ TDD is mandatory for all execution features.
- **Test Case**: Verify `display_data` (specifically `image/png`) triggers the correct local handling (saving or display).

### 2. TTY and Piping
- **Test Case**: `tests/test_exec_json.py` covers the `--json` record for success, kernel error (ANSI stripped, exit 1), images (saved, path reported, no base64 on stdout), per-cell notebook records, client-side failures, a lost session, and that the default mode still exits 0 on a kernel error.
- **Test Case**: Mock `sys.stdin.isatty()` to verify `colab repl` correctly switches between interactive mode and one-shot piped execution.
- **Test Case**: Verify large piped inputs are handled without buffer overflow or truncation.
- **Test Case**: `print_kitty` is a no-op when `sys.stdout.isatty()` is false (regression: previously emitted ANSI/base64 into pipes and files).
1 change: 1 addition & 0 deletions skills/colab-operator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ by running `uv tool install google-colab-cli` or `pip install google-colab-cli`.
- **Preferred**: `colab exec -s <name> -f <script.py>` runs a local script on the remote VM (read locally, sent to the kernel — no manual upload needed).
- **Piped code**: `echo "print(1)" | colab exec -s <name>` or `cat script.py | colab exec -s <name>`.
- **Notebooks**: `colab exec -s <name> -f nb.ipynb` runs each code cell and writes results to `<basename>_output.ipynb` next to the input. A `# @title Foo` first line labels the cell in progress output.
- **Check the result with `--json`**: plain `colab exec` exits 0 even when the code raises, so don't infer success from the exit status. `colab exec -s <name> --json` prints one JSON object per executed cell on stdout (`status` is `"ok"` or `"error"`; `stdout`, `stderr`; `data` with `text/plain` results; `images` as saved file paths; `error` with `ename`, `evalue` and a colour-free `traceback`) and exits 1 if any cell failed. `[colab]` progress lines go to stderr. Output arrives when a cell finishes, not live, so keep `--timeout` in mind for long cells.
- **Plots/images**: PNG/JPEG outputs are intercepted. Use `--output-image <path>` on `exec`/`repl` to save to a known location (otherwise a temp path is printed). Inline terminal-image escapes are auto-suppressed when stdout isn't a TTY, so piped/captured output stays clean.
- **Shell**: run a one-shot shell command with `colab ssh -s <name> -- <cmd>`: it runs in `/content` and exits with the command's status. Output is byte-exact when stdout is piped or redirected; ssh allocates a PTY only when stdin and stdout are both terminals, so a shell tool that runs commands in a terminal should add `</dev/null` and redirect the output to a file (a pipe keeps `colab ssh`'s exit status only under `set -o pipefail`). One argument is a shell command line (quote it for pipes or globs: `colab ssh -s <name> -- 'nvidia-smi | head -5'`); several arguments are an exact argv, so pass untrusted values as separate arguments after `--` where the program accepts it (e.g. `colab ssh -s <name> -- ls -- "$f"`), never inside a shell line or a piped script. Pipe a multi-line script to `colab ssh -s <name> -- bash`. Use `colab exec` for Python code. When ssh can't work (no local `ssh` client, no `~/.ssh/id_ed25519` or `id_ecdsa` key and no `-i KEY`, a tool not on the minimal ssh environment's `PATH` such as `gcloud` or `node`, or the runtime's limited concurrent SSH connections in use, which the server answers with 429, e.g. while a person or an IDE is connected), fall back to `exec` with IPython's `!` prefix: `echo '!df -h /content' | colab exec -s <name>` (single quotes stop your local shell expanding `!`). Each `!` line runs in its own shell with no input (a command that prompts hangs), and IPython evaluates `{expr}` as Python even inside quotes and expands `$name` outside single quotes. Start a multi-line script with a `%%bash` line (not with `--env`, which prepends Python lines), use non-interactive flags such as `apt-get -y`, and never put untrusted text in a `%%bash` script or a `!` line (even via `{var}` or `$var`): pass it with `--env NAME=VALUE` and run `subprocess.run(["cmd", "--", os.environ["NAME"]])` from Python. For an interactive shell on the VM, a human runs `colab ssh -s <name>`.
- **Never run `colab repl`, `colab ssh` without `-- CMD`, `colab auth`, or `colab drivemount` interactively from an agent** — `repl`, `auth` and `drivemount` expect a TTY and will hang or fail, and bare `colab ssh` with no saved session creates a billable runtime; an interactive shell is for a human. `repl` accepts piped stdin and exits on EOF; `auth`/`drivemount` genuinely require a human at the terminal.
Expand Down
124 changes: 112 additions & 12 deletions src/colab_cli/commands/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.

import datetime
import json
import nbformat
import os
import re
Expand All @@ -25,12 +26,20 @@
from typing_extensions import Annotated

from colab_cli.runtime import ColabRuntime
from colab_cli.utils import handle_image, is_terminal_error, render_display_data
from colab_cli.utils import (
handle_image,
is_terminal_error,
render_display_data,
save_image,
)

_console = Console()

TITLE_REGEX = re.compile(r"^\s*#\s*@title\s+(.*)", re.MULTILINE)
ENV_KEY_REGEX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# CSI escape sequences (colours, cursor moves) that IPython puts in tracebacks.
ANSI_ESCAPE_REGEX = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
IMAGE_MIME_TYPES = ("image/png", "image/jpeg")


def is_stdin_tty() -> bool:
Expand Down Expand Up @@ -142,6 +151,64 @@ def display_output(out, output_image=None):
pass


def _error_record(ename: str, evalue: str, traceback: List[str]) -> dict:
return {
"ename": ename,
"evalue": evalue,
"traceback": ANSI_ESCAPE_REGEX.sub("", "\n".join(traceback)),
}


def build_json_record(
outputs: List[dict],
cell_index: int,
cell_id: Optional[str],
output_image: Optional[str] = None,
) -> dict:
"""Summarize one cell's kernel outputs as a JSON-serializable record.

This is the `colab exec --json` contract: streams are concatenated, images
are written to disk and reported by path (never inlined as base64), and
the traceback is plain text with ANSI colour codes removed.
"""
record = {
"cell_index": cell_index,
"cell_id": cell_id,
"status": "ok",
"stdout": "",
"stderr": "",
"data": [],
"images": [],
"error": None,
}
for out in outputs:
output_type = out.get("output_type")
if output_type == "stream":
name = "stderr" if out.get("name") == "stderr" else "stdout"
record[name] += out.get("text", "")
elif output_type == "error":
record["status"] = "error"
record["error"] = _error_record(
out.get("ename", "Error"),
out.get("evalue", ""),
out.get("traceback", []),
)
elif "data" in out:
data = out["data"]
for mime_type in IMAGE_MIME_TYPES:
if mime_type in data:
record["images"].append(
save_image(data[mime_type], mime_type, output_image)
)
break
rest = {k: v for k, v in data.items() if not k.startswith("image/")}
if rest:
record["data"].append(
{"output_type": output_type or "display_data", **rest}
)
return record


def exec_command(
session: Annotated[
Optional[str], typer.Option("-s", "--session", help="Session name")
Expand Down Expand Up @@ -169,18 +236,35 @@ def exec_command(
),
),
] = None,
json_output: Annotated[
bool,
typer.Option(
"--json",
help=(
"Print one JSON object per executed cell on stdout instead of "
"streaming output, and exit 1 if any cell failed."
),
),
] = False,
):
"""Execute code in a session"""
from colab_cli.common import state

def info(message: str) -> None:
# With --json, stdout carries only JSON records.
if json_output:
typer.echo(message, err=True)
else:
typer.echo(message)

env_vars = _parse_env_vars(env)
name = state.resolve_session(session)
s = state.get_session(name)

code_blocks = []
if file:
if file.endswith(".ipynb"):
typer.echo(f"[colab] Parsing notebook '{file}'...")
info(f"[colab] Parsing notebook '{file}'...")
with open(file, "r", encoding="utf-8") as f:
nb = nbformat.read(f, as_version=4)
for cell in nb.cells:
Expand Down Expand Up @@ -227,13 +311,12 @@ def on_sess_started(sid):
)
except Exception as e:
if is_terminal_error(e):
typer.echo(
f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up."
)
info(f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up.")
state.prune_session(name)
raise typer.Exit(1)
raise e

failed = False
try:
is_nb = file and file.endswith(".ipynb")
s.running = f"exec({file or 'stdin'})"
Expand All @@ -252,7 +335,7 @@ def on_sess_started(sid):
identifier = ""

identifier_str = f" - {identifier}" if identifier else ""
typer.echo(
info(
f"[colab] Executing cell {i + 1}/{len(code_blocks)}{identifier_str}..."
)

Expand All @@ -263,11 +346,26 @@ def on_sess_started(sid):
)
state.store.add(s)

outputs = runtime.execute_code(
code,
output_hook=lambda o: display_output(o, output_image),
timeout=timeout,
)
if json_output:
try:
outputs = runtime.execute_code(code, timeout=timeout)
except Exception as e:
# Timeouts and lost connections never reach the kernel's
# outputs; report them in the same shape before failing.
record = build_json_record([], i, block.get("id"))
record["status"] = "error"
record["error"] = _error_record(type(e).__name__, str(e), [])
typer.echo(json.dumps(record))
raise
record = build_json_record(outputs, i, block.get("id"), output_image)
failed = failed or record["status"] == "error"
typer.echo(json.dumps(record))
else:
outputs = runtime.execute_code(
code,
output_hook=lambda o: display_output(o, output_image),
timeout=timeout,
)
if "cell" in block:
save_output(outputs, block["cell"])
state.history.log_event(
Expand All @@ -286,9 +384,11 @@ def on_sess_started(sid):
runtime.stop()
if file and file.endswith(".ipynb"):
output_file = os.path.splitext(file)[0] + "_output.ipynb"
typer.echo(f"[colab] Saving notebook with outputs to '{output_file}'...")
info(f"[colab] Saving notebook with outputs to '{output_file}'...")
with open(output_file, "w", encoding="utf-8") as f:
nbformat.write(nb, f)
if failed:
raise typer.Exit(1)


def repl(
Expand Down
31 changes: 18 additions & 13 deletions src/colab_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,25 @@ def render_display_data(data: dict) -> Union[Markdown, Text, None]:
return None


def handle_image(image_b64: str, mime_type: str = "image/png", target_path: str = None):
def save_image(
image_b64: str, mime_type: str = "image/png", target_path: str = None
) -> str:
"""Write a base64 image to `target_path` (or a temp file); return its path."""
image_bytes = base64.b64decode(image_b64)
# Print inline using Kitty protocol
print_kitty(image_bytes)

if target_path:
# If a target path is specified, save it there
with open(target_path, "wb") as f:
f.write(image_bytes)
print(f"\n[Image saved to: {target_path}]")
else:
# Save to temp file as fallback
ext = mime_type.split("/")[-1]
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}")
tmp.write(image_bytes)
tmp.close()
print(f"\n[Image saved to: {tmp.name}]")
return target_path
# Save to temp file as fallback
ext = mime_type.split("/")[-1]
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}")
tmp.write(image_bytes)
tmp.close()
return tmp.name


def handle_image(image_b64: str, mime_type: str = "image/png", target_path: str = None):
# Print inline using Kitty protocol
print_kitty(base64.b64decode(image_b64))
path = save_image(image_b64, mime_type, target_path)
print(f"\n[Image saved to: {path}]")
Loading
Loading