agentlink-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.
- agentlink_cli-0.1.0.dist-info/METADATA +136 -0
- agentlink_cli-0.1.0.dist-info/RECORD +55 -0
- agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
- agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/acp/__init__.py +6 -0
- connector/acp/adapter.py +1221 -0
- connector/acp/config_options.py +175 -0
- connector/acp/discovery.py +385 -0
- connector/acp/manifest.py +110 -0
- connector/acp/manifests/__init__.py +1 -0
- connector/acp/manifests/codebuddy.json +37 -0
- connector/acp/manifests/cursor.json +39 -0
- connector/acp/manifests/gemini.json +33 -0
- connector/acp/manifests/grok_build.json +31 -0
- connector/acp/reducer.py +615 -0
- connector/acp/rpc.py +308 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +603 -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 +1376 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +280 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +1150 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1309 -0
- connector/codex/rpc.py +261 -0
- connector/control.py +298 -0
- connector/json_rpc.py +143 -0
- connector/launch.py +310 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +144 -0
- connector/local/ops.py +92 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +658 -0
- connector/local_ops.py +5 -0
- connector/local_runtime.py +139 -0
- connector/logging.py +50 -0
- connector/perf.py +89 -0
- connector/protocol.py +26 -0
- connector/registry.py +49 -0
- connector/runtime.py +1309 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
- connector/version.py +13 -0
connector/json_rpc.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
JsonRpcHandler = Callable[[Any], Any | Awaitable[Any]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JsonRpcError(RuntimeError):
|
|
16
|
+
def __init__(self, code: int, message: str, data: Any = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.code = code
|
|
19
|
+
self.message = message
|
|
20
|
+
self.data = data
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JsonRpcStdioServer:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
reader: asyncio.StreamReader,
|
|
27
|
+
writer: asyncio.StreamWriter,
|
|
28
|
+
handlers: dict[str, JsonRpcHandler],
|
|
29
|
+
) -> None:
|
|
30
|
+
self.reader = reader
|
|
31
|
+
self.writer = writer
|
|
32
|
+
self.handlers = handlers
|
|
33
|
+
self._write_lock = asyncio.Lock()
|
|
34
|
+
|
|
35
|
+
async def serve_forever(self) -> None:
|
|
36
|
+
while line := await self.reader.readline():
|
|
37
|
+
await self.handle_line(line)
|
|
38
|
+
|
|
39
|
+
async def handle_line(self, line: bytes) -> None:
|
|
40
|
+
try:
|
|
41
|
+
payload = json.loads(line)
|
|
42
|
+
except json.JSONDecodeError as exc:
|
|
43
|
+
await self._write_error(None, -32700, "Parse error", {"detail": str(exc)})
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if not isinstance(payload, dict):
|
|
47
|
+
await self._write_error(None, -32600, "Invalid Request")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
request_id = payload.get("id")
|
|
51
|
+
method = payload.get("method")
|
|
52
|
+
if payload.get("jsonrpc") != "2.0" or not isinstance(method, str):
|
|
53
|
+
if request_id is not None:
|
|
54
|
+
await self._write_error(request_id, -32600, "Invalid Request")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
handler = self.handlers.get(method)
|
|
58
|
+
if handler is None:
|
|
59
|
+
if request_id is not None:
|
|
60
|
+
await self._write_error(request_id, -32601, "Method not found")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
result = handler(payload.get("params"))
|
|
65
|
+
if inspect.isawaitable(result):
|
|
66
|
+
result = await result
|
|
67
|
+
except JsonRpcError as exc:
|
|
68
|
+
if request_id is not None:
|
|
69
|
+
await self._write_error(request_id, exc.code, exc.message, exc.data)
|
|
70
|
+
return
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
if request_id is not None:
|
|
73
|
+
await self._write_error(request_id, -32000, str(exc) or exc.__class__.__name__)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if request_id is not None:
|
|
77
|
+
await self.write({"jsonrpc": "2.0", "id": request_id, "result": result})
|
|
78
|
+
|
|
79
|
+
async def notify(self, method: str, params: Any = None) -> None:
|
|
80
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
81
|
+
if params is not None:
|
|
82
|
+
payload["params"] = params
|
|
83
|
+
await self.write(payload)
|
|
84
|
+
|
|
85
|
+
async def write(self, payload: dict[str, Any]) -> None:
|
|
86
|
+
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
87
|
+
async with self._write_lock:
|
|
88
|
+
self.writer.write(data)
|
|
89
|
+
await self.writer.drain()
|
|
90
|
+
|
|
91
|
+
async def _write_error(self, request_id: Any, code: int, message: str, data: Any = None) -> None:
|
|
92
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
93
|
+
if data is not None:
|
|
94
|
+
error["data"] = data
|
|
95
|
+
await self.write({"jsonrpc": "2.0", "id": request_id, "error": error})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ThreadedStdioWriter:
|
|
99
|
+
def __init__(self, stream: Any) -> None:
|
|
100
|
+
self._stream = stream
|
|
101
|
+
self._lock = threading.Lock()
|
|
102
|
+
|
|
103
|
+
def write(self, data: bytes) -> None:
|
|
104
|
+
self._data = data
|
|
105
|
+
|
|
106
|
+
async def drain(self) -> None:
|
|
107
|
+
data = self._data
|
|
108
|
+
await asyncio.to_thread(self._write_sync, data)
|
|
109
|
+
|
|
110
|
+
def _write_sync(self, data: bytes) -> None:
|
|
111
|
+
with self._lock:
|
|
112
|
+
self._stream.write(data)
|
|
113
|
+
self._stream.flush()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _start_threaded_stdin_reader(reader: asyncio.StreamReader, stream: Any) -> None:
|
|
117
|
+
loop = asyncio.get_running_loop()
|
|
118
|
+
|
|
119
|
+
def read_stdin() -> None:
|
|
120
|
+
try:
|
|
121
|
+
while line := stream.readline():
|
|
122
|
+
loop.call_soon_threadsafe(reader.feed_data, line)
|
|
123
|
+
except BaseException as exc: # noqa: BLE001 - forward fatal pipe failures into the async reader.
|
|
124
|
+
loop.call_soon_threadsafe(reader.set_exception, exc)
|
|
125
|
+
return
|
|
126
|
+
loop.call_soon_threadsafe(reader.feed_eof)
|
|
127
|
+
|
|
128
|
+
thread = threading.Thread(target=read_stdin, name="json-rpc-stdio-reader", daemon=True)
|
|
129
|
+
thread.start()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def open_stdio_server(handlers: dict[str, JsonRpcHandler]) -> JsonRpcStdioServer:
|
|
133
|
+
loop = asyncio.get_running_loop()
|
|
134
|
+
reader = asyncio.StreamReader()
|
|
135
|
+
if sys.platform == "win32":
|
|
136
|
+
_start_threaded_stdin_reader(reader, sys.stdin.buffer)
|
|
137
|
+
return JsonRpcStdioServer(reader, ThreadedStdioWriter(sys.stdout.buffer), handlers) # type: ignore[arg-type]
|
|
138
|
+
|
|
139
|
+
reader_protocol = asyncio.StreamReaderProtocol(reader)
|
|
140
|
+
await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin.buffer)
|
|
141
|
+
writer_transport, writer_protocol = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, sys.stdout.buffer)
|
|
142
|
+
writer = asyncio.StreamWriter(writer_transport, writer_protocol, None, loop)
|
|
143
|
+
return JsonRpcStdioServer(reader, writer, handlers)
|
connector/launch.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
Launcher = Literal["direct", "powershell", "cmd"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class LaunchTarget:
|
|
19
|
+
"""How to spawn a CLI agent binary on this host.
|
|
20
|
+
|
|
21
|
+
``path`` is the user-facing / discovery path (agent.cmd, gemini.cmd, …).
|
|
22
|
+
``exec_argv`` when set is the *actual* argv prefix used for CreateProcess
|
|
23
|
+
(e.g. resolved ``node.exe`` + ``index.js`` for Cursor), avoiding slow
|
|
24
|
+
shell wrappers on Windows.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
source: str
|
|
28
|
+
path: str
|
|
29
|
+
launcher: Launcher = "direct"
|
|
30
|
+
exec_argv: tuple[str, ...] | None = None
|
|
31
|
+
|
|
32
|
+
def command(self, args: list[str] | tuple[str, ...] = ()) -> list[str]:
|
|
33
|
+
argv = list(args)
|
|
34
|
+
if self.exec_argv is not None:
|
|
35
|
+
return [*self.exec_argv, *argv]
|
|
36
|
+
if self.launcher == "powershell":
|
|
37
|
+
return [
|
|
38
|
+
_powershell_bin(),
|
|
39
|
+
"-NoProfile",
|
|
40
|
+
"-ExecutionPolicy",
|
|
41
|
+
"Bypass",
|
|
42
|
+
"-File",
|
|
43
|
+
self.path,
|
|
44
|
+
*argv,
|
|
45
|
+
]
|
|
46
|
+
if self.launcher == "cmd":
|
|
47
|
+
# Prefer cmd.exe over PowerShell for .cmd/.bat — ~0.5–1s faster cold
|
|
48
|
+
# start, and avoids nested PowerShell when the script itself invokes PS.
|
|
49
|
+
return [_cmd_bin(), "/d", "/c", self.path, *argv]
|
|
50
|
+
return [self.path, *argv]
|
|
51
|
+
|
|
52
|
+
def report_path(self) -> str:
|
|
53
|
+
return self.path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class LaunchCommand:
|
|
58
|
+
"""A user-selected executable plus arguments, without shell evaluation."""
|
|
59
|
+
|
|
60
|
+
raw: str
|
|
61
|
+
target: LaunchTarget
|
|
62
|
+
args: tuple[str, ...] = ()
|
|
63
|
+
|
|
64
|
+
def command(self, managed_args: list[str] | tuple[str, ...] = ()) -> list[str]:
|
|
65
|
+
return self.target.command([*self.args, *managed_args])
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def launch_id(self) -> str:
|
|
69
|
+
payload = json.dumps(
|
|
70
|
+
[self.target.report_path(), *self.args],
|
|
71
|
+
ensure_ascii=False,
|
|
72
|
+
separators=(",", ":"),
|
|
73
|
+
).encode("utf-8")
|
|
74
|
+
return "codex:" + hashlib.sha256(payload).hexdigest()[:24]
|
|
75
|
+
|
|
76
|
+
def report(self, *, mode: str = "command") -> dict[str, str]:
|
|
77
|
+
return {
|
|
78
|
+
"mode": mode,
|
|
79
|
+
"command": self.raw,
|
|
80
|
+
"launchId": self.launch_id,
|
|
81
|
+
"resolvedExecutable": self.target.report_path(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_UNSUPPORTED_SHELL_TOKENS = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"}
|
|
86
|
+
_CMD_METACHARACTERS = frozenset('&|<>^()%!"')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_launch_command(value: str) -> LaunchCommand:
|
|
90
|
+
"""Parse a frontend command as argv and resolve its executable locally.
|
|
91
|
+
|
|
92
|
+
Shell operators are deliberately rejected. The resulting command is
|
|
93
|
+
always passed to ``create_subprocess_exec`` as an argv list.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
raw = value.strip()
|
|
97
|
+
if not raw:
|
|
98
|
+
raise ValueError("launch command is empty")
|
|
99
|
+
if "\n" in raw or "\r" in raw:
|
|
100
|
+
raise ValueError("launch command must be a single line")
|
|
101
|
+
try:
|
|
102
|
+
argv = _split_command_line(raw)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
raise ValueError(f"invalid launch command: {exc}") from exc
|
|
105
|
+
if not argv:
|
|
106
|
+
raise ValueError("launch command is empty")
|
|
107
|
+
if any(token in _UNSUPPORTED_SHELL_TOKENS for token in argv):
|
|
108
|
+
raise ValueError("shell operators are not supported; enter an executable and arguments only")
|
|
109
|
+
|
|
110
|
+
executable = expand_vars(argv[0])
|
|
111
|
+
resolved = shutil.which(executable)
|
|
112
|
+
if resolved is None and Path(executable).is_file():
|
|
113
|
+
resolved = executable
|
|
114
|
+
if resolved is None:
|
|
115
|
+
raise ValueError(f"executable not found: {argv[0]}")
|
|
116
|
+
target = launch_target("custom", resolved)
|
|
117
|
+
if target.launcher == "cmd" and any(
|
|
118
|
+
character in _CMD_METACHARACTERS
|
|
119
|
+
for token in argv[1:]
|
|
120
|
+
for character in token
|
|
121
|
+
):
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"shell metacharacters are not supported in Windows .cmd/.bat commands"
|
|
124
|
+
)
|
|
125
|
+
return LaunchCommand(raw=raw, target=target, args=tuple(argv[1:]))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def launch_command_from_target(target: LaunchTarget, *, raw: str | None = None) -> LaunchCommand:
|
|
129
|
+
return LaunchCommand(raw=raw or target.report_path(), target=target)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _split_command_line(value: str) -> list[str]:
|
|
133
|
+
if sys.platform != "win32":
|
|
134
|
+
return shlex.split(value, posix=True)
|
|
135
|
+
|
|
136
|
+
# Match the parsing used by CreateProcess/CommandLineToArgvW so quoted
|
|
137
|
+
# Windows paths round-trip exactly. Import lazily to keep Unix clean.
|
|
138
|
+
import ctypes
|
|
139
|
+
from ctypes import wintypes
|
|
140
|
+
|
|
141
|
+
argc = ctypes.c_int()
|
|
142
|
+
command_line_to_argv = ctypes.windll.shell32.CommandLineToArgvW
|
|
143
|
+
command_line_to_argv.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
|
|
144
|
+
command_line_to_argv.restype = ctypes.POINTER(wintypes.LPWSTR)
|
|
145
|
+
argv_ptr = command_line_to_argv(value, ctypes.byref(argc))
|
|
146
|
+
if not argv_ptr:
|
|
147
|
+
raise ValueError("Windows could not parse the command line")
|
|
148
|
+
try:
|
|
149
|
+
return [argv_ptr[index] for index in range(argc.value)]
|
|
150
|
+
finally:
|
|
151
|
+
ctypes.windll.kernel32.LocalFree(argv_ptr)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def launch_target(source: str, path: str) -> LaunchTarget:
|
|
155
|
+
path = expand_vars(path)
|
|
156
|
+
# Cursor agent.cmd → powershell → node is extremely slow. Prefer direct node.
|
|
157
|
+
cursor_argv = _resolve_cursor_agent_exec(path)
|
|
158
|
+
if cursor_argv is not None:
|
|
159
|
+
return LaunchTarget(
|
|
160
|
+
source=source,
|
|
161
|
+
path=path,
|
|
162
|
+
launcher="direct",
|
|
163
|
+
exec_argv=cursor_argv,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
launcher: Launcher = "direct"
|
|
167
|
+
if sys.platform == "win32":
|
|
168
|
+
suffix = Path(path).suffix.lower()
|
|
169
|
+
if suffix == ".ps1":
|
|
170
|
+
launcher = "powershell"
|
|
171
|
+
elif suffix in {".cmd", ".bat"}:
|
|
172
|
+
launcher = "cmd"
|
|
173
|
+
return LaunchTarget(source=source, path=path, launcher=launcher)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def command_name(name: str) -> LaunchTarget | None:
|
|
177
|
+
found = shutil.which(name)
|
|
178
|
+
if not found:
|
|
179
|
+
return None
|
|
180
|
+
return launch_target("cli", found)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def existing_launch_targets(candidates: list[tuple[str, str]]) -> list[LaunchTarget]:
|
|
184
|
+
seen: set[str] = set()
|
|
185
|
+
out: list[LaunchTarget] = []
|
|
186
|
+
for source, raw in candidates:
|
|
187
|
+
path = expand_vars(raw)
|
|
188
|
+
if not path or path in seen:
|
|
189
|
+
continue
|
|
190
|
+
seen.add(path)
|
|
191
|
+
out.append(launch_target(source, path))
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def expand_vars(value: str) -> str:
|
|
196
|
+
return os.path.expandvars(os.path.expanduser(value))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def path_exists_for_launch(path: str) -> bool:
|
|
200
|
+
if not Path(path).is_file():
|
|
201
|
+
return False
|
|
202
|
+
if sys.platform == "win32":
|
|
203
|
+
return True
|
|
204
|
+
return os.access(path, os.X_OK)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_cursor_agent_exec(path: str) -> tuple[str, ...] | None:
|
|
208
|
+
"""If *path* is a Cursor agent shim, return ``(node.exe, index.js)``.
|
|
209
|
+
|
|
210
|
+
Official Windows install layout::
|
|
211
|
+
|
|
212
|
+
%LOCALAPPDATA%/cursor-agent/agent.cmd
|
|
213
|
+
→ powershell → cursor-agent.ps1
|
|
214
|
+
→ versions/<ver>/node.exe versions/<ver>/index.js
|
|
215
|
+
|
|
216
|
+
Spawning node directly cuts multi-second PowerShell cold-start cost that
|
|
217
|
+
previously caused ACP ``initialize`` timeouts.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
p = Path(path)
|
|
221
|
+
except Exception:
|
|
222
|
+
return None
|
|
223
|
+
name = p.name.lower()
|
|
224
|
+
if name not in {"agent.cmd", "agent.ps1", "cursor-agent.cmd", "cursor-agent.ps1", "agent.exe"}:
|
|
225
|
+
# Only resolve known Cursor install shims / paths under cursor-agent.
|
|
226
|
+
if "cursor-agent" not in str(p).lower().replace("\\", "/"):
|
|
227
|
+
return None
|
|
228
|
+
if name not in {"agent", "cursor-agent"} and not name.startswith("agent"):
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# Locate install root (directory containing versions/ or node.exe).
|
|
232
|
+
candidates: list[Path] = []
|
|
233
|
+
if p.is_file():
|
|
234
|
+
candidates.append(p.parent)
|
|
235
|
+
candidates.append(p.parent.parent)
|
|
236
|
+
elif p.is_dir():
|
|
237
|
+
candidates.append(p)
|
|
238
|
+
|
|
239
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
240
|
+
if local:
|
|
241
|
+
candidates.append(Path(local) / "cursor-agent")
|
|
242
|
+
|
|
243
|
+
for root in candidates:
|
|
244
|
+
resolved = _cursor_node_from_root(root)
|
|
245
|
+
if resolved is not None:
|
|
246
|
+
return resolved
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_CURSOR_VERSION_RE = re.compile(r"^\d{4}\.\d{1,2}\.\d{1,2}-.+$")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _cursor_node_from_root(root: Path) -> tuple[str, ...] | None:
|
|
254
|
+
if not root.is_dir():
|
|
255
|
+
return None
|
|
256
|
+
# Same-dir layout (dev / unpacked)
|
|
257
|
+
local_node = root / "node.exe"
|
|
258
|
+
local_index = root / "index.js"
|
|
259
|
+
if local_node.is_file() and local_index.is_file():
|
|
260
|
+
return (str(local_node), str(local_index))
|
|
261
|
+
|
|
262
|
+
versions = root / "versions"
|
|
263
|
+
if not versions.is_dir():
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
version_dirs = [
|
|
267
|
+
d
|
|
268
|
+
for d in versions.iterdir()
|
|
269
|
+
if d.is_dir() and _CURSOR_VERSION_RE.match(d.name)
|
|
270
|
+
]
|
|
271
|
+
if not version_dirs:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def _sort_key(d: Path) -> tuple[int, str]:
|
|
275
|
+
# YYYY.MM.DD-... → integer date for newest-first
|
|
276
|
+
date_part = d.name.split("-", 1)[0]
|
|
277
|
+
parts = date_part.split(".")
|
|
278
|
+
try:
|
|
279
|
+
y, m, day = int(parts[0]), int(parts[1]), int(parts[2])
|
|
280
|
+
return (y * 10000 + m * 100 + day, d.name)
|
|
281
|
+
except (ValueError, IndexError):
|
|
282
|
+
return (0, d.name)
|
|
283
|
+
|
|
284
|
+
latest = max(version_dirs, key=_sort_key)
|
|
285
|
+
node = latest / "node.exe"
|
|
286
|
+
index = latest / "index.js"
|
|
287
|
+
if node.is_file() and index.is_file():
|
|
288
|
+
return (str(node), str(index))
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _cmd_quote(value: str) -> str:
|
|
293
|
+
escaped = value.replace('"', r'\"')
|
|
294
|
+
return f'"{escaped}"'
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _powershell_quote(value: str) -> str:
|
|
298
|
+
return "'" + value.replace("'", "''") + "'"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _powershell_bin() -> str:
|
|
302
|
+
return shutil.which("powershell.exe") or shutil.which("powershell") or "powershell.exe"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _cmd_bin() -> str:
|
|
306
|
+
return (
|
|
307
|
+
shutil.which("cmd.exe")
|
|
308
|
+
or shutil.which("cmd")
|
|
309
|
+
or os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "cmd.exe")
|
|
310
|
+
)
|
|
@@ -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
|
+
}
|