shell-use 0.0.1__tar.gz → 0.0.1b2__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ build/
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: shell-use
3
+ Version: 0.0.1b2
4
+ Summary: Python client for the shell-use terminal daemon
5
+ Project-URL: Homepage, https://github.com/microsoft/shell-use
6
+ Project-URL: Repository, https://github.com/microsoft/shell-use
7
+ Author: Microsoft
8
+ License: MIT
9
+ Keywords: automation,pty,shell-use,terminal,testing,tui
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+
16
+ # shell-use (Python)
17
+
18
+ A Python client for the [`shell-use`](https://github.com/microsoft/shell-use) terminal daemon.
19
+
20
+ The `shell-use` binary must be on your `PATH` (or point to it with the `SHELL_USE_BIN` environment variable or the `binary=` argument). The client talks to the per-session daemon directly over its local socket (a named pipe on Windows, a Unix socket elsewhere) and starts the daemon automatically.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pip install shell-use
26
+ ```
27
+
28
+ Requires Python 3.8+.
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ import asyncio
34
+ from shell_use import ShellUse
35
+
36
+ async def main():
37
+ async with ShellUse() as su:
38
+ await su.open()
39
+ await su.submit("echo hello")
40
+ await su.wait_command()
41
+ await su.expect_text("hello")
42
+ await su.expect_exit_code(0)
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ Drive a full-screen TUI:
48
+
49
+ ```python
50
+ async with ShellUse("vim-session") as su:
51
+ await su.run("vim", "file.txt")
52
+ await su.wait_idle()
53
+ await su.press("i")
54
+ await su.type("some text")
55
+ await su.press("Escape", ":", "w", "q", "Enter")
56
+ await su.wait_exit()
57
+ ```
58
+
59
+ ## Errors
60
+
61
+ Every failure maps to one of the daemon's exit codes:
62
+
63
+ | Exception | Exit code | Meaning |
64
+ | --- | --- | --- |
65
+ | `ExpectationError` | 1 | an `expect`/`wait` condition was not met |
66
+ | `UsageError` | 2 | invalid argument (e.g. a bad regex) |
67
+ | `NoSessionError` | 3 | no active session |
68
+ | `DaemonError` | 4 | daemon could not be reached or started |
69
+ | `VersionMismatchError` | 4 | the daemon's version differs from this package |
70
+ | `InternalError` | 5 | internal daemon error |
71
+
72
+ All derive from `ShellUseError`. `wait_*` and `expect_*` raise `ExpectationError` on failure.
73
+
74
+ On its first call, a client checks that the running daemon's version matches the
75
+ package version and raises `VersionMismatchError` if they differ. Stop the daemon
76
+ (`daemon_stop`) so it restarts with the current binary, or point `SHELL_USE_BIN`
77
+ at a matching one.
78
+
79
+ ## API
80
+
81
+ `ShellUse(session="default", *, binary=None, home=None)` mirrors the CLI: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`), `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, and `close`.
82
+
83
+ Module-level helpers: `sessions()`, `close_all()`, `daemon_status()`, `daemon_stop()`, `get_recording()`.
84
+
85
+ ## Configuration
86
+
87
+ | Variable | Purpose |
88
+ | --- | --- |
89
+ | `SHELL_USE_BIN` | path to the `shell-use` binary |
90
+ | `SHELL_USE_SESSION` | default session name |
91
+ | `SHELL_USE_HOME` | daemon state directory (sockets, pids) |
@@ -0,0 +1,76 @@
1
+ # shell-use (Python)
2
+
3
+ A Python client for the [`shell-use`](https://github.com/microsoft/shell-use) terminal daemon.
4
+
5
+ The `shell-use` binary must be on your `PATH` (or point to it with the `SHELL_USE_BIN` environment variable or the `binary=` argument). The client talks to the per-session daemon directly over its local socket (a named pipe on Windows, a Unix socket elsewhere) and starts the daemon automatically.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install shell-use
11
+ ```
12
+
13
+ Requires Python 3.8+.
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ import asyncio
19
+ from shell_use import ShellUse
20
+
21
+ async def main():
22
+ async with ShellUse() as su:
23
+ await su.open()
24
+ await su.submit("echo hello")
25
+ await su.wait_command()
26
+ await su.expect_text("hello")
27
+ await su.expect_exit_code(0)
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ Drive a full-screen TUI:
33
+
34
+ ```python
35
+ async with ShellUse("vim-session") as su:
36
+ await su.run("vim", "file.txt")
37
+ await su.wait_idle()
38
+ await su.press("i")
39
+ await su.type("some text")
40
+ await su.press("Escape", ":", "w", "q", "Enter")
41
+ await su.wait_exit()
42
+ ```
43
+
44
+ ## Errors
45
+
46
+ Every failure maps to one of the daemon's exit codes:
47
+
48
+ | Exception | Exit code | Meaning |
49
+ | --- | --- | --- |
50
+ | `ExpectationError` | 1 | an `expect`/`wait` condition was not met |
51
+ | `UsageError` | 2 | invalid argument (e.g. a bad regex) |
52
+ | `NoSessionError` | 3 | no active session |
53
+ | `DaemonError` | 4 | daemon could not be reached or started |
54
+ | `VersionMismatchError` | 4 | the daemon's version differs from this package |
55
+ | `InternalError` | 5 | internal daemon error |
56
+
57
+ All derive from `ShellUseError`. `wait_*` and `expect_*` raise `ExpectationError` on failure.
58
+
59
+ On its first call, a client checks that the running daemon's version matches the
60
+ package version and raises `VersionMismatchError` if they differ. Stop the daemon
61
+ (`daemon_stop`) so it restarts with the current binary, or point `SHELL_USE_BIN`
62
+ at a matching one.
63
+
64
+ ## API
65
+
66
+ `ShellUse(session="default", *, binary=None, home=None)` mirrors the CLI: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`), `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, and `close`.
67
+
68
+ Module-level helpers: `sessions()`, `close_all()`, `daemon_status()`, `daemon_stop()`, `get_recording()`.
69
+
70
+ ## Configuration
71
+
72
+ | Variable | Purpose |
73
+ | --- | --- |
74
+ | `SHELL_USE_BIN` | path to the `shell-use` binary |
75
+ | `SHELL_USE_SESSION` | default session name |
76
+ | `SHELL_USE_HOME` | daemon state directory (sockets, pids) |
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shell-use"
7
+ version = "0.0.1-beta.2"
8
+ description = "Python client for the shell-use terminal daemon"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Microsoft" }]
13
+ keywords = ["terminal", "pty", "automation", "testing", "tui", "shell-use"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/microsoft/shell-use"
23
+ Repository = "https://github.com/microsoft/shell-use"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/shell_use"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["src/shell_use", "README.md"]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from ._config import VERSION as __version__
4
+ from .client import (
5
+ ShellUse,
6
+ close_all,
7
+ daemon_status,
8
+ daemon_stop,
9
+ get_recording,
10
+ sessions,
11
+ )
12
+ from .errors import (
13
+ DaemonError,
14
+ ExpectationError,
15
+ InternalError,
16
+ NoSessionError,
17
+ ShellUseError,
18
+ UsageError,
19
+ VersionMismatchError,
20
+ )
21
+ from .types import Cell, State
22
+
23
+ __all__ = [
24
+ "ShellUse",
25
+ "sessions",
26
+ "close_all",
27
+ "daemon_status",
28
+ "daemon_stop",
29
+ "get_recording",
30
+ "ShellUseError",
31
+ "ExpectationError",
32
+ "UsageError",
33
+ "NoSessionError",
34
+ "DaemonError",
35
+ "VersionMismatchError",
36
+ "InternalError",
37
+ "Cell",
38
+ "State",
39
+ "__version__",
40
+ ]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ VERSION = "0.0.1-beta.2"
9
+
10
+ DEFAULT_COLS = 80
11
+ DEFAULT_ROWS = 30
12
+
13
+ IS_WINDOWS = sys.platform == "win32"
14
+
15
+
16
+ def resolve_session(session: Optional[str]) -> str:
17
+ return session or os.environ.get("SHELL_USE_SESSION") or "default"
18
+
19
+
20
+ def resolve_binary(binary: Optional[str]) -> str:
21
+ return binary or os.environ.get("SHELL_USE_BIN") or "shell-use"
22
+
23
+
24
+ def resolve_home(home: Optional[str]) -> Optional[str]:
25
+ return home or os.environ.get("SHELL_USE_HOME") or None
26
+
27
+
28
+ def home_dir(home: Optional[str]) -> Path:
29
+ return Path(home) if home else Path.home() / ".shell-use"
30
+
31
+
32
+ def socket_path(session: str, home: Optional[str]) -> str:
33
+ if IS_WINDOWS:
34
+ return rf"\\.\pipe\shell-use-{session}.sock"
35
+ return str(home_dir(home) / f"{session}.sock")
36
+
37
+
38
+ def _cache_dir() -> Path:
39
+ if IS_WINDOWS:
40
+ base = os.environ.get("LOCALAPPDATA")
41
+ return Path(base) if base else Path.home() / "AppData" / "Local"
42
+ if sys.platform == "darwin":
43
+ return Path.home() / "Library" / "Caches"
44
+ xdg = os.environ.get("XDG_CACHE_HOME")
45
+ return Path(xdg) if xdg else Path.home() / ".cache"
46
+
47
+
48
+ def recording_dir(home: Optional[str]) -> Path:
49
+ if home:
50
+ return Path(home) / "recordings"
51
+ return _cache_dir() / "shell-use"
52
+
53
+
54
+ def recording_path(session: str, home: Optional[str]) -> Path:
55
+ return recording_dir(home) / f"{session}.cast"
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union
4
+
5
+ from .errors import make_error
6
+
7
+ EnvLike = Union[Mapping[str, str], Iterable[Tuple[str, str]], None]
8
+
9
+
10
+ def unwrap(resp: Dict[str, Any]) -> Any:
11
+ if resp.get("ok"):
12
+ return resp.get("data")
13
+ raise make_error(resp.get("kind"), resp.get("message") or "shell-use error")
14
+
15
+
16
+ def env_pairs(env: EnvLike) -> List[List[str]]:
17
+ if env is None:
18
+ return []
19
+ items = env.items() if isinstance(env, Mapping) else env
20
+ return [[str(k), str(v)] for k, v in items]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from typing import Any, Dict, Optional, Tuple
7
+
8
+ from . import _config as cfg
9
+ from .errors import DaemonError
10
+
11
+ _Streams = Tuple[asyncio.StreamReader, asyncio.StreamWriter]
12
+
13
+
14
+ async def _open(session: str, home: Optional[str]) -> _Streams:
15
+ path = cfg.socket_path(session, home)
16
+ if cfg.IS_WINDOWS:
17
+ loop = asyncio.get_running_loop()
18
+ create = getattr(loop, "create_pipe_connection", None)
19
+ if create is None:
20
+ raise DaemonError(
21
+ "named-pipe client requires the Proactor event loop on Windows "
22
+ "(the default since Python 3.8)"
23
+ )
24
+ reader = asyncio.StreamReader()
25
+ protocol = asyncio.StreamReaderProtocol(reader)
26
+ transport, _ = await create(lambda: protocol, path)
27
+ writer = asyncio.StreamWriter(transport, protocol, reader, loop)
28
+ return reader, writer
29
+ return await asyncio.open_unix_connection(path)
30
+
31
+
32
+ async def _close(writer: asyncio.StreamWriter) -> None:
33
+ writer.close()
34
+ try:
35
+ await writer.wait_closed()
36
+ except Exception:
37
+ pass
38
+
39
+
40
+ async def can_connect(session: str, home: Optional[str]) -> bool:
41
+ try:
42
+ _, writer = await _open(session, home)
43
+ except (FileNotFoundError, ConnectionRefusedError, OSError):
44
+ return False
45
+ await _close(writer)
46
+ return True
47
+
48
+
49
+ async def ensure_daemon(session: str, home: Optional[str], binary: str) -> None:
50
+ if await can_connect(session, home):
51
+ return
52
+ env = dict(os.environ)
53
+ if home:
54
+ env["SHELL_USE_HOME"] = home
55
+ try:
56
+ proc = await asyncio.create_subprocess_exec(
57
+ binary,
58
+ "--session",
59
+ session,
60
+ "daemon",
61
+ "status",
62
+ stdout=asyncio.subprocess.DEVNULL,
63
+ stderr=asyncio.subprocess.DEVNULL,
64
+ env=env,
65
+ )
66
+ except FileNotFoundError:
67
+ raise DaemonError(
68
+ f"could not find the '{binary}' binary on PATH; "
69
+ "set SHELL_USE_BIN or pass binary="
70
+ )
71
+ await proc.wait()
72
+ for _ in range(100):
73
+ if await can_connect(session, home):
74
+ return
75
+ await asyncio.sleep(0.05)
76
+ raise DaemonError(f"daemon for session '{session}' did not become ready")
77
+
78
+
79
+ async def request(
80
+ session: str,
81
+ home: Optional[str],
82
+ binary: str,
83
+ payload: Dict[str, Any],
84
+ *,
85
+ autostart: bool = True,
86
+ ) -> Dict[str, Any]:
87
+ if autostart:
88
+ await ensure_daemon(session, home, binary)
89
+ try:
90
+ reader, writer = await _open(session, home)
91
+ except (FileNotFoundError, ConnectionRefusedError, OSError) as e:
92
+ raise DaemonError(f"could not connect to session '{session}': {e}")
93
+ try:
94
+ writer.write(json.dumps(payload).encode("utf-8") + b"\n")
95
+ await writer.drain()
96
+ line = await reader.readline()
97
+ finally:
98
+ await _close(writer)
99
+ if not line:
100
+ raise DaemonError("daemon closed the connection without responding")
101
+ try:
102
+ return json.loads(line.decode("utf-8"))
103
+ except json.JSONDecodeError as e:
104
+ raise DaemonError(f"invalid response from daemon: {e}")
@@ -0,0 +1,378 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from . import _config as cfg
7
+ from . import _transport as transport
8
+ from ._protocol import EnvLike, env_pairs, unwrap
9
+ from .errors import VersionMismatchError
10
+ from .types import Cell, State
11
+
12
+
13
+ def check_version(daemon_version: Optional[str]) -> None:
14
+ if daemon_version != cfg.VERSION:
15
+ raise VersionMismatchError(
16
+ f"shell-use version mismatch: client {cfg.VERSION}, daemon "
17
+ f"{daemon_version or 'unknown'}. Ensure the shell-use binary matches the "
18
+ "shell-use package version, or stop the daemon (daemon_stop) so it "
19
+ "restarts with the current binary."
20
+ )
21
+
22
+
23
+ class _Mouse:
24
+ def __init__(self, client: "ShellUse") -> None:
25
+ self._c = client
26
+
27
+ async def click(
28
+ self,
29
+ x: Optional[int] = None,
30
+ y: Optional[int] = None,
31
+ *,
32
+ on_text: Optional[str] = None,
33
+ button: int = 0,
34
+ clicks: int = 1,
35
+ ) -> None:
36
+ await self._c.send(
37
+ {
38
+ "kind": "mouse",
39
+ "action": {
40
+ "op": "click",
41
+ "x": x,
42
+ "y": y,
43
+ "on_text": on_text,
44
+ "button": button,
45
+ "clicks": clicks,
46
+ },
47
+ }
48
+ )
49
+
50
+ async def move(self, x: int, y: int) -> None:
51
+ await self._c.send({"kind": "mouse", "action": {"op": "move", "x": x, "y": y}})
52
+
53
+ async def down(self, x: int, y: int, *, button: int = 0) -> None:
54
+ await self._c.send(
55
+ {"kind": "mouse", "action": {"op": "down", "x": x, "y": y, "button": button}}
56
+ )
57
+
58
+ async def up(self, x: int, y: int, *, button: int = 0) -> None:
59
+ await self._c.send(
60
+ {"kind": "mouse", "action": {"op": "up", "x": x, "y": y, "button": button}}
61
+ )
62
+
63
+ async def drag(
64
+ self, x1: int, y1: int, x2: int, y2: int, *, button: int = 0
65
+ ) -> None:
66
+ await self._c.send(
67
+ {
68
+ "kind": "mouse",
69
+ "action": {
70
+ "op": "drag",
71
+ "x1": x1,
72
+ "y1": y1,
73
+ "x2": x2,
74
+ "y2": y2,
75
+ "button": button,
76
+ },
77
+ }
78
+ )
79
+
80
+ async def scroll(self, direction: str, *, amount: int = 3) -> None:
81
+ await self._c.send(
82
+ {
83
+ "kind": "mouse",
84
+ "action": {"op": "scroll", "direction": direction, "amount": amount},
85
+ }
86
+ )
87
+
88
+
89
+ class ShellUse:
90
+ def __init__(
91
+ self,
92
+ session: Optional[str] = None,
93
+ *,
94
+ binary: Optional[str] = None,
95
+ home: Optional[str] = None,
96
+ ) -> None:
97
+ self._session = cfg.resolve_session(session)
98
+ self._binary = cfg.resolve_binary(binary)
99
+ self._home = cfg.resolve_home(home)
100
+ self._version_checked = False
101
+ self.mouse = _Mouse(self)
102
+
103
+ @property
104
+ def session(self) -> str:
105
+ return self._session
106
+
107
+ async def send(self, payload: Dict[str, Any]) -> Any:
108
+ await self._check_version()
109
+ resp = await transport.request(self._session, self._home, self._binary, payload)
110
+ return unwrap(resp)
111
+
112
+ async def _check_version(self) -> None:
113
+ if self._version_checked:
114
+ return
115
+ resp = await transport.request(
116
+ self._session, self._home, self._binary, {"kind": "status"}
117
+ )
118
+ data = unwrap(resp)
119
+ check_version(data.get("version") if isinstance(data, dict) else None)
120
+ self._version_checked = True
121
+
122
+ async def open(
123
+ self,
124
+ *,
125
+ shell: Optional[str] = None,
126
+ cols: int = cfg.DEFAULT_COLS,
127
+ rows: int = cfg.DEFAULT_ROWS,
128
+ cwd: Optional[str] = None,
129
+ env: EnvLike = None,
130
+ ) -> Dict[str, Any]:
131
+ return await self.send(
132
+ {
133
+ "kind": "open",
134
+ "shell": shell,
135
+ "program": None,
136
+ "cols": cols,
137
+ "rows": rows,
138
+ "cwd": cwd,
139
+ "env": env_pairs(env),
140
+ }
141
+ )
142
+
143
+ async def run(
144
+ self,
145
+ program: str,
146
+ *args: str,
147
+ cols: int = cfg.DEFAULT_COLS,
148
+ rows: int = cfg.DEFAULT_ROWS,
149
+ cwd: Optional[str] = None,
150
+ env: EnvLike = None,
151
+ ) -> Dict[str, Any]:
152
+ return await self.send(
153
+ {
154
+ "kind": "open",
155
+ "shell": None,
156
+ "program": [program, *args],
157
+ "cols": cols,
158
+ "rows": rows,
159
+ "cwd": cwd,
160
+ "env": env_pairs(env),
161
+ }
162
+ )
163
+
164
+ async def close(self) -> None:
165
+ if not await transport.can_connect(self._session, self._home):
166
+ return
167
+ resp = await transport.request(
168
+ self._session, self._home, self._binary, {"kind": "close"}, autostart=False
169
+ )
170
+ unwrap(resp)
171
+
172
+ async def type(self, text: str) -> None:
173
+ await self.send({"kind": "write", "data": text})
174
+
175
+ async def write(self, data: str) -> None:
176
+ await self.send({"kind": "write", "data": data})
177
+
178
+ async def submit(self, text: Optional[str] = None) -> None:
179
+ await self.send({"kind": "submit", "data": text})
180
+
181
+ async def press(self, *keys: str) -> None:
182
+ await self.send({"kind": "press", "keys": list(keys)})
183
+
184
+ async def keys(self, combo: str) -> None:
185
+ await self.send({"kind": "press", "keys": [combo]})
186
+
187
+ async def resize(self, cols: int, rows: int) -> None:
188
+ await self.send({"kind": "resize", "cols": cols, "rows": rows})
189
+
190
+ async def signal(self, name: str) -> None:
191
+ await self.send({"kind": "signal", "name": name})
192
+
193
+ async def kill(self) -> None:
194
+ await self.send({"kind": "signal", "name": "KILL"})
195
+
196
+ async def state(self) -> State:
197
+ return State.from_dict(await self.send({"kind": "state"}))
198
+
199
+ async def text(self, *, full: bool = False) -> str:
200
+ return (await self.send({"kind": "text", "full": full}))["text"]
201
+
202
+ async def cells(self, x: int, y: int, w: int = 1, h: int = 1) -> List[Cell]:
203
+ data = await self.send({"kind": "cells", "x": x, "y": y, "w": w, "h": h})
204
+ return [Cell(**c) for c in data["cells"]]
205
+
206
+ async def get(self, field: str) -> Any:
207
+ return (await self.send({"kind": "get", "field": field}))["value"]
208
+
209
+ async def get_command(self) -> Optional[str]:
210
+ return await self.get("command")
211
+
212
+ async def get_output(self) -> Optional[str]:
213
+ return await self.get("output")
214
+
215
+ async def get_exit_code(self) -> Optional[int]:
216
+ return await self.get("exit-code")
217
+
218
+ async def get_cwd(self) -> Optional[str]:
219
+ return await self.get("cwd")
220
+
221
+ async def get_cursor(self) -> Dict[str, int]:
222
+ return await self.get("cursor")
223
+
224
+ async def get_size(self) -> Dict[str, int]:
225
+ return await self.get("size")
226
+
227
+ async def screenshot(self, path: Optional[str] = None, *, full: bool = False) -> str:
228
+ data = await self.send({"kind": "screenshot", "full": full, "path": path})
229
+ return data.get("path") or data.get("text")
230
+
231
+ async def wait_text(
232
+ self,
233
+ text: str,
234
+ *,
235
+ regex: bool = False,
236
+ full: bool = False,
237
+ not_: bool = False,
238
+ timeout: int = 5000,
239
+ ) -> None:
240
+ await self.send(
241
+ {
242
+ "kind": "wait_text",
243
+ "text": text,
244
+ "regex": regex,
245
+ "full": full,
246
+ "timeout_ms": timeout,
247
+ "not": not_,
248
+ }
249
+ )
250
+
251
+ async def wait_idle(self, *, timeout: int = 5000) -> None:
252
+ await self.send({"kind": "wait_idle", "timeout_ms": timeout})
253
+
254
+ async def wait_command(self, *, timeout: int = 30000) -> None:
255
+ await self.send({"kind": "wait_command", "timeout_ms": timeout})
256
+
257
+ async def wait_exit(self, *, timeout: int = 30000) -> None:
258
+ await self.send({"kind": "wait_exit", "timeout_ms": timeout})
259
+
260
+ async def expect_text(
261
+ self,
262
+ text: str,
263
+ *,
264
+ regex: bool = False,
265
+ full: bool = False,
266
+ strict: bool = True,
267
+ not_: bool = False,
268
+ fg: Optional[str] = None,
269
+ bg: Optional[str] = None,
270
+ timeout: int = 5000,
271
+ ) -> None:
272
+ await self.send(
273
+ {
274
+ "kind": "expect_text",
275
+ "text": text,
276
+ "regex": regex,
277
+ "full": full,
278
+ "strict": strict,
279
+ "not": not_,
280
+ "fg": fg,
281
+ "bg": bg,
282
+ "timeout_ms": timeout,
283
+ }
284
+ )
285
+
286
+ async def expect_exit_code(self, code: int) -> None:
287
+ await self.send({"kind": "expect_exit_code", "code": code})
288
+
289
+ async def expect_output(self, text: str, *, regex: bool = False) -> None:
290
+ await self.send({"kind": "expect_output", "text": text, "regex": regex})
291
+
292
+ async def expect_snapshot(
293
+ self, name: str, *, update: bool = False, include_colors: bool = False
294
+ ) -> str:
295
+ return (
296
+ await self.send(
297
+ {
298
+ "kind": "snapshot",
299
+ "name": name,
300
+ "update": update,
301
+ "include_colors": include_colors,
302
+ "cwd": os.getcwd(),
303
+ }
304
+ )
305
+ )["status"]
306
+
307
+ async def __aenter__(self) -> "ShellUse":
308
+ return self
309
+
310
+ async def __aexit__(self, *exc: Any) -> None:
311
+ await self.close()
312
+
313
+
314
+ async def sessions(*, home: Optional[str] = None) -> List[str]:
315
+ h = cfg.resolve_home(home)
316
+ directory = cfg.home_dir(h)
317
+ out: List[str] = []
318
+ if directory.is_dir():
319
+ for entry in sorted(directory.iterdir()):
320
+ if entry.suffix == ".pid":
321
+ name = entry.stem
322
+ if await transport.can_connect(name, h):
323
+ out.append(name)
324
+ return out
325
+
326
+
327
+ async def close_all(*, binary: Optional[str] = None, home: Optional[str] = None) -> None:
328
+ h = cfg.resolve_home(home)
329
+ b = cfg.resolve_binary(binary)
330
+ for name in await sessions(home=h):
331
+ try:
332
+ await transport.request(name, h, b, {"kind": "close"}, autostart=False)
333
+ except Exception:
334
+ pass
335
+
336
+
337
+ async def daemon_status(
338
+ session: Optional[str] = None,
339
+ *,
340
+ binary: Optional[str] = None,
341
+ home: Optional[str] = None,
342
+ ) -> Dict[str, Any]:
343
+ s = cfg.resolve_session(session)
344
+ h = cfg.resolve_home(home)
345
+ b = cfg.resolve_binary(binary)
346
+ return unwrap(await transport.request(s, h, b, {"kind": "status"}))
347
+
348
+
349
+ async def daemon_stop(
350
+ session: Optional[str] = None,
351
+ *,
352
+ binary: Optional[str] = None,
353
+ home: Optional[str] = None,
354
+ ) -> None:
355
+ s = cfg.resolve_session(session)
356
+ h = cfg.resolve_home(home)
357
+ b = cfg.resolve_binary(binary)
358
+ if not await transport.can_connect(s, h):
359
+ return
360
+ unwrap(await transport.request(s, h, b, {"kind": "shutdown"}, autostart=False))
361
+
362
+
363
+ async def get_recording(
364
+ session: Optional[str] = None, *, home: Optional[str] = None
365
+ ) -> str:
366
+ import asyncio
367
+
368
+ s = cfg.resolve_session(session)
369
+ h = cfg.resolve_home(home)
370
+ path = cfg.recording_path(s, h)
371
+ loop = asyncio.get_running_loop()
372
+ try:
373
+ data = await loop.run_in_executor(None, path.read_bytes)
374
+ except FileNotFoundError:
375
+ from .errors import NoSessionError
376
+
377
+ raise NoSessionError(f"no recording for session '{s}'")
378
+ return data.decode("utf-8", errors="replace")
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class ShellUseError(Exception):
7
+ kind: str = "internal"
8
+ exit_code: int = 5
9
+
10
+ def __init__(self, message: str) -> None:
11
+ super().__init__(message)
12
+ self.message = message
13
+
14
+
15
+ class ExpectationError(ShellUseError):
16
+ kind = "assertion"
17
+ exit_code = 1
18
+
19
+
20
+ class UsageError(ShellUseError):
21
+ kind = "usage"
22
+ exit_code = 2
23
+
24
+
25
+ class NoSessionError(ShellUseError):
26
+ kind = "no_session"
27
+ exit_code = 3
28
+
29
+
30
+ class DaemonError(ShellUseError):
31
+ kind = "daemon"
32
+ exit_code = 4
33
+
34
+
35
+ class VersionMismatchError(ShellUseError):
36
+ kind = "version_mismatch"
37
+ exit_code = 4
38
+
39
+
40
+ class InternalError(ShellUseError):
41
+ kind = "internal"
42
+ exit_code = 5
43
+
44
+
45
+ _BY_KIND = {
46
+ "assertion": ExpectationError,
47
+ "usage": UsageError,
48
+ "no_session": NoSessionError,
49
+ "daemon": DaemonError,
50
+ "internal": InternalError,
51
+ }
52
+
53
+
54
+ def make_error(kind: Optional[str], message: str) -> ShellUseError:
55
+ """Construct the typed error for a daemon ``kind`` string."""
56
+ return _BY_KIND.get(kind or "", InternalError)(message)
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ Color = Union[str, int]
7
+
8
+
9
+ @dataclass
10
+ class Cell:
11
+ x: int
12
+ y: int
13
+ char: str
14
+ fg: Color
15
+ bg: Color
16
+ bold: bool
17
+ italic: bool
18
+ underline: bool
19
+ inverse: bool
20
+
21
+
22
+ @dataclass
23
+ class State:
24
+ cols: int
25
+ rows: int
26
+ cursor: Dict[str, int]
27
+ cwd: Optional[str]
28
+ last_command: Optional[str]
29
+ last_exit: Optional[int]
30
+ exited: Optional[int]
31
+ ready: bool
32
+ text: str
33
+ session_shell: Optional[str]
34
+
35
+ @classmethod
36
+ def from_dict(cls, d: Dict[str, Any]) -> "State":
37
+ return cls(
38
+ cols=d.get("cols", 0),
39
+ rows=d.get("rows", 0),
40
+ cursor=d.get("cursor", {"x": 0, "y": 0}),
41
+ cwd=d.get("cwd"),
42
+ last_command=d.get("last_command"),
43
+ last_exit=d.get("last_exit"),
44
+ exited=d.get("exited"),
45
+ ready=d.get("ready", False),
46
+ text=d.get("text", ""),
47
+ session_shell=d.get("session_shell"),
48
+ )
@@ -1,4 +0,0 @@
1
- /target
2
- *.gif
3
- *.trace
4
- .shell-use/
shell_use-0.0.1/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) Microsoft Corporation.
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE
shell_use-0.0.1/PKG-INFO DELETED
@@ -1,25 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: shell-use
3
- Version: 0.0.1
4
- Summary: A headless terminal CLI + daemon for driving, asserting on, and recording shells
5
- Project-URL: Homepage, https://github.com/microsoft/shell-use
6
- Project-URL: Repository, https://github.com/microsoft/shell-use
7
- Project-URL: Issues, https://github.com/microsoft/shell-use/issues
8
- Author: Microsoft Corporation
9
- License-Expression: MIT
10
- License-File: LICENSE
11
- Keywords: cli,pty,shell,terminal,testing,tui
12
- Classifier: Development Status :: 1 - Planning
13
- Classifier: Intended Audience :: Developers
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3
16
- Classifier: Topic :: Software Development :: Testing
17
- Classifier: Topic :: Terminals
18
- Requires-Python: >=3.8
19
- Description-Content-Type: text/markdown
20
-
21
- # shell-use
22
-
23
- `shell-use` is a Rust-powered CLI for controlling, inspecting, testing, and recording shell sessions and terminal apps.
24
-
25
- This package reserves the `shell-use` name on PyPI. See the project repository for the actual tool and installation instructions: https://github.com/microsoft/shell-use
shell_use-0.0.1/README.md DELETED
@@ -1,5 +0,0 @@
1
- # shell-use
2
-
3
- `shell-use` is a Rust-powered CLI for controlling, inspecting, testing, and recording shell sessions and terminal apps.
4
-
5
- This package reserves the `shell-use` name on PyPI. See the project repository for the actual tool and installation instructions: https://github.com/microsoft/shell-use
shell_use-0.0.1/build.log DELETED
@@ -1,5 +0,0 @@
1
- * Creating isolated environment: venv+pip...
2
- * Installing packages in isolated environment:
3
- - hatchling
4
- * Getting build dependencies for sdist...
5
- * Building sdist...
@@ -1,30 +0,0 @@
1
- [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "shell-use"
7
- version = "0.0.1"
8
- description = "A headless terminal CLI + daemon for driving, asserting on, and recording shells"
9
- readme = "README.md"
10
- requires-python = ">=3.8"
11
- license = "MIT"
12
- license-files = ["LICENSE"]
13
- authors = [{ name = "Microsoft Corporation" }]
14
- keywords = ["terminal", "shell", "cli", "tui", "pty", "testing"]
15
- classifiers = [
16
- "Development Status :: 1 - Planning",
17
- "Intended Audience :: Developers",
18
- "Operating System :: OS Independent",
19
- "Programming Language :: Python :: 3",
20
- "Topic :: Software Development :: Testing",
21
- "Topic :: Terminals",
22
- ]
23
-
24
- [project.urls]
25
- Homepage = "https://github.com/microsoft/shell-use"
26
- Repository = "https://github.com/microsoft/shell-use"
27
- Issues = "https://github.com/microsoft/shell-use/issues"
28
-
29
- [tool.hatch.build.targets.wheel]
30
- packages = ["src/shell_use"]
@@ -1 +0,0 @@
1
- __version__ = "0.0.1"