Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
44ee17b
feat: add CLI shell and exec commands for deployment pod terminal access
V2arK Mar 12, 2026
c62f890
fix: use urlparse for scheme replacement to satisfy CodeQL
V2arK Mar 12, 2026
095bd1e
fix: apply black formatting and fix CodeQL url.startswith alert
V2arK Mar 12, 2026
d03f493
style: condense multiline expressions for readability
V2arK Mar 12, 2026
3dadb75
fix: resolve pylint warnings in shell.py and test_shell.py
V2arK Mar 12, 2026
8a93733
fix: skip PyTorch-dependent tests in sanity mode
V2arK Mar 12, 2026
0a4c1bb
fix: break out of exec loop after end marker to prevent hanging
V2arK Mar 12, 2026
7259bd4
fix: re-enable OPOST after setraw to fix terminal rendering
V2arK Mar 12, 2026
ab51beb
fix: replace pytest-asyncio with asyncio.run in tests for CI compat
V2arK Mar 12, 2026
8770660
fix: match Web UI protocol - remove rows/cols from stdin messages, re…
V2arK Mar 12, 2026
b79a30a
fix: send delayed resize to fix prompt rendering after shell startup
V2arK Mar 12, 2026
0b03a53
fix: await cancelled tasks for cleanup, reduce WS close_timeout to 2s
V2arK Mar 12, 2026
54445b8
fix: toggle PTY width to force SIGWINCH and prompt redraw on connect
V2arK Mar 12, 2026
0a0636c
fix: include rows/cols in stdin messages and send Ctrl+L after resize…
V2arK Mar 12, 2026
31d41ae
fix: use stty to set PTY dimensions from inside shell instead of resi…
V2arK Mar 12, 2026
ec8286b
fix: re-enable OPOST after setraw to convert bare \n to \r\n like xte…
V2arK Mar 12, 2026
5d94f14
fix: convert \n to \r\n in output and use stty to fix PTY dimensions …
V2arK Mar 12, 2026
559460f
feat: use pyte terminal emulator for interactive shell rendering
V2arK Mar 12, 2026
ff0e893
fix: swap rows/cols unpacking from shutil.get_terminal_size
V2arK Mar 12, 2026
c6d42ed
fix: use alternate screen buffer to prevent scrollback in Warp terminal
V2arK Mar 12, 2026
ba0e9d5
fix: handle WebSocket ConnectionClosed to prevent hang on shell exit
V2arK Mar 12, 2026
f29df94
refactor: use pyte for exec ANSI stripping and add ConnectionClosed h…
V2arK Mar 12, 2026
db40469
fix: treat ArgoCD Code message as reconnect signal, not shell exit code
V2arK Mar 12, 2026
f0b37b8
fix: stop reconnecting when shell has genuinely exited
V2arK Mar 12, 2026
4265839
chore: add debug file logging to shell and exec for exit hang diagnosis
V2arK Mar 13, 2026
27977af
fix: detect shell exit via idle timeout instead of Code message
V2arK Mar 13, 2026
1857f89
fix: exit immediately on exit echo, ignore echo exit with trailing pr…
V2arK Mar 13, 2026
76fd598
fix: skip websocket close handshake wait after session ends
V2arK Mar 13, 2026
a531f00
refactor: extract shell logic from CLI to SDK layer
V2arK Mar 13, 2026
e9e3829
refactor: extract shell logic to SDK layer, rely on server close frame
V2arK Mar 13, 2026
423cdc7
ruff format
V2arK Mar 13, 2026
9ef67f5
refactor: remove debug logging, fix unused imports and SDK/CLI bounda…
V2arK Mar 13, 2026
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
3 changes: 3 additions & 0 deletions centml/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from centml.cli.login import login, logout
from centml.cli.cluster import ls, get, delete, pause, resume
from centml.cli.shell import shell, exec_cmd


@click.group()
Expand Down Expand Up @@ -47,6 +48,8 @@ def ccluster():
ccluster.add_command(delete)
ccluster.add_command(pause)
ccluster.add_command(resume)
ccluster.add_command(shell)
ccluster.add_command(exec_cmd, name="exec")


