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,139 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from connector.runtime import ConnectorConfig
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class RuntimeOwner:
14
+ pid: int
15
+ kind: str
16
+ connector_id: str
17
+ server_url: str
18
+ started_at: str | None = None
19
+
20
+
21
+ class ConnectorAlreadyRunningError(RuntimeError):
22
+ def __init__(self, owner: RuntimeOwner) -> None:
23
+ super().__init__(f"connector {owner.connector_id} is already running in {owner.kind} pid {owner.pid}")
24
+ self.owner = owner
25
+
26
+
27
+ def runtime_path(config_path: str | Path | None = None) -> Path:
28
+ base = Path(config_path) if config_path is not None else ConnectorConfig.default_path()
29
+ return base.with_name("connector-runtime.json")
30
+
31
+
32
+ def read_runtime(path: str | Path) -> RuntimeOwner | None:
33
+ try:
34
+ data = json.loads(Path(path).read_text(encoding="utf-8-sig"))
35
+ except (OSError, ValueError):
36
+ return None
37
+ pid = data.get("pid")
38
+ kind = data.get("kind")
39
+ connector_id = data.get("connectorId")
40
+ server_url = data.get("serverUrl")
41
+ if not isinstance(pid, int) or not isinstance(kind, str) or not isinstance(connector_id, str) or not isinstance(server_url, str):
42
+ return None
43
+ return RuntimeOwner(
44
+ pid=pid,
45
+ kind=kind,
46
+ connector_id=connector_id,
47
+ server_url=server_url,
48
+ started_at=data.get("startedAt") if isinstance(data.get("startedAt"), str) else None,
49
+ )
50
+
51
+
52
+ def assert_can_start(path: str | Path, config: ConnectorConfig, *, current_pid: int | None = None) -> None:
53
+ owner = read_runtime(path)
54
+ if owner is None:
55
+ return
56
+ if current_pid is not None and owner.pid == current_pid:
57
+ return
58
+ if not _pid_alive(owner.pid):
59
+ clear_runtime(path)
60
+ return
61
+ raise ConnectorAlreadyRunningError(owner)
62
+
63
+
64
+ def write_runtime(path: str | Path, config: ConnectorConfig, *, kind: str, pid: int | None = None) -> Path:
65
+ import datetime as _dt
66
+
67
+ runtime_file = Path(path)
68
+ runtime_file.parent.mkdir(parents=True, exist_ok=True)
69
+ runtime_file.write_text(
70
+ json.dumps(
71
+ {
72
+ "pid": int(pid if pid is not None else os.getpid()),
73
+ "kind": kind,
74
+ "connectorId": config.connector_id,
75
+ "serverUrl": config.server_url,
76
+ "startedAt": _dt.datetime.now(_dt.UTC).isoformat(),
77
+ },
78
+ indent=2,
79
+ )
80
+ + "\n",
81
+ encoding="utf-8",
82
+ )
83
+ try:
84
+ runtime_file.chmod(0o600)
85
+ except OSError:
86
+ pass
87
+ return runtime_file
88
+
89
+
90
+ def clear_runtime(path: str | Path, *, pid: int | None = None) -> None:
91
+ runtime_file = Path(path)
92
+ if pid is not None:
93
+ owner = read_runtime(runtime_file)
94
+ if owner is not None and owner.pid != pid:
95
+ return
96
+ try:
97
+ runtime_file.unlink()
98
+ except FileNotFoundError:
99
+ return
100
+ except OSError:
101
+ return
102
+
103
+
104
+ def _pid_alive(pid: int) -> bool:
105
+ if pid <= 0:
106
+ return False
107
+ if pid == os.getpid():
108
+ return True
109
+ try:
110
+ os.kill(pid, 0)
111
+ except ProcessLookupError:
112
+ return False
113
+ except PermissionError:
114
+ return True
115
+ except OSError:
116
+ if sys.platform != "win32":
117
+ return False
118
+ return _windows_pid_alive(pid)
119
+ return True
120
+
121
+
122
+ def _windows_pid_alive(pid: int) -> bool:
123
+ try:
124
+ import ctypes
125
+ from ctypes import wintypes
126
+
127
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
128
+ handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
129
+ if not handle:
130
+ return False
131
+ exit_code = wintypes.DWORD()
132
+ try:
133
+ if not ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
134
+ return False
135
+ return exit_code.value == 259
136
+ finally:
137
+ ctypes.windll.kernel32.CloseHandle(handle)
138
+ except Exception:
139
+ return False
connector/logging.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any
6
+
7
+ from loguru import logger as logger
8
+
9
+
10
+ RpcLogNotifier = Callable[[str, Any], Awaitable[None]]
11
+
12
+
13
+ class RpcLogSink:
14
+ def __init__(self, notifier: RpcLogNotifier) -> None:
15
+ self.notifier = notifier
16
+ self._tasks: set[asyncio.Task[None]] = set()
17
+ self._sink_id: int | None = None
18
+
19
+ def install(self, *, level: str = "TRACE", remove_default_sink: bool = False) -> RpcLogSink:
20
+ if remove_default_sink:
21
+ logger.remove()
22
+ self._sink_id = logger.add(self._write, level=level, format="{message}")
23
+ return self
24
+
25
+ async def close(self) -> None:
26
+ if self._sink_id is not None:
27
+ logger.remove(self._sink_id)
28
+ self._sink_id = None
29
+ if self._tasks:
30
+ await asyncio.gather(*self._tasks, return_exceptions=True)
31
+
32
+ def _write(self, message: Any) -> None:
33
+ record = message.record
34
+ payload = {
35
+ "time": record["time"].isoformat(),
36
+ "level": record["level"].name,
37
+ "name": record["name"],
38
+ "message": record["message"],
39
+ }
40
+ exception = record.get("exception")
41
+ if exception is not None:
42
+ payload["exception"] = str(exception)
43
+
44
+ task = asyncio.create_task(self.notifier("connector/log", payload))
45
+ self._tasks.add(task)
46
+ task.add_done_callback(self._tasks.discard)
47
+
48
+
49
+ def install_rpc_log_sink(notifier: RpcLogNotifier, *, level: str = "TRACE", remove_default_sink: bool = False) -> RpcLogSink:
50
+ return RpcLogSink(notifier).install(level=level, remove_default_sink=remove_default_sink)
connector/perf.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from typing import Any
7
+
8
+ from connector.logging import logger
9
+
10
+ _FIELD_ORDER = (
11
+ "method",
12
+ "runtime",
13
+ "session_id",
14
+ "turn_id",
15
+ "connector_id",
16
+ "outcome",
17
+ "command",
18
+ )
19
+
20
+
21
+ def elapsed_ms(started: float) -> float:
22
+ return round((time.perf_counter() - started) * 1000, 1)
23
+
24
+
25
+ def log_stage(stage: str, elapsed_ms_value: float, *, level: str = "info", **fields: Any) -> None:
26
+ parts = [f"stage={stage}", f"elapsed_ms={elapsed_ms_value:.1f}"]
27
+ for key in _FIELD_ORDER:
28
+ value = fields.pop(key, None)
29
+ if value is None or value == "":
30
+ continue
31
+ parts.append(f"{key}={value}")
32
+ for key, value in fields.items():
33
+ if value is None or value == "":
34
+ continue
35
+ parts.append(f"{key}={value}")
36
+ message = " ".join(parts)
37
+ logger.log(level.upper(), message)
38
+
39
+
40
+ class StageTimer:
41
+ """Wall-clock timer for agent invoke stages."""
42
+
43
+ __slots__ = ("_started", "_first_timeline_logged")
44
+
45
+ def __init__(self, started: float | None = None) -> None:
46
+ self._started = time.perf_counter() if started is None else started
47
+ self._first_timeline_logged = False
48
+
49
+ def elapsed_ms(self) -> float:
50
+ return elapsed_ms(self._started)
51
+
52
+ def mark(self, stage: str, *, level: str = "info", **fields: Any) -> float:
53
+ value = self.elapsed_ms()
54
+ log_stage(stage, value, level=level, **fields)
55
+ return value
56
+
57
+ def mark_first_timeline(self, *, level: str = "info", **fields: Any) -> float | None:
58
+ if self._first_timeline_logged:
59
+ return None
60
+ self._first_timeline_logged = True
61
+ # Prefer explicit alias; default is first assistant text token (TTFB).
62
+ stage = fields.pop("stage_alias", None) or "adapter.first_assistant_token"
63
+ return self.mark(str(stage), level=level, **fields)
64
+
65
+ def mark_turn_complete(
66
+ self,
67
+ *,
68
+ outcome: str,
69
+ level: str = "info",
70
+ **fields: Any,
71
+ ) -> float:
72
+ return self.mark("adapter.turn_complete", level=level, outcome=outcome, **fields)
73
+
74
+ @contextmanager
75
+ def span(self, stage: str, *, level: str = "info", **fields: Any) -> Iterator[None]:
76
+ started = time.perf_counter()
77
+ try:
78
+ yield
79
+ finally:
80
+ log_stage(stage, elapsed_ms(started), level=level, **fields)
81
+
82
+
83
+ @contextmanager
84
+ def span_stage(stage: str, *, level: str = "info", **fields: Any) -> Iterator[None]:
85
+ started = time.perf_counter()
86
+ try:
87
+ yield
88
+ finally:
89
+ log_stage(stage, elapsed_ms(started), level=level, **fields)
connector/protocol.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class RpcRequest(BaseModel):
9
+ id: str
10
+ type: Literal["request"] = "request"
11
+ method: str
12
+ params: Any = None
13
+
14
+
15
+ class RpcResponse(BaseModel):
16
+ id: str
17
+ type: Literal["response"] = "response"
18
+ ok: bool
19
+ result: Any = None
20
+ error: dict[str, str] | None = None
21
+
22
+
23
+ class RpcNotification(BaseModel):
24
+ type: Literal["notification"] = "notification"
25
+ method: str
26
+ params: Any = None
connector/registry.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any
5
+
6
+ from connector.acp.adapter import AcpAdapter
7
+ from connector.acp.manifest import AgentManifest, load_builtin_manifests
8
+ from connector.adapter import Adapter
9
+ from connector.claude.history_adapter import ClaudeHistoryAdapter
10
+ from connector.claude.sdk_adapter import ClaudeSdkAdapter
11
+ from connector.codex.adapter import CodexAdapter
12
+ from connector.sync_state import SyncStateStore
13
+
14
+
15
+ NotificationSink = Callable[[str, dict[str, Any]], Awaitable[None]] | None
16
+ AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
17
+
18
+
19
+ def build_default_adapters(
20
+ *,
21
+ notification_sink: NotificationSink = None,
22
+ sync_state_store: SyncStateStore | None = None,
23
+ attachment_downloader: AttachmentDownloader | None = None,
24
+ acp_manifests: list[AgentManifest] | None = None,
25
+ ) -> dict[str, Adapter]:
26
+ """Assemble native + ACP adapters for BackendRpcClient."""
27
+ adapters: dict[str, Adapter] = {
28
+ "codex": CodexAdapter(
29
+ notification_sink=notification_sink,
30
+ sync_state_store=sync_state_store,
31
+ attachment_downloader=attachment_downloader,
32
+ ),
33
+ "claude": ClaudeSdkAdapter(
34
+ notification_sink=notification_sink,
35
+ history_adapter=ClaudeHistoryAdapter(sync_state_store=sync_state_store),
36
+ attachment_downloader=attachment_downloader,
37
+ ),
38
+ }
39
+ for manifest in acp_manifests if acp_manifests is not None else load_builtin_manifests():
40
+ adapters[manifest.id] = AcpAdapter(
41
+ manifest=manifest,
42
+ notification_sink=notification_sink,
43
+ attachment_downloader=attachment_downloader,
44
+ )
45
+ return adapters
46
+
47
+
48
+ def builtin_acp_runtime_ids() -> list[str]:
49
+ return [manifest.id for manifest in load_builtin_manifests()]