anywhere-cli 0.1.4__tar.gz

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 (48) hide show
  1. anywhere_cli-0.1.4/.gitignore +30 -0
  2. anywhere_cli-0.1.4/PKG-INFO +111 -0
  3. anywhere_cli-0.1.4/README.md +94 -0
  4. anywhere_cli-0.1.4/connector/__init__.py +3 -0
  5. anywhere_cli-0.1.4/connector/adapter.py +39 -0
  6. anywhere_cli-0.1.4/connector/attachments.py +36 -0
  7. anywhere_cli-0.1.4/connector/capabilities.py +334 -0
  8. anywhere_cli-0.1.4/connector/claude/__init__.py +8 -0
  9. anywhere_cli-0.1.4/connector/claude/history_adapter.py +642 -0
  10. anywhere_cli-0.1.4/connector/claude/normalized.py +23 -0
  11. anywhere_cli-0.1.4/connector/claude/normalizers.py +97 -0
  12. anywhere_cli-0.1.4/connector/claude/path_utils.py +13 -0
  13. anywhere_cli-0.1.4/connector/claude/preferences.py +38 -0
  14. anywhere_cli-0.1.4/connector/claude/sdk_adapter.py +1376 -0
  15. anywhere_cli-0.1.4/connector/claude/timeline_identity.py +47 -0
  16. anywhere_cli-0.1.4/connector/claude/timeline_reducer.py +379 -0
  17. anywhere_cli-0.1.4/connector/claude/trust.py +69 -0
  18. anywhere_cli-0.1.4/connector/cli.py +220 -0
  19. anywhere_cli-0.1.4/connector/codex/__init__.py +3 -0
  20. anywhere_cli-0.1.4/connector/codex/adapter.py +1025 -0
  21. anywhere_cli-0.1.4/connector/codex/history.py +199 -0
  22. anywhere_cli-0.1.4/connector/codex/reducer.py +1239 -0
  23. anywhere_cli-0.1.4/connector/codex/rpc.py +262 -0
  24. anywhere_cli-0.1.4/connector/launch.py +104 -0
  25. anywhere_cli-0.1.4/connector/local/__init__.py +6 -0
  26. anywhere_cli-0.1.4/connector/local/common.py +118 -0
  27. anywhere_cli-0.1.4/connector/local/file_ops.py +122 -0
  28. anywhere_cli-0.1.4/connector/local/ops.py +83 -0
  29. anywhere_cli-0.1.4/connector/local/shell.py +225 -0
  30. anywhere_cli-0.1.4/connector/local/terminal.py +389 -0
  31. anywhere_cli-0.1.4/connector/local_ops.py +5 -0
  32. anywhere_cli-0.1.4/connector/protocol.py +26 -0
  33. anywhere_cli-0.1.4/connector/runtime.py +1021 -0
  34. anywhere_cli-0.1.4/connector/sync_state.py +155 -0
  35. anywhere_cli-0.1.4/connector/time.py +7 -0
  36. anywhere_cli-0.1.4/pyproject.toml +41 -0
  37. anywhere_cli-0.1.4/run.sh +4 -0
  38. anywhere_cli-0.1.4/tests/test_claude_history_adapter.py +344 -0
  39. anywhere_cli-0.1.4/tests/test_claude_preferences.py +68 -0
  40. anywhere_cli-0.1.4/tests/test_claude_sdk_adapter.py +884 -0
  41. anywhere_cli-0.1.4/tests/test_claude_timeline_parity.py +252 -0
  42. anywhere_cli-0.1.4/tests/test_claude_trust.py +60 -0
  43. anywhere_cli-0.1.4/tests/test_codex_adapter.py +1476 -0
  44. anywhere_cli-0.1.4/tests/test_connector_capabilities.py +316 -0
  45. anywhere_cli-0.1.4/tests/test_connector_cli.py +170 -0
  46. anywhere_cli-0.1.4/tests/test_connector_runtime.py +1264 -0
  47. anywhere_cli-0.1.4/tests/test_terminal_backend.py +78 -0
  48. anywhere_cli-0.1.4/uv.lock +912 -0