cli.add_command(ccluster, name="cluster")
58 changes: 58 additions & 0 deletions centml/cli/shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""CLI commands for interactive shell and command execution in deployment pods."""

import asyncio
import sys

import click

from centml.cli.cluster import handle_exception
from centml.sdk import auth
from centml.sdk.api import get_centml_client
from centml.sdk.config import settings
from centml.sdk.shell import ShellError
from centml.sdk.shell.session import build_ws_url, exec_session, interactive_session, resolve_pod


@click.command(help="Open an interactive shell to a deployment pod")
@click.argument("deployment_id", type=int)
@click.option("--pod", default=None, help="Specific pod name (auto-selects first running pod)")
@click.option("--shell", "shell_type", default=None, type=click.Choice(["bash", "sh", "zsh"]), help="Shell type")
@handle_exception
def shell(deployment_id, pod, shell_type):
if not sys.stdin.isatty():
raise click.ClickException("Interactive shell requires a terminal (TTY)")

with get_centml_client() as cclient:
try:
pod_name, warning = resolve_pod(cclient, deployment_id, pod)
except ShellError as exc:
raise click.ClickException(str(exc)) from exc
if warning:
click.echo(f"{warning} Use --pod to specify a different pod.", err=True)

ws_url = build_ws_url(settings.CENTML_PLATFORM_API_URL, deployment_id, pod_name, shell_type)
token = auth.get_centml_token()
exit_code = asyncio.run(interactive_session(ws_url, token))
sys.exit(exit_code)


@click.command(help="Execute a command in a deployment pod", context_settings={"ignore_unknown_options": True})
@click.argument("deployment_id", type=int)
@click.argument("command", nargs=-1, required=True, type=click.UNPROCESSED)
@click.option("--pod", default=None, help="Specific pod name")
@click.option("--shell", "shell_type", default=None, type=click.Choice(["bash", "sh", "zsh"]), help="Shell type")
@handle_exception
def exec_cmd(deployment_id, command, pod, shell_type):
with get_centml_client() as cclient:
try:
pod_name, warning = resolve_pod(cclient, deployment_id, pod)
except ShellError as exc:
raise click.ClickException(str(exc)) from exc
if warning:
click.echo(f"{warning} Use --pod to specify a different pod.", err=True)

ws_url = build_ws_url(settings.CENTML_PLATFORM_API_URL, deployment_id, pod_name, shell_type)
token = auth.get_centml_token()
cmd_str = " ".join(command)
exit_code = asyncio.run(exec_session(ws_url, token, cmd_str))
sys.exit(exit_code)
3 changes: 3 additions & 0 deletions centml/sdk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ def get(self, depl_type):
def get_status(self, id):
return self._api.get_deployment_status_deployments_status_deployment_id_get(id)

def get_status_v3(self, deployment_id):
return self._api.get_deployment_status_v3_deployments_status_v3_deployment_id_get(deployment_id)

def get_inference(self, id):
"""Get Inference deployment details - automatically handles both V2 and V3 deployments"""
# Try V3 first (recommended), fallback to V2 if deployment is V2
Expand Down
34 changes: 34 additions & 0 deletions centml/sdk/shell/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""SDK shell module -- reusable shell/exec session logic (no Click dependency)."""

from centml.sdk.shell.exceptions import NoPodAvailableError, PodNotFoundError, ShellError
from centml.sdk.shell.renderer import char_to_sgr, color_sgr, pyte_extract_text, render_dirty
from centml.sdk.shell.session import (
BEGIN_MARKER,
END_MARKER,
PRINTF_BEGIN,
PRINTF_END,
build_ws_url,
exec_session,
forward_io,
interactive_session,
resolve_pod,
)

