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.
Files changed (55) hide show
  1. agentlink_cli-0.1.0.dist-info/METADATA +136 -0
  2. agentlink_cli-0.1.0.dist-info/RECORD +55 -0
  3. agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
  4. agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
  5. connector/__init__.py +3 -0
  6. connector/acp/__init__.py +6 -0
  7. connector/acp/adapter.py +1221 -0
  8. connector/acp/config_options.py +175 -0
  9. connector/acp/discovery.py +385 -0
  10. connector/acp/manifest.py +110 -0
  11. connector/acp/manifests/__init__.py +1 -0
  12. connector/acp/manifests/codebuddy.json +37 -0
  13. connector/acp/manifests/cursor.json +39 -0
  14. connector/acp/manifests/gemini.json +33 -0
  15. connector/acp/manifests/grok_build.json +31 -0
  16. connector/acp/reducer.py +615 -0
  17. connector/acp/rpc.py +308 -0
  18. connector/adapter.py +39 -0
  19. connector/attachments.py +36 -0
  20. connector/capabilities.py +603 -0
  21. connector/claude/__init__.py +8 -0
  22. connector/claude/history_adapter.py +642 -0
  23. connector/claude/normalized.py +23 -0
  24. connector/claude/normalizers.py +97 -0
  25. connector/claude/path_utils.py +13 -0
  26. connector/claude/preferences.py +38 -0
  27. connector/claude/sdk_adapter.py +1376 -0
  28. connector/claude/timeline_identity.py +47 -0
  29. connector/claude/timeline_reducer.py +379 -0
  30. connector/claude/trust.py +69 -0
  31. connector/cli.py +280 -0
  32. connector/codex/__init__.py +3 -0
  33. connector/codex/adapter.py +1150 -0
  34. connector/codex/history.py +199 -0
  35. connector/codex/reducer.py +1309 -0
  36. connector/codex/rpc.py +261 -0
  37. connector/control.py +298 -0
  38. connector/json_rpc.py +143 -0
  39. connector/launch.py +310 -0
  40. connector/local/__init__.py +6 -0
  41. connector/local/common.py +118 -0
  42. connector/local/file_ops.py +144 -0
  43. connector/local/ops.py +92 -0
  44. connector/local/shell.py +225 -0
  45. connector/local/terminal.py +658 -0
  46. connector/local_ops.py +5 -0
  47. connector/local_runtime.py +139 -0
  48. connector/logging.py +50 -0
  49. connector/perf.py +89 -0
  50. connector/protocol.py +26 -0
  51. connector/registry.py +49 -0
  52. connector/runtime.py +1309 -0
  53. connector/sync_state.py +155 -0
  54. connector/time.py +7 -0
  55. connector/version.py +13 -0
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import hashlib
5
+ import sys
6
+ from typing import Any
7
+
8
+ from connector.local.common import (
9
+ MAX_DIR_ENTRIES,
10
+ MAX_READ_TEXT_BYTES,
11
+ StaleFileError,
12
+ encoding,
13
+ nearest_existing_dir,
14
+ required_string,
15
+ required_text,
16
+ resolve_path,
17
+ workspace_root,
18
+ )
19
+
20
+
21
+ class FileOps:
22
+ def _windows_drive_entries(self) -> list[dict[str, Any]]:
23
+ if sys.platform != "win32":
24
+ return []
25
+
26
+ entries: list[dict[str, Any]] = []
27
+ from pathlib import Path
28
+
29
+ for code in range(ord("A"), ord("Z") + 1):
30
+ letter = chr(code)
31
+ path = Path(f"{letter}:\\")
32
+ try:
33
+ exists = path.exists()
34
+ except OSError:
35
+ exists = False
36
+ if not exists:
37
+ continue
38
+ entries.append({"name": f"{letter}:", "path": str(path), "type": "directory", "size": None})
39
+ return entries
40
+
41
+ async def prepare_download(self, params: dict[str, Any]) -> dict[str, Any]:
42
+ root = workspace_root(params)
43
+ path = resolve_path(root, required_string(params, "path"))
44
+ if not path.is_file():
45
+ raise FileNotFoundError(f"file not found: {path}")
46
+ data = path.read_bytes()
47
+ return {
48
+ "path": str(path),
49
+ "name": path.name,
50
+ "size": len(data),
51
+ "sha256": hashlib.sha256(data).hexdigest(),
52
+ "mediaType": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
53
+ }
54
+
55
+ def prepared_download_path(self, params: dict[str, Any]) -> str:
56
+ root = workspace_root(params)
57
+ return str(resolve_path(root, required_string(params, "path")))
58
+
59
+ async def write_file(self, params: dict[str, Any]) -> dict[str, Any]:
60
+ root = workspace_root(params)
61
+ path = resolve_path(root, required_string(params, "path"))
62
+ content_encoding = encoding(params)
63
+ content = required_text(params, "content")
64
+ if_match = params.get("ifMatch")
65
+ if not path.parent.is_dir():
66
+ raise FileNotFoundError(f"parent directory not found: {path.parent}")
67
+ if if_match is not None:
68
+ if not isinstance(if_match, str):
69
+ raise ValueError("ifMatch must be a sha256 hex string")
70
+ current_hash = ""
71
+ if path.is_file():
72
+ current_hash = hashlib.sha256(path.read_bytes()).hexdigest()
73
+ elif if_match != "":
74
+ raise StaleFileError(
75
+ f"file disappeared (expected sha256={if_match})"
76
+ )
77
+ if if_match and current_hash != if_match:
78
+ raise StaleFileError(
79
+ f"file changed on disk (expected sha256={if_match}, found sha256={current_hash or 'none'})"
80
+ )
81
+ data = content.encode(content_encoding)
82
+ path.write_bytes(data)
83
+ return {
84
+ "path": str(path),
85
+ "encoding": "utf8",
86
+ "bytesWritten": len(data),
87
+ "sha256": hashlib.sha256(data).hexdigest(),
88
+ }
89
+
90
+ async def read_text(self, params: dict[str, Any]) -> dict[str, Any]:
91
+ root = workspace_root(params)
92
+ path = resolve_path(root, required_string(params, "path"))
93
+ if not path.is_file():
94
+ raise FileNotFoundError(f"file not found: {path}")
95
+ raw_max = params.get("maxBytes", 1_048_576)
96
+ if not isinstance(raw_max, int):
97
+ raise ValueError("maxBytes must be an integer")
98
+ max_bytes = min(max(raw_max, 1), MAX_READ_TEXT_BYTES)
99
+ full = path.read_bytes()
100
+ clipped = full[:max_bytes]
101
+ truncated = len(full) > max_bytes
102
+ binary = b"\x00" in clipped
103
+ content = "" if binary else clipped.decode("utf-8", errors="replace")
104
+ return {
105
+ "path": str(path),
106
+ "name": path.name,
107
+ "size": len(full),
108
+ "sha256": hashlib.sha256(full).hexdigest(),
109
+ "encoding": "utf8",
110
+ "content": content,
111
+ "truncated": truncated,
112
+ "binary": binary,
113
+ }
114
+
115
+ async def read_dir(self, params: dict[str, Any]) -> dict[str, Any]:
116
+ root = workspace_root(params)
117
+ raw_path = params.get("path")
118
+ if sys.platform == "win32" and raw_path == "":
119
+ return {"path": "", "entries": self._windows_drive_entries(), "truncated": False}
120
+ path = root if raw_path is None else resolve_path(root, required_string(params, "path"))
121
+ path = nearest_existing_dir(path, fallback=root)
122
+
123
+ entries: list[dict[str, Any]] = []
124
+ for child in sorted(path.iterdir(), key=lambda item: item.name):
125
+ if len(entries) >= MAX_DIR_ENTRIES:
126
+ break
127
+ try:
128
+ stat = child.stat()
129
+ except OSError:
130
+ stat = None
131
+ entries.append(
132
+ {
133
+ "name": child.name,
134
+ "path": str(child),
135
+ "type": "directory" if child.is_dir() else "file" if child.is_file() else "other",
136
+ "size": stat.st_size if stat is not None and child.is_file() else None,
137
+ }
138
+ )
139
+
140
+ return {
141
+ "path": str(path),
142
+ "entries": entries,
143
+ "truncated": len(entries) >= MAX_DIR_ENTRIES,
144
+ }
connector/local/ops.py ADDED
@@ -0,0 +1,92 @@
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_rename(self, params: dict[str, Any]) -> dict[str, Any]:
70
+ return await self.terminal.rename(params)
71
+
72
+ async def terminal_list(self, params: dict[str, Any]) -> dict[str, Any]:
73
+ return await self.terminal.list(params)
74
+
75
+ async def terminal_release(self, params: dict[str, Any]) -> dict[str, Any]:
76
+ return await self.terminal.release(params)
77
+
78
+ async def terminal_snapshot(self, params: dict[str, Any]) -> dict[str, Any]:
79
+ return await self.terminal.snapshot(params)
80
+
81
+
82
+ def create_local_ops(
83
+ notify: Notify | None = None,
84
+ ) -> LocalOps:
85
+ files = FileOps()
86
+ shell: ShellBackend
87
+ if sys.platform == "win32":
88
+ shell = WindowsShellBackend(notify=notify)
89
+ else:
90
+ shell = UnixShellBackend(notify=notify)
91
+ terminal = default_terminal_backend(notify=notify)
92
+ return LocalOps(files=files, shell=shell, terminal=terminal)
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from connector.local.common import (
12
+ Notify,
13
+ required_int,
14
+ required_string,
15
+ resolve_path,
16
+ shell_result,
17
+ workspace_root,
18
+ )
19
+
20
+
21
+ class ShellBackend:
22
+ def __init__(self, notify: Notify | None = None) -> None:
23
+ self.notify = notify
24
+ self._shell_tasks: dict[str, dict[str, Any]] = {}
25
+
26
+ async def exec(self, params: dict[str, Any]) -> dict[str, Any]:
27
+ root = workspace_root(params)
28
+ cwd = resolve_path(root, required_string(params, "cwd"))
29
+ if not cwd.is_dir():
30
+ raise NotADirectoryError(f"cwd not found: {cwd}")
31
+ command = required_string(params, "command")
32
+ timeout_ms = required_int(params, "timeoutMs")
33
+ if timeout_ms <= 0:
34
+ raise ValueError("timeoutMs must be positive")
35
+
36
+ start = time.monotonic()
37
+ process = await self._create_process(cwd, command)
38
+ timed_out = False
39
+ try:
40
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout_ms / 1000)
41
+ except TimeoutError:
42
+ timed_out = True
43
+ await self._terminate_process(process)
44
+ stdout, stderr = await process.communicate()
45
+
46
+ return shell_result(cwd, command, process.returncode, timed_out, start, stdout, stderr)
47
+
48
+ async def task_start(self, params: dict[str, Any]) -> dict[str, Any]:
49
+ root = workspace_root(params)
50
+ cwd = resolve_path(root, required_string(params, "cwd"))
51
+ if not cwd.is_dir():
52
+ raise NotADirectoryError(f"cwd not found: {cwd}")
53
+ task_id = required_string(params, "taskId")
54
+ session_id = required_string(params, "sessionId")
55
+ command = required_string(params, "command")
56
+ timeout_ms = required_int(params, "timeoutMs")
57
+ if timeout_ms <= 0:
58
+ raise ValueError("timeoutMs must be positive")
59
+ if task_id in self._shell_tasks:
60
+ raise ValueError(f"shell task already exists: {task_id}")
61
+
62
+ record: dict[str, Any] = {"process": None, "cancelled": False}
63
+ background = asyncio.create_task(
64
+ self._run_shell_task(
65
+ task_id=task_id,
66
+ session_id=session_id,
67
+ cwd=cwd,
68
+ command=command,
69
+ timeout_ms=timeout_ms,
70
+ record=record,
71
+ )
72
+ )
73
+ record["background"] = background
74
+ self._shell_tasks[task_id] = record
75
+ await self._notify("shell.task.started", {"taskId": task_id, "sessionId": session_id, "status": "running"})
76
+ return {"taskId": task_id, "sessionId": session_id, "status": "running"}
77
+
78
+ async def task_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
79
+ task_id = required_string(params, "taskId")
80
+ session_id = required_string(params, "sessionId")
81
+ record = self._shell_tasks.get(task_id)
82
+ if record is None:
83
+ return {"taskId": task_id, "sessionId": session_id, "cancelled": False}
84
+ record["cancelled"] = True
85
+ await self._terminate_process(record.get("process"))
86
+ background = record.get("background")
87
+ if isinstance(background, asyncio.Task):
88
+ background.cancel()
89
+ self._shell_tasks.pop(task_id, None)
90
+ await self._notify("shell.task.completed", {"taskId": task_id, "sessionId": session_id, "status": "cancelled"})
91
+ return {"taskId": task_id, "sessionId": session_id, "cancelled": True}
92
+
93
+ async def _run_shell_task(
94
+ self,
95
+ *,
96
+ task_id: str,
97
+ session_id: str,
98
+ cwd: Path,
99
+ command: str,
100
+ timeout_ms: int,
101
+ record: dict[str, Any],
102
+ ) -> None:
103
+ start = time.monotonic()
104
+ timed_out = False
105
+ stdout = b""
106
+ stderr = b""
107
+ process: asyncio.subprocess.Process | None = None
108
+ try:
109
+ process = await self._create_process(cwd, command)
110
+ record["process"] = process
111
+ try:
112
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout_ms / 1000)
113
+ except TimeoutError:
114
+ timed_out = True
115
+ await self._terminate_process(process)
116
+ stdout, stderr = await process.communicate()
117
+ result = shell_result(cwd, command, process.returncode, timed_out, start, stdout, stderr)
118
+ await self._notify(
119
+ "shell.task.completed",
120
+ {"taskId": task_id, "sessionId": session_id, "status": "completed", "result": result},
121
+ )
122
+ except asyncio.CancelledError:
123
+ if process is not None:
124
+ await self._terminate_process(process)
125
+ raise
126
+ except Exception as exc:
127
+ await self._notify(
128
+ "shell.task.completed",
129
+ {
130
+ "taskId": task_id,
131
+ "sessionId": session_id,
132
+ "status": "failed",
133
+ "error": {"code": exc.__class__.__name__, "message": str(exc)},
134
+ },
135
+ )
136
+ finally:
137
+ self._shell_tasks.pop(task_id, None)
138
+
139
+ async def _create_process(self, cwd: Path, command: str) -> asyncio.subprocess.Process:
140
+ raise NotImplementedError
141
+
142
+ async def _terminate_process(self, process: Any) -> None:
143
+ raise NotImplementedError
144
+
145
+ async def _notify(self, method: str, params: dict[str, Any]) -> None:
146
+ if self.notify is not None:
147
+ await self.notify(method, params)
148
+
149
+
150
+ class UnixShellBackend(ShellBackend):
151
+ async def _create_process(self, cwd: Path, command: str) -> asyncio.subprocess.Process:
152
+ return await asyncio.create_subprocess_shell(
153
+ command,
154
+ cwd=str(cwd),
155
+ stdin=asyncio.subprocess.DEVNULL,
156
+ stdout=asyncio.subprocess.PIPE,
157
+ stderr=asyncio.subprocess.PIPE,
158
+ start_new_session=True,
159
+ )
160
+
161
+ async def _terminate_process(self, process: Any) -> None:
162
+ if process is None or getattr(process, "returncode", None) is not None:
163
+ return
164
+ pid = getattr(process, "pid", None)
165
+ if isinstance(pid, int):
166
+ try:
167
+ os.killpg(pid, signal.SIGTERM)
168
+ except ProcessLookupError:
169
+ return
170
+ except OSError:
171
+ process.terminate()
172
+ else:
173
+ process.terminate()
174
+ try:
175
+ await asyncio.wait_for(process.wait(), timeout=2)
176
+ except TimeoutError:
177
+ if isinstance(pid, int):
178
+ try:
179
+ os.killpg(pid, signal.SIGKILL)
180
+ except ProcessLookupError:
181
+ return
182
+ except OSError:
183
+ process.kill()
184
+ else:
185
+ process.kill()
186
+ await process.wait()
187
+
188
+
189
+ class WindowsShellBackend(ShellBackend):
190
+ async def _create_process(self, cwd: Path, command: str) -> asyncio.subprocess.Process:
191
+ creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
192
+ return await asyncio.create_subprocess_shell(
193
+ command,
194
+ cwd=str(cwd),
195
+ stdin=asyncio.subprocess.DEVNULL,
196
+ stdout=asyncio.subprocess.PIPE,
197
+ stderr=asyncio.subprocess.PIPE,
198
+ creationflags=creationflags,
199
+ )
200
+
201
+ async def _terminate_process(self, process: Any) -> None:
202
+ if process is None or getattr(process, "returncode", None) is not None:
203
+ return
204
+ pid = getattr(process, "pid", None)
205
+ if isinstance(pid, int):
206
+ try:
207
+ taskkill = await asyncio.create_subprocess_exec(
208
+ "taskkill",
209
+ "/T",
210
+ "/F",
211
+ "/PID",
212
+ str(pid),
213
+ stdout=asyncio.subprocess.DEVNULL,
214
+ stderr=asyncio.subprocess.DEVNULL,
215
+ )
216
+ await asyncio.wait_for(taskkill.wait(), timeout=5)
217
+ except Exception:
218
+ process.terminate()
219
+ else:
220
+ process.terminate()
221
+ try:
222
+ await asyncio.wait_for(process.wait(), timeout=5)
223
+ except TimeoutError:
224
+ process.kill()
225
+ await process.wait()