Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
with:
python-version: "3.10"
- run: >-
uv run --isolated --resolution lowest-direct --with-editable .
uv run --isolated --resolution lowest-direct --with-editable '.[pty]'
--with pytest --with respx --with trio
pytest -m "not live" -p no:cacheprovider

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,27 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
they stay open between events.

## Terminals

`client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
`pip install "opencode-python-sdk[pty]"`.

```python
pty = client.pty.create(command="/bin/sh").data
with client.pty.attach(pty.id) as term:
term.resize(120, 40)
term.send("echo hi\n")
for chunk in term: # output bytes, until the shell exits
print(chunk.decode(errors="replace"), end="")
```

`recv(timeout=...)` reads one chunk and returns `None` once the terminal has ended. `term.cursor` holds the output
position to pass as `cursor=` when reattaching; `cursor=-1` skips the replay of earlier output. Persistent terminals
work the same way through `client.experimental.persistent_pty.attach(pty_id, role=..., takeover=...)`. The async
client returns `AsyncPtyConnection` (asyncio only): `async with await client.pty.attach(...) as term`. A connection
the server refuses or closes abnormally raises `PtyConnectionError`. See
[`examples/terminal.py`](examples/terminal.py) for a full TTY bridge.

## Errors

Every error derives from `opencode.OpencodeError`. Errors the server declares are raised as their own class, with a
Expand Down
18 changes: 18 additions & 0 deletions codegen/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,12 +378,29 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i
return lines


# Hand-written methods mixed into generated resources: resource path -> (module, sync mixin, async mixin).
# The mixins subclass the resource base classes and cover endpoints the generator cannot express (WebSockets).
MIXINS: dict[tuple[str, ...], tuple[str, str, str]] = {
("pty",): ("_pty", "PtyAttachMixin", "AsyncPtyAttachMixin"),
("experimental", "persistentPty"): ("_pty", "PersistentPtyAttachMixin", "AsyncPersistentPtyAttachMixin"),
}


def _mixins(resource: Resource) -> list[tuple[str, str, str]]:
found = [MIXINS[tuple(resource.path)]] if tuple(resource.path) in MIXINS else []
for child in resource.children.values():
found += _mixins(child)
return found


def _resource_classes(resource: Resource, errors: dict[str, dict[str, Any]], *, is_async: bool) -> list[str]:
blocks: list[str] = []
for child in resource.children.values():
blocks += _resource_classes(child, errors, is_async=is_async)
prefix = "Async" if is_async else ""
base = "AsyncAPIResource" if is_async else "SyncAPIResource"
if tuple(resource.path) in MIXINS:
base = MIXINS[tuple(resource.path)][2 if is_async else 1]
lines = [f"class {prefix}{resource.class_name}Resource({base}):"]
title = ".".join(resource.path)
lines.append(f' """Operations under ``client.{_attr_path(resource)}`` (OpenCode ``{title}``)."""\n')
Expand Down Expand Up @@ -457,6 +474,7 @@ def render_resource_module(
if any(op.kind == "page" for op in operations)
else []
),
*[f"from ..{module} import {', '.join(sorted({sync, async_}))}" for module, sync, async_ in _mixins(resource)],
*([f"from .._qs import {', '.join(runtime)}"] if runtime else []),
"from .._resource import AsyncAPIResource, SyncAPIResource",
*(["from .._streaming import AsyncStream, Stream"] if any(op.kind == "stream" for op in operations) else []),
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ entry; the common ones also have a guide. CI fails if a group on the client is m
| `client.plugin` | List plugins | - | [`PluginResource`](../reference/resources.md#opencode.resources.plugin.PluginResource) |
| `client.project` | Projects | - | [`ProjectResource`](../reference/resources.md#opencode.resources.project.ProjectResource) |
| `client.provider` | Model providers | - | [`ProviderResource`](../reference/resources.md#opencode.resources.provider.ProviderResource) |
| `client.pty` | Terminals | - | [`PtyResource`](../reference/resources.md#opencode.resources.pty.PtyResource) |
| `client.pty` | Terminals | [Interactive terminals](terminals.md) | [`PtyResource`](../reference/resources.md#opencode.resources.pty.PtyResource) |
| `client.reference` | References | - | [`ReferenceResource`](../reference/resources.md#opencode.resources.reference.ReferenceResource) |
| `client.rpc` | Plugin RPC | - | [`RpcResource`](../reference/resources.md#opencode.resources.rpc.RpcResource) |
| `client.server` | Server info and health | - | [`ServerResource`](../reference/resources.md#opencode.resources.server.ServerResource) |
Expand Down
58 changes: 58 additions & 0 deletions docs/guides/terminals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Interactive terminals

`client.pty.attach()` opens an interactive connection to a server-side PTY over a WebSocket. It needs the `pty`
extra:

```bash
pip install "opencode-python-sdk[pty]"
```

```python
pty = client.pty.create(command="/bin/sh").data
with client.pty.attach(pty.id) as term: # (1)!
term.resize(120, 40)
term.send("echo hi\n")
for chunk in term: # (2)!
print(chunk.decode(errors="replace"), end="")
```

1. Closing the connection leaves the PTY running; remove it with `client.pty.remove(pty.id)`.
2. Output arrives as `bytes` until the shell exits.

`recv(timeout=...)` reads one chunk and returns `None` once the terminal has ended. `term.cursor` is the output
position to pass as `cursor=` when you reattach; `cursor=-1` skips the replay of earlier output.

## Persistent terminals

Persistent terminals belong to a session and survive server restarts *(experimental)*:

```python
info = client.experimental.persistent_pty.create(session.id, command="/bin/sh", args=[], title="sh", env={})
with client.experimental.persistent_pty.attach(info.id, role="controller", on_event=print) as term:
term.send("ls\n")
print(term.recv(timeout=5))
```

`role="observer"` watches without sending input, `takeover=True` takes control from another controller, and
`attachment_id` identifies this client across reconnects. `on_event` receives the JSON events (`attached`,
`replay_complete`, `resized`, `controller_changed`, ...).

## Async

The async client returns `AsyncPtyConnection`, which needs asyncio:

```python
async with await client.pty.attach(pty.id) as term:
await term.send("echo hi\n")
async for chunk in term:
...
```

## Errors

A refused handshake (bad ticket, unknown PTY) or an abnormal close raises `PtyConnectionError`, with `status` for the
HTTP status and `code` / `reason` for the close frame (`4404` means the terminal was not found, has exited or is
unavailable). The same error explains how to install the extra when `websockets` is missing.

[`examples/terminal.py`](https://github.com/shrijayan/opencode-python-sdk/blob/main/examples/terminal.py) bridges
your local terminal to a remote shell.
10 changes: 10 additions & 0 deletions docs/reference/terminals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Terminals

Connections returned by `client.pty.attach()` and `client.experimental.persistent_pty.attach()`. See the
[terminals guide](../guides/terminals.md).

::: opencode.PtyConnection

::: opencode.AsyncPtyConnection

::: opencode.PtyConnectionError
55 changes: 55 additions & 0 deletions examples/terminal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Opens a shell on an OpenCode server and bridges it to this terminal. Exit the shell to quit.

Usage: OPENCODE_BASE_URL=http://127.0.0.1:4096 OPENCODE_PASSWORD=... uv run python examples/terminal.py

Needs the ``pty`` extra (``pip install "opencode-python-sdk[pty]"``) and a POSIX terminal.
"""

from __future__ import annotations

import os
import shutil
import signal
import sys
import termios
import threading
import tty
from typing import Any

from opencode import Opencode, PtyConnection


def pump_input(term: PtyConnection) -> None:
"""Forwards keystrokes to the remote shell."""
fd = sys.stdin.fileno()
while data := os.read(fd, 1024):
term.send(data)


def main() -> None:
with Opencode() as client:
pty = client.pty.create(command=os.environ.get("SHELL", "/bin/sh"), title="examples/terminal.py").data
try:
with client.pty.attach(pty.id) as term:
size = shutil.get_terminal_size()
term.resize(size.columns, size.lines)

def on_resize(*_: Any) -> None:
size = shutil.get_terminal_size()
term.resize(size.columns, size.lines)

signal.signal(signal.SIGWINCH, on_resize)
saved = termios.tcgetattr(sys.stdin)
tty.setraw(sys.stdin)
try:
threading.Thread(target=pump_input, args=(term,), daemon=True).start()
for chunk in term:
os.write(sys.stdout.fileno(), chunk)
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, saved)
finally:
client.pty.remove(pty.id)


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ nav:
- Models and unions: guides/models.md
- Pagination: guides/pagination.md
- Events and streaming: guides/events.md
- Interactive terminals: guides/terminals.md
- Errors: guides/errors.md
- Retries and timeouts: guides/retries.md
- The project directory: guides/directory.md
Expand All @@ -150,6 +151,7 @@ nav:
- Server launcher: reference/server.md
- Resources: reference/resources.md
- Pagination and streams: reference/pagination.md
- Terminals: reference/terminals.md
- Exceptions: reference/exceptions.md
- Types: reference/types/index.md
- Project:
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ Repository = "https://github.com/shrijayan/opencode-python-sdk"
Issues = "https://github.com/shrijayan/opencode-python-sdk/issues"
Changelog = "https://github.com/shrijayan/opencode-python-sdk/blob/main/CHANGELOG.md"

[project.optional-dependencies]
pty = [
"websockets>=14,<17",
]

[dependency-groups]
dev = [
"mypy>=1.18",
Expand All @@ -48,6 +53,7 @@ dev = [
"respx>=0.22",
"ruff>=0.14",
"trio>=0.31",
"websockets>=14",
]
docs = [
"mkdocs-material[imaging]>=9.6",
Expand Down
4 changes: 4 additions & 0 deletions src/opencode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
)
from ._models import BaseModel
from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
from ._streaming import AsyncStream, Stream
from ._types import NOT_GIVEN, NotGiven
Expand All @@ -97,6 +98,7 @@
"AsyncOpencode",
"AsyncOpencodeServer",
"AsyncPaginator",
"AsyncPtyConnection",
"AsyncStream",
"AuthenticationError",
"BadRequestError",
Expand All @@ -111,6 +113,8 @@
"OpencodeError",
"OpencodeServer",
"PermissionDeniedError",
"PtyConnection",
"PtyConnectionError",
"RateLimitError",
"ServerStartError",
"ServiceUnavailableError",
Expand Down
Loading
Loading