__all__ = [
"ShellError",
"NoPodAvailableError",
"PodNotFoundError",
"color_sgr",
"char_to_sgr",
"render_dirty",
"pyte_extract_text",
"build_ws_url",
"resolve_pod",
"forward_io",
"interactive_session",
"exec_session",
"BEGIN_MARKER",
"END_MARKER",
"PRINTF_BEGIN",
"PRINTF_END",
]
13 changes: 13 additions & 0 deletions centml/sdk/shell/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""SDK exceptions for shell operations (no Click dependency)."""


class ShellError(Exception):
"""Base exception for shell operations."""


class NoPodAvailableError(ShellError):
"""No running pods found for the deployment."""


class PodNotFoundError(ShellError):
"""Specified pod not found among running pods."""
130 changes: 130 additions & 0 deletions centml/sdk/shell/renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Pyte terminal screen renderer -- converts pyte's in-memory buffer to ANSI."""

_PYTE_FG_TO_SGR = {
"default": "39",
"black": "30",
"red": "31",
"green": "32",
"brown": "33",
"blue": "34",
"magenta": "35",
"cyan": "36",
"white": "37",
"brightblack": "90",
"brightred": "91",
"brightgreen": "92",
"brightbrown": "93",
"brightblue": "94",
"brightmagenta": "95",
"brightcyan": "96",
"brightwhite": "97",
}

_PYTE_BG_TO_SGR = {
"default": "49",
"black": "40",
"red": "41",
"green": "42",
"brown": "43",
"blue": "44",
"magenta": "45",
"cyan": "46",
"white": "47",
"brightblack": "100",
"brightred": "101",
"brightgreen": "102",
"brightbrown": "103",
"brightblue": "104",
"brightmagenta": "105",
"brightcyan": "106",
"brightwhite": "107",
}


def color_sgr(color, is_bg=False):
"""Convert a pyte color value to an SGR parameter string."""
table = _PYTE_BG_TO_SGR if is_bg else _PYTE_FG_TO_SGR
if color in table:
default_val = "49" if is_bg else "39"
code = table[color]
return code if code != default_val else ""
# 6-char hex -> truecolor
if len(color) == 6:
try:
r, g, b = int(color[:2], 16), int(color[2:4], 16), int(color[4:], 16)
prefix = "48" if is_bg else "38"
return f"{prefix};2;{r};{g};{b}"
except ValueError:
return ""
return ""


def char_to_sgr(char):
"""Build the ANSI SGR parameter string for a pyte Char's attributes."""
parts = []
if char.bold:
parts.append("1")
if char.italics:
parts.append("3")
if char.underscore:
parts.append("4")
if char.blink:
parts.append("5")
if char.reverse:
parts.append("7")
if char.strikethrough:
parts.append("9")
fg = color_sgr(char.fg, is_bg=False)
if fg:
parts.append(fg)
bg = color_sgr(char.bg, is_bg=True)
if bg:
parts.append(bg)
return ";".join(parts)


def render_dirty(screen, output):
"""Render only the dirty lines from the pyte Screen to the terminal.

Args:
screen: pyte.Screen instance.
output: Writable binary stream (e.g. sys.stdout.buffer).
"""
parts = []
for row in sorted(screen.dirty):
# Position cursor at row (1-based), column 1; clear line.
parts.append(f"\033[{row + 1};1H\033[2K")
prev_sgr = ""
line_chars = []
for col in range(screen.columns):
char = screen.buffer[row][col]
if char.data == "":
continue
sgr = char_to_sgr(char)
if sgr != prev_sgr:
line_chars.append(f"\033[0m\033[{sgr}m" if sgr else "\033[0m")
prev_sgr = sgr
line_chars.append(char.data)
text = "".join(line_chars).rstrip()
parts.append(text)
# Reset attributes, position cursor.
parts.append("\033[0m")
parts.append(f"\033[{screen.cursor.y + 1};{screen.cursor.x + 1}H")
if screen.cursor.hidden:
parts.append("\033[?25l")
else:
parts.append("\033[?25h")
screen.dirty.clear()
output.write("".join(parts).encode("utf-8"))
output.flush()


def pyte_extract_text(line_stream, line_screen, text):
"""Feed text through a single-row pyte screen and return visible characters.

More robust than regex ANSI stripping: pyte interprets all VT100/VT220
sequences including OSC, cursor repositioning, and truecolor escapes.
"""
line_screen.reset()
line_stream.feed(text)
return "".join(line_screen.buffer[0][col].data for col in range(line_screen.columns)).rstrip()
Loading
Loading