anywhere-cli 0.1.0__py3-none-any.whl

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.
connector/codex/rpc.py ADDED
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from collections.abc import Awaitable, Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from loguru import logger
14
+
15
+ from connector.launch import launch_target
16
+
17
+
18
+ NotificationHandler = Callable[[dict[str, Any]], Awaitable[None]]
19
+ APP_SERVER_STREAM_LIMIT = 64 * 1024 * 1024
20
+
21
+
22
+ class JsonRpcStdioClient:
23
+ """Line-delimited JSON-RPC client for `codex app-server --listen stdio://`."""
24
+
25
+ def __init__(self, command: list[str] | None = None) -> None:
26
+ self.command = command or _resolve_codex_command()
27
+ self.process: asyncio.subprocess.Process | None = None
28
+ self._next_id = 1
29
+ self._pending: dict[int | str, asyncio.Future[dict[str, Any]]] = {}
30
+ self._server_request_ids: set[int | str] = set()
31
+ self._notification_handler: NotificationHandler | None = None
32
+ self._initialized = False
33
+
34
+ async def start(self, handler: NotificationHandler) -> None:
35
+ if self.process and self._initialized:
36
+ self._notification_handler = handler
37
+ return
38
+
39
+ self._notification_handler = handler
40
+ if self.process is None:
41
+ logger.info("starting codex app-server command={}", self.command)
42
+ self.process = await asyncio.create_subprocess_exec(
43
+ *self.command,
44
+ stdin=asyncio.subprocess.PIPE,
45
+ stdout=asyncio.subprocess.PIPE,
46
+ stderr=asyncio.subprocess.PIPE,
47
+ limit=APP_SERVER_STREAM_LIMIT,
48
+ )
49
+ self._track_reader(asyncio.create_task(self._read_stdout()), "stdout")
50
+ self._track_reader(asyncio.create_task(self._read_stderr()), "stderr")
51
+
52
+ await self.request(
53
+ "initialize",
54
+ {
55
+ "clientInfo": {
56
+ "name": "agent-server-connector",
57
+ "title": "Agent Server Connector",
58
+ "version": "0.1.0",
59
+ },
60
+ "capabilities": {
61
+ "experimentalApi": True,
62
+ "requestAttestation": False,
63
+ },
64
+ },
65
+ )
66
+ await self.notify("initialized")
67
+ self._initialized = True
68
+
69
+ async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
70
+ if not self.process or not self.process.stdin:
71
+ raise RuntimeError("Codex app-server is not started")
72
+
73
+ request_id = self._next_id
74
+ self._next_id += 1
75
+ loop = asyncio.get_running_loop()
76
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
77
+ self._pending[request_id] = future
78
+ payload: dict[str, Any] = {
79
+ "jsonrpc": "2.0",
80
+ "id": request_id,
81
+ "method": method,
82
+ "params": params or {},
83
+ }
84
+
85
+ started = time.perf_counter()
86
+ self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
87
+ await self.process.stdin.drain()
88
+ try:
89
+ return await future
90
+ finally:
91
+ elapsed_ms = (time.perf_counter() - started) * 1000
92
+ logger.trace("codex rpc method={} id={} elapsed_ms={:.1f}", method, request_id, elapsed_ms)
93
+
94
+ async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
95
+ if not self.process or not self.process.stdin:
96
+ raise RuntimeError("Codex app-server is not started")
97
+
98
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}}
99
+ self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
100
+ await self.process.stdin.drain()
101
+
102
+ async def respond(self, request_id: str | int, result: dict[str, Any] | None = None) -> None:
103
+ if not self.process or not self.process.stdin:
104
+ raise RuntimeError("Codex app-server is not started")
105
+
106
+ response_id = self._response_id_for(request_id)
107
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "id": response_id, "result": result or {}}
108
+ self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
109
+ await self.process.stdin.drain()
110
+
111
+ async def close(self) -> None:
112
+ if self.process is None:
113
+ return
114
+ self.process.terminate()
115
+ try:
116
+ await asyncio.wait_for(self.process.wait(), timeout=5)
117
+ except TimeoutError:
118
+ self.process.kill()
119
+ await self.process.wait()
120
+ finally:
121
+ self.process = None
122
+ self._initialized = False
123
+
124
+ async def _read_stdout(self) -> None:
125
+ assert self.process and self.process.stdout
126
+ while line := await self.process.stdout.readline():
127
+ try:
128
+ payload = json.loads(line)
129
+ except json.JSONDecodeError:
130
+ logger.warning("codex app-server emitted non-json stdout: {}", line.decode(errors="replace").strip())
131
+ continue
132
+
133
+ request_id = payload.get("id")
134
+ if request_id in self._pending and ("result" in payload or "error" in payload):
135
+ future = self._pending.pop(request_id)
136
+ self._settle_pending_future(future, payload)
137
+ continue
138
+
139
+ if request_id is not None and isinstance(payload.get("method"), str):
140
+ self._server_request_ids.add(request_id)
141
+
142
+ if self._notification_handler is not None:
143
+ await self._notification_handler(payload)
144
+
145
+ async def _read_stderr(self) -> None:
146
+ assert self.process and self.process.stderr
147
+ while line := await self.process.stderr.readline():
148
+ logger.trace("codex app-server stderr: {}", line.decode(errors="replace").rstrip())
149
+
150
+ def _track_reader(self, task: asyncio.Task[None], name: str) -> None:
151
+ def done(completed: asyncio.Task[None]) -> None:
152
+ try:
153
+ completed.result()
154
+ except asyncio.CancelledError:
155
+ return
156
+ except Exception:
157
+ logger.exception("codex app-server {} reader stopped unexpectedly", name)
158
+
159
+ task.add_done_callback(done)
160
+
161
+ def _settle_pending_future(self, future: asyncio.Future[dict[str, Any]], payload: dict[str, Any]) -> None:
162
+ if future.done():
163
+ logger.trace("codex rpc received response for completed request id={}", payload.get("id"))
164
+ return
165
+ if "error" in payload:
166
+ future.set_exception(RuntimeError(json.dumps(payload["error"], ensure_ascii=False)))
167
+ else:
168
+ future.set_result(payload.get("result") or {})
169
+
170
+ def _response_id_for(self, request_id: str | int) -> str | int:
171
+ if request_id in self._server_request_ids:
172
+ self._server_request_ids.remove(request_id)
173
+ return request_id
174
+ if isinstance(request_id, str):
175
+ try:
176
+ numeric_request_id = int(request_id)
177
+ except ValueError:
178
+ numeric_request_id = None
179
+ if numeric_request_id is not None and numeric_request_id in self._server_request_ids:
180
+ self._server_request_ids.remove(numeric_request_id)
181
+ logger.trace(
182
+ "codex rpc coerced approval response id from string to number request_id={}",
183
+ request_id,
184
+ )
185
+ return numeric_request_id
186
+ logger.warning("codex rpc responding to unknown server request id={}", request_id)
187
+ return request_id
188
+
189
+
190
+ def _resolve_codex_bin() -> str:
191
+ for candidate in codex_candidate_paths():
192
+ if candidate["source"] == "custom":
193
+ return candidate["path"]
194
+ path = Path(candidate["path"])
195
+ if path.is_file():
196
+ return str(path)
197
+ return "codex"
198
+
199
+
200
+ def _resolve_codex_command() -> list[str]:
201
+ path = _resolve_codex_bin()
202
+ return launch_target("cli", path).command(["app-server", "--listen", "stdio://"])
203
+
204
+
205
+ def codex_candidate_paths() -> list[dict[str, str]]:
206
+ if sys.platform == "win32":
207
+ home = Path.home()
208
+ appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
209
+ candidates = [
210
+ {"source": "custom", "path": os.getenv("CODEX_BIN", "")},
211
+ *[
212
+ {"source": "nvm", "path": str(Path("C:/nvm4w/nodejs") / name)}
213
+ for name in ("codex.cmd", "codex.ps1", "codex.exe")
214
+ ],
215
+ {"source": "cli", "path": shutil.which("codex") or ""},
216
+ *[
217
+ {"source": "npm", "path": str(Path(appdata) / "npm" / name)}
218
+ for name in ("codex.cmd", "codex.ps1", "codex.exe")
219
+ ],
220
+ *[
221
+ {"source": "npm", "path": str(home / ".npm-global" / "bin" / name)}
222
+ for name in ("codex.cmd", "codex.ps1", "codex.exe")
223
+ ],
224
+ *[
225
+ {"source": "cli", "path": str(home / ".local" / "bin" / name)}
226
+ for name in ("codex.exe", "codex.cmd", "codex.ps1")
227
+ ],
228
+ *[
229
+ {"source": "scoop", "path": str(home / "scoop" / "shims" / name)}
230
+ for name in ("codex.exe", "codex.cmd", "codex.ps1")
231
+ ],
232
+ ]
233
+ seen: set[str] = set()
234
+ out: list[dict[str, str]] = []
235
+ for candidate in candidates:
236
+ path = candidate.get("path") or ""
237
+ if not path or path in seen:
238
+ continue
239
+ seen.add(path)
240
+ out.append(candidate)
241
+ return out
242
+
243
+ candidates = [
244
+ {"source": "custom", "path": os.getenv("CODEX_BIN", "")},
245
+ {"source": "app", "path": "/Applications/Codex.app/Contents/Resources/codex"},
246
+ {"source": "app", "path": str(Path.home() / "Applications" / "Codex.app" / "Contents" / "Resources" / "codex")},
247
+ {"source": "cli", "path": shutil.which("codex") or ""},
248
+ {"source": "cli", "path": "/opt/homebrew/bin/codex"},
249
+ {"source": "cli", "path": "/usr/local/bin/codex"},
250
+ ]
251
+
252
+ seen: set[str] = set()
253
+ out: list[dict[str, str]] = []
254
+ for candidate in candidates:
255
+ path = candidate.get("path") or ""
256
+ if not path or path in seen:
257
+ continue
258
+ seen.add(path)
259
+ out.append(candidate)
260
+ return out
connector/launch.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+
11
+ Launcher = Literal["direct", "powershell", "cmd"]
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class LaunchTarget:
16
+ source: str
17
+ path: str
18
+ launcher: Launcher = "direct"
19
+
20
+ def command(self, args: list[str] | tuple[str, ...] = ()) -> list[str]:
21
+ argv = list(args)
22
+ if self.launcher == "powershell":
23
+ return [
24
+ _powershell_bin(),
25
+ "-NoProfile",
26
+ "-ExecutionPolicy",
27
+ "Bypass",
28
+ "-File",
29
+ self.path,
30
+ *argv,
31
+ ]
32
+ if self.launcher == "cmd":
33
+ script = _powershell_invoke_script(self.path, argv)
34
+ return [
35
+ _powershell_bin(),
36
+ "-NoProfile",
37
+ "-ExecutionPolicy",
38
+ "Bypass",
39
+ "-Command",
40
+ script,
41
+ ]
42
+ return [self.path, *argv]
43
+
44
+ def report_path(self) -> str:
45
+ return self.path
46
+
47
+
48
+ def launch_target(source: str, path: str) -> LaunchTarget:
49
+ launcher: Launcher = "direct"
50
+ if sys.platform == "win32":
51
+ suffix = Path(path).suffix.lower()
52
+ if suffix == ".ps1":
53
+ launcher = "powershell"
54
+ elif suffix in {".cmd", ".bat"}:
55
+ launcher = "cmd"
56
+ return LaunchTarget(source=source, path=path, launcher=launcher)
57
+
58
+
59
+ def command_name(name: str) -> LaunchTarget | None:
60
+ found = shutil.which(name)
61
+ if not found:
62
+ return None
63
+ return launch_target("cli", found)
64
+
65
+
66
+ def existing_launch_targets(candidates: list[tuple[str, str]]) -> list[LaunchTarget]:
67
+ seen: set[str] = set()
68
+ out: list[LaunchTarget] = []
69
+ for source, raw in candidates:
70
+ path = expand_vars(raw)
71
+ if not path or path in seen:
72
+ continue
73
+ seen.add(path)
74
+ out.append(launch_target(source, path))
75
+ return out
76
+
77
+
78
+ def expand_vars(value: str) -> str:
79
+ return os.path.expandvars(os.path.expanduser(value))
80
+
81
+
82
+ def path_exists_for_launch(path: str) -> bool:
83
+ if not Path(path).is_file():
84
+ return False
85
+ if sys.platform == "win32":
86
+ return True
87
+ return os.access(path, os.X_OK)
88
+
89
+
90
+ def _powershell_invoke_script(path: str, args: list[str]) -> str:
91
+ return " ".join(["&", _powershell_quote(path), *(_powershell_quote(arg) for arg in args)])
92
+
93
+
94
+ def _cmd_quote(value: str) -> str:
95
+ escaped = value.replace('"', r'\"')
96
+ return f'"{escaped}"'
97
+
98
+
99
+ def _powershell_quote(value: str) -> str:
100
+ return "'" + value.replace("'", "''") + "'"
101
+
102
+
103
+ def _powershell_bin() -> str:
104
+ return shutil.which("powershell.exe") or shutil.which("powershell") or "powershell.exe"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from connector.local.common import StaleFileError
4
+ from connector.local.ops import LocalOps, create_local_ops
5
+
6
+ __all__ = ["LocalOps", "StaleFileError", "create_local_ops"]
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any, Awaitable, Callable
6
+
7
+
8
+ MAX_DIR_ENTRIES = 500
9
+ MAX_OUTPUT_CHARS = 64_000
10
+ MAX_READ_TEXT_BYTES = 4 * 1024 * 1024
11
+
12
+ Notify = Callable[[str, dict[str, Any]], Awaitable[None]]
13
+
14
+
15
+ class StaleFileError(Exception):
16
+ """Raised when fs.writeFile's ifMatch check fails."""
17
+
18
+ code = "stale"
19
+
20
+
21
+ def workspace_root(params: dict[str, Any]) -> Path:
22
+ raw_root = params.get("root") or params.get("cwd")
23
+ if not isinstance(raw_root, str) or not raw_root.strip():
24
+ raise ValueError("root is required")
25
+ return Path(raw_root).expanduser().resolve(strict=False)
26
+
27
+
28
+ def resolve_path(root: Path, raw_path: str) -> Path:
29
+ path = Path(raw_path).expanduser()
30
+ if not path.is_absolute():
31
+ path = root / path
32
+ return path.resolve(strict=False)
33
+
34
+
35
+ def nearest_existing_dir(path: Path, *, fallback: Path | None = None) -> Path:
36
+ """Return `path` if it is a directory, otherwise the closest existing parent.
37
+
38
+ Workspace paths can point at projects that were deleted or moved after a
39
+ session was recorded. Runtime panels should still open somewhere useful
40
+ instead of failing on a stale cwd/path.
41
+ """
42
+ current = path
43
+ while True:
44
+ if current.is_dir():
45
+ return current
46
+ parent = current.parent
47
+ if parent == current:
48
+ break
49
+ current = parent
50
+ if fallback is not None:
51
+ fallback_current = fallback
52
+ while True:
53
+ if fallback_current.is_dir():
54
+ return fallback_current
55
+ parent = fallback_current.parent
56
+ if parent == fallback_current:
57
+ break
58
+ fallback_current = parent
59
+ return Path.cwd()
60
+
61
+
62
+ def required_string(params: dict[str, Any], key: str) -> str:
63
+ value = params.get(key)
64
+ if not isinstance(value, str) or not value:
65
+ raise ValueError(f"{key} is required")
66
+ return value
67
+
68
+
69
+ def required_text(params: dict[str, Any], key: str) -> str:
70
+ value = params.get(key)
71
+ if not isinstance(value, str):
72
+ raise ValueError(f"{key} is required")
73
+ return value
74
+
75
+
76
+ def required_int(params: dict[str, Any], key: str) -> int:
77
+ value = params.get(key)
78
+ if not isinstance(value, int):
79
+ raise ValueError(f"{key} is required")
80
+ return value
81
+
82
+
83
+ def encoding(params: dict[str, Any]) -> str:
84
+ value = params.get("encoding", "utf8")
85
+ if value not in {"utf8", "utf-8"}:
86
+ raise ValueError("only utf8 encoding is supported")
87
+ return "utf-8"
88
+
89
+
90
+ def decode_output(output: bytes) -> tuple[str, bool]:
91
+ text = output.decode("utf-8", errors="replace")
92
+ if len(text) <= MAX_OUTPUT_CHARS:
93
+ return text, False
94
+ return text[:MAX_OUTPUT_CHARS], True
95
+
96
+
97
+ def shell_result(
98
+ cwd: Path,
99
+ command: str,
100
+ exit_code: int | None,
101
+ timed_out: bool,
102
+ start: float,
103
+ stdout: bytes,
104
+ stderr: bytes,
105
+ ) -> dict[str, Any]:
106
+ stdout_text, stdout_truncated = decode_output(stdout)
107
+ stderr_text, stderr_truncated = decode_output(stderr)
108
+ return {
109
+ "cwd": str(cwd),
110
+ "command": command,
111
+ "exitCode": exit_code,
112
+ "timedOut": timed_out,
113
+ "durationMs": int((time.monotonic() - start) * 1000),
114
+ "stdout": stdout_text,
115
+ "stderr": stderr_text,
116
+ "stdoutTruncated": stdout_truncated,
117
+ "stderrTruncated": stderr_truncated,
118
+ }
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import hashlib
5
+ from typing import Any
6
+
7
+ from connector.local.common import (
8
+ MAX_DIR_ENTRIES,
9
+ MAX_READ_TEXT_BYTES,
10
+ StaleFileError,
11
+ encoding,
12
+ nearest_existing_dir,
13
+ required_string,
14
+ required_text,
15
+ resolve_path,
16
+ workspace_root,
17
+ )
18
+
19
+
20
+ class FileOps:
21
+ async def prepare_download(self, params: dict[str, Any]) -> dict[str, Any]:
22
+ root = workspace_root(params)
23
+ path = resolve_path(root, required_string(params, "path"))
24
+ if not path.is_file():
25
+ raise FileNotFoundError(f"file not found: {path}")
26
+ data = path.read_bytes()
27
+ return {
28
+ "path": str(path),
29
+ "name": path.name,
30
+ "size": len(data),
31
+ "sha256": hashlib.sha256(data).hexdigest(),
32
+ "mediaType": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
33
+ }
34
+
35
+ def prepared_download_path(self, params: dict[str, Any]) -> str:
36
+ root = workspace_root(params)
37
+ return str(resolve_path(root, required_string(params, "path")))
38
+
39
+ async def write_file(self, params: dict[str, Any]) -> dict[str, Any]:
40
+ root = workspace_root(params)
41
+ path = resolve_path(root, required_string(params, "path"))
42
+ content_encoding = encoding(params)
43
+ content = required_text(params, "content")
44
+ if_match = params.get("ifMatch")
45
+ if not path.parent.is_dir():
46
+ raise FileNotFoundError(f"parent directory not found: {path.parent}")
47
+ if if_match is not None:
48
+ if not isinstance(if_match, str):
49
+ raise ValueError("ifMatch must be a sha256 hex string")
50
+ current_hash = ""
51
+ if path.is_file():
52
+ current_hash = hashlib.sha256(path.read_bytes()).hexdigest()
53
+ elif if_match != "":
54
+ raise StaleFileError(
55
+ f"file disappeared (expected sha256={if_match})"
56
+ )
57
+ if if_match and current_hash != if_match:
58
+ raise StaleFileError(
59
+ f"file changed on disk (expected sha256={if_match}, found sha256={current_hash or 'none'})"
60
+ )
61
+ data = content.encode(content_encoding)
62
+ path.write_bytes(data)
63
+ return {
64
+ "path": str(path),
65
+ "encoding": "utf8",
66
+ "bytesWritten": len(data),
67
+ "sha256": hashlib.sha256(data).hexdigest(),
68
+ }
69
+
70
+ async def read_text(self, params: dict[str, Any]) -> dict[str, Any]:
71
+ root = workspace_root(params)
72
+ path = resolve_path(root, required_string(params, "path"))
73
+ if not path.is_file():
74
+ raise FileNotFoundError(f"file not found: {path}")
75
+ raw_max = params.get("maxBytes", 1_048_576)
76
+ if not isinstance(raw_max, int):
77
+ raise ValueError("maxBytes must be an integer")
78
+ max_bytes = min(max(raw_max, 1), MAX_READ_TEXT_BYTES)
79
+ full = path.read_bytes()
80
+ clipped = full[:max_bytes]
81
+ truncated = len(full) > max_bytes
82
+ binary = b"\x00" in clipped
83
+ content = "" if binary else clipped.decode("utf-8", errors="replace")
84
+ return {
85
+ "path": str(path),
86
+ "name": path.name,
87
+ "size": len(full),
88
+ "sha256": hashlib.sha256(full).hexdigest(),
89
+ "encoding": "utf8",
90
+ "content": content,
91
+ "truncated": truncated,
92
+ "binary": binary,
93
+ }
94
+
95
+ async def read_dir(self, params: dict[str, Any]) -> dict[str, Any]:
96
+ root = workspace_root(params)
97
+ raw_path = params.get("path")
98
+ path = root if raw_path is None else resolve_path(root, required_string(params, "path"))
99
+ path = nearest_existing_dir(path, fallback=root)
100
+
101
+ entries: list[dict[str, Any]] = []
102
+ for child in sorted(path.iterdir(), key=lambda item: item.name):
103
+ if len(entries) >= MAX_DIR_ENTRIES:
104
+ break
105
+ try:
106
+ stat = child.stat()
107
+ except OSError:
108
+ stat = None
109
+ entries.append(
110
+ {
111
+ "name": child.name,
112
+ "path": str(child),
113
+ "type": "directory" if child.is_dir() else "file" if child.is_file() else "other",
114
+ "size": stat.st_size if stat is not None and child.is_file() else None,
115
+ }
116
+ )
117
+
118
+ return {
119
+ "path": str(path),
120
+ "entries": entries,
121
+ "truncated": len(entries) >= MAX_DIR_ENTRIES,
122
+ }
connector/local/ops.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ from connector.local.common import Notify
7
+ from connector.local.file_ops import FileOps
8
+ from connector.local.shell import ShellBackend, UnixShellBackend, WindowsShellBackend
9
+ from connector.local.terminal import TerminalBackend, default_terminal_backend
10
+
11
+
12
+ class LocalOps:
13
+ def __init__(
14
+ self,
15
+ *,
16
+ files: FileOps,
17
+ shell: ShellBackend,
18
+ terminal: TerminalBackend,
19
+ ) -> None:
20
+ self.files = files
21
+ self.shell = shell
22
+ self.terminal = terminal
23
+
24
+ @property
25
+ def notify(self) -> Notify | None:
26
+ return self.shell.notify
27
+
28
+ @notify.setter
29
+ def notify(self, value: Notify | None) -> None:
30
+ self.shell.notify = value
31
+ self.terminal.notify = value
32
+
33
+ async def prepare_download(self, params: dict[str, Any]) -> dict[str, Any]:
34
+ return await self.files.prepare_download(params)
35
+
36
+ def prepared_download_path(self, params: dict[str, Any]) -> str:
37
+ return self.files.prepared_download_path(params)
38
+
39
+ async def write_file(self, params: dict[str, Any]) -> dict[str, Any]:
40
+ return await self.files.write_file(params)
41
+
42
+ async def read_text(self, params: dict[str, Any]) -> dict[str, Any]:
43
+ return await self.files.read_text(params)
44
+
45
+ async def read_dir(self, params: dict[str, Any]) -> dict[str, Any]:
46
+ return await self.files.read_dir(params)
47
+
48
+ async def shell_exec(self, params: dict[str, Any]) -> dict[str, Any]:
49
+ return await self.shell.exec(params)
50
+
51
+ async def shell_task_start(self, params: dict[str, Any]) -> dict[str, Any]:
52
+ return await self.shell.task_start(params)
53
+
54
+ async def shell_task_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
55
+ return await self.shell.task_cancel(params)
56
+
57
+ async def terminal_create(self, params: dict[str, Any]) -> dict[str, Any]:
58
+ return await self.terminal.create(params)
59
+
60
+ async def terminal_write(self, params: dict[str, Any]) -> dict[str, Any]:
61
+ return await self.terminal.write(params)
62
+
63
+ async def terminal_resize(self, params: dict[str, Any]) -> dict[str, Any]:
64
+ return await self.terminal.resize(params)
65
+
66
+ async def terminal_close(self, params: dict[str, Any]) -> dict[str, Any]:
67
+ return await self.terminal.close(params)
68
+
69
+ async def terminal_list(self, params: dict[str, Any]) -> dict[str, Any]:
70
+ return await self.terminal.list(params)
71
+
72
+
73
+ def create_local_ops(
74
+ notify: Notify | None = None,
75
+ ) -> LocalOps:
76
+ files = FileOps()
77
+ shell: ShellBackend
78
+ if sys.platform == "win32":
79
+ shell = WindowsShellBackend(notify=notify)
80
+ else:
81
+ shell = UnixShellBackend(notify=notify)
82
+ terminal = default_terminal_backend(notify=notify)
83
+ return LocalOps(files=files, shell=shell, terminal=terminal)