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.
- anywhere_cli-0.1.0.dist-info/METADATA +110 -0
- anywhere_cli-0.1.0.dist-info/RECORD +36 -0
- anywhere_cli-0.1.0.dist-info/WHEEL +4 -0
- anywhere_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +334 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1377 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +149 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +951 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1223 -0
- connector/codex/rpc.py +260 -0
- connector/launch.py +104 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +122 -0
- connector/local/ops.py +83 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +389 -0
- connector/local_ops.py +5 -0
- connector/protocol.py +26 -0
- connector/runtime.py +1002 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
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,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)
|