@@ -0,0 +1,30 @@
1
+ .DS_Store
2
+ xcuserdata/
3
+ *.xcuserstate
4
+ __pycache__/
5
+ *.py[cod]
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .venv/
9
+ .next/
10
+ .env.local
11
+ .env.*.local
12
+
13
+ # Local Claude tooling (launch.json, transcripts, worktree metadata)
14
+ .claude/
15
+ .codex-run/
16
+ agent-server/*-review.html
17
+
18
+ # Local runtime state
19
+ *.sqlite
20
+ *.sqlite3
21
+ *.db
22
+ agent-server/agent-server.files/
23
+
24
+ # Generated reference-doc caches live under docs/reference locally.
25
+ docs/reference/*
26
+ !docs/reference/README.md
27
+
28
+ _reference/*
29
+ !_reference/httpx-s3-client/
30
+ !_reference/httpx-s3-client/**
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: anywhere-cli
3
+ Version: 0.1.4
4
+ Summary: Local runtime connector for Agents Anywhere
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: claude-agent-sdk
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: loguru>=0.7.3
9
+ Requires-Dist: pexpect>=4.9.0; sys_platform != 'win32'
10
+ Requires-Dist: ptyprocess>=0.7.0; sys_platform != 'win32'
11
+ Requires-Dist: pydantic>=2.0.0
12
+ Requires-Dist: pyte>=0.8.2
13
+ Requires-Dist: python-socks>=2.8.1
14
+ Requires-Dist: pywinpty>=2.0.13; sys_platform == 'win32'
15
+ Requires-Dist: websockets>=16.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Anywhere CLI
19
+
20
+ Local runtime connector for Agents Anywhere. It runs on the machine that owns
21
+ the workspace and agent runtimes, connects to the server over HTTP/WebSocket,
22
+ executes connector RPC locally, and uploads normalized runtime/session state
23
+ back to the backend.
24
+
25
+ ## Layout
26
+
27
+ ```text
28
+ connector/
29
+ claude/ Claude Code discovery, adapter, reducer, and transcript logic
30
+ codex/ Codex app-server discovery, RPC, adapter, and reducer logic
31
+ local/ Local filesystem, shell, and terminal backends
32
+ cli.py anywhere-cli CLI
33
+ runtime.py Connector config, auth, WebSocket loop, and RPC dispatch
34
+ tests/ Connector tests
35
+ pyproject.toml Connector dependencies and console script
36
+ run.sh Local helper for saved-config startup
37
+ ```
38
+
39
+ ## Run
40
+
41
+ Install dependencies:
42
+
43
+ ```bash
44
+ uv sync
45
+ ```
46
+
47
+ Start with explicit credentials from the web pairing flow:
48
+
49
+ ```bash
50
+ uvx anywhere-cli start \
51
+ --server-url http://127.0.0.1:8000 \
52
+ --connector-id conn_xxx \
53
+ --connector-token cxt_xxx
54
+ ```
55
+
56
+ Or save the config locally and start without arguments:
57
+
58
+ ```bash
59
+ uvx anywhere-cli configure \
60
+ --server-url http://127.0.0.1:8000 \
61
+ --connector-id conn_xxx \
62
+ --connector-token cxt_xxx
63
+
64
+ uvx anywhere-cli start
65
+ ```
66
+
67
+ The default config path is `~/.agent-server/connector.json`. Override it with
68
+ `--config` or `AGENT_CONNECTOR_CONFIG`.
69
+
70
+ ## Runtime Discovery
71
+
72
+ The connector discovers Codex and Claude locally and reports attached runtime
73
+ capabilities to the server. If a runtime is not on `PATH`, set one of:
74
+
75
+ ```bash
76
+ CODEX_BIN=/path/to/codex
77
+ CLAUDE_BIN=/path/to/claude
78
+ ```
79
+
80
+ The connector uses local runtime credentials and local filesystem permissions.
81
+ Agents Anywhere does not proxy Claude or Codex account credentials.
82
+
83
+ ## Local Operations
84
+
85
+ The server can ask an online connector to perform local work:
86
+
87
+ - read/list/write files inside workspace-safe roots
88
+ - upload/download file content through the server
89
+ - run one-shot shell commands
90
+ - start and wait for shell tasks
91
+ - create, write, resize, stream, list, and close interactive terminals
92
+ - start, interrupt, sync, and approve runtime turns
93
+
94
+ ## Environment
95
+
96
+ | Variable | Purpose |
97
+ | --- | --- |
98
+ | `AGENT_CONNECTOR_CONFIG` | Connector config path. |
99
+ | `AGENT_SERVER_URL` | Server URL used when `--server-url` is omitted. |
100
+ | `AGENT_CONNECTOR_ID` | Connector id used when `--connector-id` is omitted. |
101
+ | `AGENT_CONNECTOR_TOKEN` | Connector token used when `--connector-token` is omitted. |
102
+ | `AGENT_CONNECTOR_ATTACHMENTS_ROOT` | Runtime attachment download directory. Defaults to `~/.agents-anywhere/attachments`. |
103
+ | `CODEX_BIN` | Explicit Codex CLI/app-server path. |
104
+ | `CLAUDE_BIN` | Explicit Claude Code CLI path. |
105
+
106
+ ## Verify
107
+
108
+ ```bash
109
+ uv run ruff check connector tests
110
+ uv run pytest -q
111
+ ```
@@ -0,0 +1,94 @@
1
+ # Anywhere CLI
2
+
3
+ Local runtime connector for Agents Anywhere. It runs on the machine that owns
4
+ the workspace and agent runtimes, connects to the server over HTTP/WebSocket,
5
+ executes connector RPC locally, and uploads normalized runtime/session state
6
+ back to the backend.
7
+
8
+ ## Layout
9
+
10
+ ```text
11
+ connector/
12
+ claude/ Claude Code discovery, adapter, reducer, and transcript logic
13
+ codex/ Codex app-server discovery, RPC, adapter, and reducer logic
14
+ local/ Local filesystem, shell, and terminal backends
15
+ cli.py anywhere-cli CLI
16
+ runtime.py Connector config, auth, WebSocket loop, and RPC dispatch
17
+ tests/ Connector tests
18
+ pyproject.toml Connector dependencies and console script
19
+ run.sh Local helper for saved-config startup
20
+ ```
21
+
22
+ ## Run
23
+
24
+ Install dependencies:
25
+
26
+ ```bash
27
+ uv sync
28
+ ```
29
+
30
+ Start with explicit credentials from the web pairing flow:
31
+
32
+ ```bash
33
+ uvx anywhere-cli start \
34
+ --server-url http://127.0.0.1:8000 \
35
+ --connector-id conn_xxx \
36
+ --connector-token cxt_xxx
37
+ ```
38
+
39
+ Or save the config locally and start without arguments:
40
+
41
+ ```bash
42
+ uvx anywhere-cli configure \
43
+ --server-url http://127.0.0.1:8000 \
44
+ --connector-id conn_xxx \
45
+ --connector-token cxt_xxx
46
+
47
+ uvx anywhere-cli start
48
+ ```
49
+
50
+ The default config path is `~/.agent-server/connector.json`. Override it with
51
+ `--config` or `AGENT_CONNECTOR_CONFIG`.
52
+
53
+ ## Runtime Discovery
54
+
55
+ The connector discovers Codex and Claude locally and reports attached runtime
56
+ capabilities to the server. If a runtime is not on `PATH`, set one of:
57
+
58
+ ```bash
59
+ CODEX_BIN=/path/to/codex
60
+ CLAUDE_BIN=/path/to/claude
61
+ ```
62
+
63
+ The connector uses local runtime credentials and local filesystem permissions.
64
+ Agents Anywhere does not proxy Claude or Codex account credentials.
65
+
66
+ ## Local Operations
67
+
68
+ The server can ask an online connector to perform local work:
69
+
70
+ - read/list/write files inside workspace-safe roots
71
+ - upload/download file content through the server
72
+ - run one-shot shell commands
73
+ - start and wait for shell tasks
74
+ - create, write, resize, stream, list, and close interactive terminals
75
+ - start, interrupt, sync, and approve runtime turns
76
+
77
+ ## Environment
78
+
79
+ | Variable | Purpose |
80
+ | --- | --- |
81
+ | `AGENT_CONNECTOR_CONFIG` | Connector config path. |
82
+ | `AGENT_SERVER_URL` | Server URL used when `--server-url` is omitted. |
83
+ | `AGENT_CONNECTOR_ID` | Connector id used when `--connector-id` is omitted. |
84
+ | `AGENT_CONNECTOR_TOKEN` | Connector token used when `--connector-token` is omitted. |
85
+ | `AGENT_CONNECTOR_ATTACHMENTS_ROOT` | Runtime attachment download directory. Defaults to `~/.agents-anywhere/attachments`. |
86
+ | `CODEX_BIN` | Explicit Codex CLI/app-server path. |
87
+ | `CLAUDE_BIN` | Explicit Claude Code CLI path. |
88
+
89
+ ## Verify
90
+
91
+ ```bash
92
+ uv run ruff check connector tests
93
+ uv run pytest -q
94
+ ```
@@ -0,0 +1,3 @@
1
+ from connector.protocol import RpcNotification, RpcRequest, RpcResponse
2
+
3
+ __all__ = ["RpcNotification", "RpcRequest", "RpcResponse"]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+
7
+ NotificationSink = Callable[[str, dict[str, Any]], Awaitable[None]] | None
8
+
9
+
10
+ @runtime_checkable
11
+ class Adapter(Protocol):
12
+ """Per-runtime backend client (Codex / Claude / OpenCode / ACP).
13
+
14
+ `BackendRpcClient` holds a dict of these keyed by runtime name and routes
15
+ incoming RPCs by `params["runtime"]`. Every adapter must accept a
16
+ `notification_sink` for pushing reduced backend notifications upstream
17
+ (set after construction by the client).
18
+ """
19
+
20
+ notification_sink: NotificationSink
21
+
22
+ async def create_session(self, params: dict[str, Any]) -> dict[str, Any]: ...
23
+
24
+ async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]: ...
25
+
26
+ async def sync_existing_sessions(
27
+ self,
28
+ connector_id: str,
29
+ *,
30
+ limit: int = 100,
31
+ force: bool = False,
32
+ notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
33
+ ) -> dict[str, Any]: ...
34
+
35
+ async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]: ...
36
+
37
+ async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]: ...
38
+
39
+ async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]: ...
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+
7
+ ATTACHMENTS_ROOT_ENV = "AGENT_CONNECTOR_ATTACHMENTS_ROOT"
8
+ DEFAULT_ATTACHMENTS_DIR = ".agents-anywhere/attachments"
9
+ _SAFE_FILENAME_RE = re.compile(r"[^\w.\-+]+")
10
+
11
+
12
+ def attachments_root() -> Path:
13
+ """Return the connector-local root used for runtime attachment copies."""
14
+ import os
15
+
16
+ configured = os.environ.get(ATTACHMENTS_ROOT_ENV)
17
+ root = Path(configured).expanduser() if configured else Path.home() / DEFAULT_ATTACHMENTS_DIR
18
+ return root.resolve(strict=False)
19
+
20
+
21
+ def session_attachments_dir(session_id: str) -> Path:
22
+ session = _safe_filename(session_id) or "session"
23
+ return attachments_root() / session
24
+
25
+
26
+ def attachment_target(session_id: str, file_id: str, original_name: str | None) -> Path:
27
+ safe_file_id = _safe_filename(file_id) or "file"
28
+ safe_name = _safe_filename(original_name or "") or safe_file_id
29
+ return session_attachments_dir(session_id) / f"{safe_file_id}-{safe_name}"
30
+
31
+
32
+ def _safe_filename(name: str) -> str:
33
+ """Reduce arbitrary user/server values to safe single path components."""
34
+ name = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
35
+ sanitized = _SAFE_FILENAME_RE.sub("_", name).strip("._") or ""
36
+ return sanitized[:120]
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib
5
+ import os
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from connector.launch import LaunchTarget, launch_target, path_exists_for_launch
15
+ from connector.codex.rpc import JsonRpcStdioClient, codex_candidate_paths
16
+
17
+
18
+ _CODEX_CHECK_TIMEOUT_S = 8.0
19
+ _COMMAND_CHECK_TIMEOUT_S = 8.0
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class RuntimeDiscovery:
24
+ report: dict[str, Any]
25
+ codex_bin: str | None = None
26
+ claude_bin: str | None = None
27
+ codex_target: LaunchTarget | None = None
28
+ claude_target: LaunchTarget | None = None
29
+
30
+
31
+ async def discover_runtime_capabilities() -> RuntimeDiscovery:
32
+ started = time.perf_counter()
33
+ codex_report, codex_target = await discover_codex_capability()
34
+ claude_report, claude_target = await discover_claude_capability()
35
+ return RuntimeDiscovery(
36
+ report={
37
+ "version": 1,
38
+ "checkedAt": _now_iso(),
39
+ "elapsedMs": round((time.perf_counter() - started) * 1000, 1),
40
+ "runtimes": {
41
+ "codex": codex_report,
42
+ "claude": claude_report,
43
+ },
44
+ },
45
+ codex_bin=codex_target.path if codex_target else None,
46
+ claude_bin=claude_target.path if claude_target else None,
47
+ codex_target=codex_target,
48
+ claude_target=claude_target,
49
+ )
50
+
51
+
52
+ async def discover_codex_capability(
53
+ *, extra_candidate: str | None = None
54
+ ) -> tuple[dict[str, Any], LaunchTarget | None]:
55
+ """Scan the local machine for a usable Codex install.
56
+
57
+ `extra_candidate`, when set, is checked first as `source="custom"`. Used
58
+ by the per-runtime scan endpoint when the user types a custom path in the
59
+ Add Agent modal.
60
+ """
61
+ candidates = codex_candidate_paths()
62
+ if extra_candidate:
63
+ candidates = _dedupe_candidates(
64
+ [{"source": "custom", "path": extra_candidate}, *candidates]
65
+ )
66
+ checked: list[dict[str, Any]] = []
67
+ for candidate in candidates:
68
+ target = _target_from_candidate(candidate)
69
+ result = await _check_codex_candidate(candidate)
70
+ checked.append(result)
71
+ if result["status"] == "ok":
72
+ return (
73
+ {
74
+ "history": "ok",
75
+ "execution": "ok",
76
+ "selected": _selected_from_check(result),
77
+ "checked": checked,
78
+ },
79
+ target,
80
+ )
81
+ return (
82
+ {
83
+ "history": "unavailable",
84
+ "execution": "unavailable",
85
+ "error": {
86
+ "code": "codex_unavailable",
87
+ "message": (
88
+ "Codex is unavailable or broken. Checked custom path, Codex App, "
89
+ "and Codex CLI. Plugin-based Codex installations are not supported yet."
90
+ ),
91
+ },
92
+ "checked": checked,
93
+ },
94
+ None,
95
+ )
96
+
97
+
98
+ async def discover_claude_capability(
99
+ *, extra_candidate: str | None = None
100
+ ) -> tuple[dict[str, Any], LaunchTarget | None]:
101
+ history = _check_claude_history()
102
+ candidates = _claude_candidate_paths()
103
+ if extra_candidate:
104
+ candidates = _dedupe_candidates(
105
+ [{"source": "custom", "path": extra_candidate}, *candidates]
106
+ )
107
+ checked: list[dict[str, Any]] = []
108
+ selected_target: LaunchTarget | None = None
109
+ execution = "unavailable"
110
+ for candidate in candidates:
111
+ target = _target_from_candidate(candidate)
112
+ result = await _check_claude_candidate(candidate)
113
+ checked.append(result)
114
+ if result["status"] == "ok":
115
+ selected_target = target
116
+ execution = "ok"
117
+ break
118
+
119
+ report: dict[str, Any] = {
120
+ "history": history["status"],
121
+ "execution": execution,
122
+ "historyCheck": history,
123
+ "checked": checked,
124
+ }
125
+ if selected_target is not None:
126
+ report["selected"] = _selected_from_check(checked[-1])
127
+ else:
128
+ report["error"] = {
129
+ "code": "claude_cli_unavailable",
130
+ "message": "Claude Code is unavailable or broken. Checked CLAUDE_BIN, PATH, and common install paths.",
131
+ }
132
+ return report, selected_target
133
+
134
+
135
+ async def _check_codex_candidate(candidate: dict[str, str] | LaunchTarget) -> dict[str, Any]:
136
+ target = _target_from_candidate(candidate)
137
+ path = target.path
138
+ source = target.source
139
+ base = {"source": source, "path": path}
140
+ if not Path(path).is_file():
141
+ return {**base, "status": "missing", "reason": "file not found"}
142
+ if not path_exists_for_launch(path):
143
+ return {**base, "status": "failed", "reason": "not executable"}
144
+
145
+ version = await _run_version(target.command(["--version"]))
146
+ if version["status"] != "ok":
147
+ return {**base, "status": "failed", "stage": "version", **version}
148
+
149
+ client = JsonRpcStdioClient(command=target.command(["app-server", "--listen", "stdio://"]))
150
+ try:
151
+ await asyncio.wait_for(client.start(lambda _payload: _noop()), timeout=_CODEX_CHECK_TIMEOUT_S)
152
+ list_result = await asyncio.wait_for(
153
+ client.request("thread/list", {"limit": 1, "sortKey": "updated_at"}),
154
+ timeout=_CODEX_CHECK_TIMEOUT_S,
155
+ )
156
+ except Exception as exc:
157
+ return {
158
+ **base,
159
+ "status": "failed",
160
+ "stage": "app-server",
161
+ "version": version.get("stdout"),
162
+ "reason": _exception_reason(exc),
163
+ }
164
+ finally:
165
+ try:
166
+ await client.close()
167
+ except Exception:
168
+ pass
169
+
170
+ return {
171
+ **base,
172
+ "status": "ok",
173
+ "version": version.get("stdout"),
174
+ "threadListKeys": sorted(list_result.keys()),
175
+ }
176
+
177
+
178
+ async def _check_claude_candidate(candidate: dict[str, str] | LaunchTarget) -> dict[str, Any]:
179
+ target = _target_from_candidate(candidate)
180
+ path = target.path
181
+ source = target.source
182
+ base = {"source": source, "path": path}
183
+ if not Path(path).is_file():
184
+ return {**base, "status": "missing", "reason": "file not found"}
185
+ if not path_exists_for_launch(path):
186
+ return {**base, "status": "failed", "reason": "not executable"}
187
+
188
+ version = await _run_version(target.command(["--version"]))
189
+ if version["status"] != "ok":
190
+ return {**base, "status": "failed", "stage": "version", **version}
191
+ help_result = await _run_version(target.command(["--help"]))
192
+ if help_result["status"] != "ok":
193
+ return {
194
+ **base,
195
+ "status": "failed",
196
+ "stage": "help",
197
+ "version": version.get("stdout"),
198
+ "reason": help_result.get("reason"),
199
+ }
200
+ return {**base, "status": "ok", "version": version.get("stdout")}
201
+
202
+
203
+ def _check_claude_history() -> dict[str, Any]:
204
+ source = "claude-agent-sdk"
205
+ api = "list_sessions"
206
+ try:
207
+ sessions = _list_claude_sdk_sessions()
208
+ except Exception as exc:
209
+ return {
210
+ "status": "unavailable",
211
+ "source": source,
212
+ "api": api,
213
+ "reason": _exception_reason(exc),
214
+ }
215
+ return {
216
+ "status": "ok" if sessions else "ok_empty",
217
+ "source": source,
218
+ "api": api,
219
+ "sessionCount": len(sessions),
220
+ }
221
+
222
+
223
+ def _list_claude_sdk_sessions() -> list[Any]:
224
+ sdk = importlib.import_module("claude_agent_sdk")
225
+ list_sessions = getattr(sdk, "list_sessions")
226
+ return list(list_sessions())
227
+
228
+
229
+ async def _run_version(command: list[str]) -> dict[str, Any]:
230
+ try:
231
+ proc = await asyncio.create_subprocess_exec(
232
+ *command,
233
+ stdout=asyncio.subprocess.PIPE,
234
+ stderr=asyncio.subprocess.PIPE,
235
+ )
236
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_COMMAND_CHECK_TIMEOUT_S)
237
+ except Exception as exc:
238
+ return {"status": "failed", "reason": _exception_reason(exc)}
239
+ out = stdout.decode(errors="replace").strip()
240
+ err = stderr.decode(errors="replace").strip()
241
+ if proc.returncode != 0:
242
+ return {
243
+ "status": "failed",
244
+ "reason": f"exit {proc.returncode}",
245
+ "stdout": out[:500],
246
+ "stderr": err[:500],
247
+ }
248
+ return {"status": "ok", "stdout": out[:500], "stderr": err[:500]}
249
+
250
+
251
+ def _claude_candidate_paths() -> list[dict[str, str]]:
252
+ if sys.platform == "win32":
253
+ home = Path.home()
254
+ appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
255
+ return _dedupe_candidates(
256
+ [
257
+ {"source": "custom", "path": os.environ.get("CLAUDE_BIN", "")},
258
+ {"source": "cli", "path": shutil.which("claude") or ""},
259
+ *[
260
+ {"source": "cli", "path": str(home / ".local" / "bin" / name)}
261
+ for name in ("claude.exe", "claude.cmd", "claude.ps1")
262
+ ],
263
+ *[
264
+ {"source": "npm", "path": str(Path(appdata) / "npm" / name)}
265
+ for name in ("claude.cmd", "claude.ps1", "claude.exe")
266
+ ],
267
+ *[
268
+ {"source": "npm", "path": str(home / ".npm-global" / "bin" / name)}
269
+ for name in ("claude.cmd", "claude.ps1", "claude.exe")
270
+ ],
271
+ *[
272
+ {"source": "nvm", "path": str(Path("C:/nvm4w/nodejs") / name)}
273
+ for name in ("claude.cmd", "claude.ps1", "claude.exe")
274
+ ],
275
+ *[
276
+ {"source": "scoop", "path": str(home / "scoop" / "shims" / name)}
277
+ for name in ("claude.exe", "claude.cmd", "claude.ps1")
278
+ ],
279
+ ]
280
+ )
281
+ return _dedupe_candidates(
282
+ [
283
+ {"source": "custom", "path": os.environ.get("CLAUDE_BIN", "")},
284
+ {"source": "cli", "path": shutil.which("claude") or ""},
285
+ {"source": "cli", "path": str(Path.home() / ".npm-global" / "bin" / "claude")},
286
+ {"source": "cli", "path": str(Path.home() / ".local" / "bin" / "claude")},
287
+ {"source": "cli", "path": "/opt/homebrew/bin/claude"},
288
+ {"source": "cli", "path": "/usr/local/bin/claude"},
289
+ ]
290
+ )
291
+
292
+
293
+ def _target_from_candidate(candidate: dict[str, str] | LaunchTarget) -> LaunchTarget:
294
+ if isinstance(candidate, LaunchTarget):
295
+ return candidate
296
+ return launch_target(candidate["source"], candidate["path"])
297
+
298
+
299
+ def _dedupe_candidates(candidates: Iterable[dict[str, str]]) -> list[dict[str, str]]:
300
+ seen: set[str] = set()
301
+ out: list[dict[str, str]] = []
302
+ for candidate in candidates:
303
+ path = candidate.get("path") or ""
304
+ if not path or path in seen:
305
+ continue
306
+ seen.add(path)
307
+ out.append(candidate)
308
+ return out
309
+
310
+
311
+ def _selected_from_check(result: dict[str, Any]) -> dict[str, Any]:
312
+ selected = {
313
+ "source": result["source"],
314
+ "path": result["path"],
315
+ }
316
+ if result.get("version"):
317
+ selected["version"] = result["version"]
318
+ return selected
319
+
320
+
321
+ def _exception_reason(exc: BaseException) -> str:
322
+ if isinstance(exc, TimeoutError):
323
+ return "timeout"
324
+ return str(exc) or exc.__class__.__name__
325
+
326
+
327
+ def _now_iso() -> str:
328
+ from datetime import UTC, datetime
329
+
330
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
331
+
332
+
333
+ async def _noop() -> None:
334
+ return None
@@ -0,0 +1,8 @@
1
+ from connector.claude.preferences import read_local_preferences
2
+ from connector.claude.sdk_adapter import ClaudeSdkAdapter, ClaudeSdkAdapterError
3
+
4
+ __all__ = [
5
+ "ClaudeSdkAdapter",
6
+ "ClaudeSdkAdapterError",
7
+ "read_local_preferences",
8
+ ]