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,603 @@
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.acp.discovery import discover_acp_manifest
15
+ from connector.acp.manifest import AgentManifest, load_builtin_manifests
16
+ from connector.codex.rpc import JsonRpcStdioClient, codex_candidate_paths
17
+ from connector.launch import (
18
+ LaunchCommand,
19
+ LaunchTarget,
20
+ launch_command_from_target,
21
+ launch_target,
22
+ parse_launch_command,
23
+ path_exists_for_launch,
24
+ )
25
+ from connector.logging import logger
26
+
27
+ _CODEX_CHECK_TIMEOUT_S = 8.0
28
+ _COMMAND_CHECK_TIMEOUT_S = 8.0
29
+ _CODEX_MODEL_PAGE_SIZE = 100
30
+ _CODEX_MODEL_MAX_PAGES = 100
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class RuntimeDiscovery:
35
+ report: dict[str, Any]
36
+ codex_bin: str | None = None
37
+ claude_bin: str | None = None
38
+ codex_target: LaunchCommand | LaunchTarget | None = None
39
+ claude_target: LaunchTarget | None = None
40
+ acp_targets: dict[str, LaunchTarget | None] | None = None
41
+
42
+
43
+ async def discover_runtime_capabilities(*, codex_launch_command: str | None = None) -> RuntimeDiscovery:
44
+ """Discover all runtimes in parallel with *light* ACP probes.
45
+
46
+ Previously every ACP agent was deep-probed sequentially (spawn + session/new),
47
+ which could take minutes and starve the event loop / reconnect loop.
48
+ """
49
+ started = time.perf_counter()
50
+ manifests = load_builtin_manifests()
51
+
52
+ async def _one_acp(manifest: AgentManifest) -> tuple[str, dict[str, Any], LaunchTarget | None]:
53
+ report, target = await discover_acp_manifest(manifest, deep_probe=False)
54
+ return manifest.id, report, target
55
+
56
+ codex_task = asyncio.create_task(
57
+ discover_codex_capability(launch_command=codex_launch_command)
58
+ )
59
+ claude_task = asyncio.create_task(discover_claude_capability())
60
+ acp_tasks = [asyncio.create_task(_one_acp(m)) for m in manifests]
61
+
62
+ codex_report, codex_target = await codex_task
63
+ claude_report, claude_target = await claude_task
64
+ acp_results = await asyncio.gather(*acp_tasks, return_exceptions=True)
65
+
66
+ runtimes: dict[str, Any] = {
67
+ "codex": codex_report,
68
+ "claude": claude_report,
69
+ }
70
+ acp_targets: dict[str, LaunchTarget | None] = {}
71
+ for result in acp_results:
72
+ if isinstance(result, BaseException):
73
+ logger.exception("ACP discovery task failed: {}", result)
74
+ continue
75
+ runtime_id, report, target = result
76
+ runtimes[runtime_id] = report
77
+ acp_targets[runtime_id] = target
78
+
79
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
80
+ logger.info(
81
+ "runtime capability discovery finished elapsed_ms={} acp_agents={}",
82
+ elapsed_ms,
83
+ len(acp_targets),
84
+ )
85
+ return RuntimeDiscovery(
86
+ report={
87
+ "version": 1,
88
+ "checkedAt": _now_iso(),
89
+ "elapsedMs": elapsed_ms,
90
+ "runtimes": runtimes,
91
+ },
92
+ codex_bin=(codex_target.target.path if isinstance(codex_target, LaunchCommand) else codex_target.path) if codex_target else None,
93
+ claude_bin=claude_target.path if claude_target else None,
94
+ codex_target=codex_target,
95
+ claude_target=claude_target,
96
+ acp_targets=acp_targets,
97
+ )
98
+
99
+
100
+ async def discover_acp_capability(
101
+ runtime: str,
102
+ *,
103
+ extra_candidate: str | None = None,
104
+ manifests: list[AgentManifest] | None = None,
105
+ ) -> tuple[dict[str, Any], LaunchTarget | None]:
106
+ for manifest in manifests if manifests is not None else load_builtin_manifests():
107
+ if manifest.id == runtime:
108
+ return await discover_acp_manifest(manifest, extra_candidate=extra_candidate)
109
+ return (
110
+ {
111
+ "history": "unavailable",
112
+ "execution": "unavailable",
113
+ "transport": "acp",
114
+ "error": {
115
+ "code": "unknown_acp_runtime",
116
+ "message": f"No ACP manifest registered for runtime {runtime!r}",
117
+ },
118
+ "checked": [],
119
+ },
120
+ None,
121
+ )
122
+
123
+
124
+ async def discover_codex_capability(
125
+ *, extra_candidate: str | None = None, launch_command: str | None = None
126
+ ) -> tuple[dict[str, Any], LaunchCommand | LaunchTarget | None]:
127
+ """Scan the local machine for a usable Codex install.
128
+
129
+ `extra_candidate`, when set, is checked first as `source="custom"`. Used
130
+ by the per-runtime scan endpoint when the user types a custom path in the
131
+ Add Agent modal.
132
+ """
133
+ if launch_command:
134
+ try:
135
+ parsed = parse_launch_command(launch_command)
136
+ except ValueError as exc:
137
+ return (
138
+ {
139
+ "history": "unavailable",
140
+ "execution": "unavailable",
141
+ "error": {"code": "invalid_launch_command", "message": str(exc)},
142
+ "checked": [],
143
+ },
144
+ None,
145
+ )
146
+ result = await _check_codex_candidate(parsed)
147
+ if result["status"] == "ok":
148
+ report = _codex_report_from_check(result, parsed)
149
+ return report, parsed
150
+ return (
151
+ {
152
+ "history": "unavailable",
153
+ "execution": "unavailable",
154
+ "error": {
155
+ "code": "codex_unavailable",
156
+ "message": str(result.get("reason") or "configured Codex command is unavailable"),
157
+ },
158
+ "checked": [result],
159
+ "launch": parsed.report(),
160
+ },
161
+ None,
162
+ )
163
+
164
+ candidates = codex_candidate_paths()
165
+ if extra_candidate:
166
+ candidates = _dedupe_candidates(
167
+ [{"source": "custom", "path": extra_candidate}, *candidates]
168
+ )
169
+ checked: list[dict[str, Any]] = []
170
+ for candidate in candidates:
171
+ target = _target_from_candidate(candidate)
172
+ result = await _check_codex_candidate(candidate)
173
+ checked.append(result)
174
+ if result["status"] == "ok":
175
+ report = _codex_report_from_check(result, launch_command_from_target(target), checked=checked)
176
+ return (
177
+ report,
178
+ target,
179
+ )
180
+ return (
181
+ {
182
+ "history": "unavailable",
183
+ "execution": "unavailable",
184
+ "error": {
185
+ "code": "codex_unavailable",
186
+ "message": (
187
+ "Codex is unavailable or broken. Checked custom path, Codex App, "
188
+ "and Codex CLI. Plugin-based Codex installations are not supported yet."
189
+ ),
190
+ },
191
+ "checked": checked,
192
+ },
193
+ None,
194
+ )
195
+
196
+
197
+ async def discover_claude_capability(
198
+ *, extra_candidate: str | None = None
199
+ ) -> tuple[dict[str, Any], LaunchTarget | None]:
200
+ history = _check_claude_history()
201
+ candidates = _claude_candidate_paths()
202
+ if extra_candidate:
203
+ candidates = _dedupe_candidates(
204
+ [{"source": "custom", "path": extra_candidate}, *candidates]
205
+ )
206
+ checked: list[dict[str, Any]] = []
207
+ selected_target: LaunchTarget | None = None
208
+ execution = "unavailable"
209
+ for candidate in candidates:
210
+ target = _target_from_candidate(candidate)
211
+ result = await _check_claude_candidate(candidate)
212
+ checked.append(result)
213
+ if result["status"] == "ok":
214
+ selected_target = target
215
+ execution = "ok"
216
+ break
217
+
218
+ report: dict[str, Any] = {
219
+ "history": history["status"],
220
+ "execution": execution,
221
+ "historyCheck": history,
222
+ "checked": checked,
223
+ }
224
+ if selected_target is not None:
225
+ report["selected"] = _selected_from_check(checked[-1])
226
+ else:
227
+ report["error"] = {
228
+ "code": "claude_cli_unavailable",
229
+ "message": "Claude Code is unavailable or broken. Checked CLAUDE_BIN, PATH, and common install paths.",
230
+ }
231
+ return report, selected_target
232
+
233
+
234
+ async def _check_codex_candidate(candidate: dict[str, str] | LaunchTarget | LaunchCommand) -> dict[str, Any]:
235
+ command = candidate if isinstance(candidate, LaunchCommand) else launch_command_from_target(_target_from_candidate(candidate))
236
+ target = command.target
237
+ path = target.path
238
+ source = target.source
239
+ base = {"source": source, "path": path}
240
+ if not Path(path).is_file():
241
+ return {**base, "status": "missing", "reason": "file not found"}
242
+ if not path_exists_for_launch(path):
243
+ return {**base, "status": "failed", "reason": "not executable"}
244
+
245
+ version = await _run_version(command.command(["--version"]))
246
+ if version["status"] != "ok":
247
+ return {**base, "status": "failed", "stage": "version", **version}
248
+
249
+ client = JsonRpcStdioClient(command=command.command(["app-server", "--listen", "stdio://"]))
250
+ try:
251
+ await asyncio.wait_for(client.start(lambda _payload: _noop()), timeout=_CODEX_CHECK_TIMEOUT_S)
252
+ list_result = await asyncio.wait_for(
253
+ client.request("thread/list", {"limit": 1, "sortKey": "updated_at"}),
254
+ timeout=_CODEX_CHECK_TIMEOUT_S,
255
+ )
256
+ model_options = await asyncio.wait_for(
257
+ _read_codex_model_options(client),
258
+ timeout=_CODEX_CHECK_TIMEOUT_S,
259
+ )
260
+ except Exception as exc:
261
+ stage = "model-list" if "model_options" in locals() or _is_model_list_error(exc) else "app-server"
262
+ return {
263
+ **base,
264
+ "status": "failed",
265
+ "stage": stage,
266
+ "version": version.get("stdout"),
267
+ "reason": _exception_reason(exc),
268
+ }
269
+ finally:
270
+ try:
271
+ await client.close()
272
+ except Exception:
273
+ pass
274
+
275
+ return {
276
+ **base,
277
+ "status": "ok",
278
+ "version": version.get("stdout"),
279
+ "threadListKeys": sorted(list_result.keys()),
280
+ "modelOptions": model_options,
281
+ }
282
+
283
+
284
+ async def prepare_codex_launch(
285
+ command: LaunchCommand,
286
+ handler,
287
+ ) -> tuple[dict[str, Any], JsonRpcStdioClient]:
288
+ """Validate a command while keeping the initialized candidate alive."""
289
+
290
+ target = command.target
291
+ if not Path(target.path).is_file() or not path_exists_for_launch(target.path):
292
+ raise ValueError(f"executable is unavailable: {target.path}")
293
+ version = await _run_version(command.command(["--version"]))
294
+ if version["status"] != "ok":
295
+ raise RuntimeError(str(version.get("reason") or "Codex version check failed"))
296
+ client = JsonRpcStdioClient(command=command.command(["app-server", "--listen", "stdio://"]))
297
+ try:
298
+ await asyncio.wait_for(client.start(handler), timeout=_CODEX_CHECK_TIMEOUT_S)
299
+ list_result = await asyncio.wait_for(
300
+ client.request("thread/list", {"limit": 1, "sortKey": "updated_at"}),
301
+ timeout=_CODEX_CHECK_TIMEOUT_S,
302
+ )
303
+ model_options = await asyncio.wait_for(
304
+ _read_codex_model_options(client), timeout=_CODEX_CHECK_TIMEOUT_S
305
+ )
306
+ except Exception:
307
+ await client.close()
308
+ raise
309
+ result = {
310
+ "source": target.source,
311
+ "path": target.path,
312
+ "status": "ok",
313
+ "version": version.get("stdout"),
314
+ "threadListKeys": sorted(list_result.keys()),
315
+ "modelOptions": model_options,
316
+ }
317
+ return _codex_report_from_check(result, command), client
318
+
319
+
320
+ def _codex_report_from_check(
321
+ result: dict[str, Any],
322
+ command: LaunchCommand,
323
+ *,
324
+ checked: list[dict[str, Any]] | None = None,
325
+ ) -> dict[str, Any]:
326
+ report: dict[str, Any] = {
327
+ "history": "ok",
328
+ "execution": "ok",
329
+ "selected": _selected_from_check(result),
330
+ "checked": checked if checked is not None else [result],
331
+ "launch": command.report(mode="command" if command.args or command.raw != command.target.path else "auto"),
332
+ }
333
+ if isinstance(result.get("modelOptions"), list):
334
+ report["modelOptions"] = result["modelOptions"]
335
+ return report
336
+
337
+
338
+ async def _read_codex_model_options(client: JsonRpcStdioClient) -> list[dict[str, Any]]:
339
+ """Read every model/list page from the already-started app-server.
340
+
341
+ Discovery must prove that the selected Codex can execute the catalog we
342
+ advertise. Treat absent, malformed, or empty catalogs as unavailable
343
+ instead of defaulting to a GPT-5.6 model that an older CLI rejects.
344
+ """
345
+ options: list[dict[str, Any]] = []
346
+ seen_models: set[str] = set()
347
+ cursor: str | None = None
348
+ seen_cursors: set[str] = set()
349
+ for _ in range(_CODEX_MODEL_MAX_PAGES):
350
+ params: dict[str, Any] = {"limit": _CODEX_MODEL_PAGE_SIZE}
351
+ if cursor is not None:
352
+ params["cursor"] = cursor
353
+ try:
354
+ result = await asyncio.wait_for(
355
+ client.request("model/list", params),
356
+ timeout=_CODEX_CHECK_TIMEOUT_S,
357
+ )
358
+ except Exception as exc:
359
+ raise RuntimeError(f"model/list failed: {_exception_reason(exc)}") from exc
360
+ page, next_cursor = _codex_model_page(result)
361
+ for item in page:
362
+ option = _normalize_codex_model_option(item)
363
+ if option is None or option["hidden"]:
364
+ continue
365
+ model = option["model"]
366
+ if model in seen_models:
367
+ continue
368
+ seen_models.add(model)
369
+ options.append(option)
370
+ if next_cursor is None:
371
+ break
372
+ if next_cursor in seen_cursors:
373
+ raise RuntimeError("model/list returned a repeated cursor")
374
+ seen_cursors.add(next_cursor)
375
+ cursor = next_cursor
376
+ else:
377
+ raise RuntimeError("model/list exceeded pagination limit")
378
+ if not options:
379
+ raise RuntimeError("model/list returned no usable models")
380
+ return options
381
+
382
+
383
+ def _codex_model_page(result: Any) -> tuple[list[Any], str | None]:
384
+ if not isinstance(result, dict):
385
+ raise RuntimeError("model/list returned a non-object response")
386
+ raw_models: Any = result.get("models")
387
+ if raw_models is None:
388
+ raw_models = result.get("items")
389
+ if raw_models is None:
390
+ raw_models = result.get("data")
391
+ if isinstance(raw_models, dict):
392
+ raw_models = raw_models.get("items") or raw_models.get("models")
393
+ if not isinstance(raw_models, list):
394
+ raise RuntimeError("model/list response has no models list")
395
+ raw_cursor = (
396
+ result.get("nextCursor")
397
+ or result.get("nextPageToken")
398
+ or result.get("next_page_token")
399
+ )
400
+ if raw_cursor is not None and (not isinstance(raw_cursor, str) or not raw_cursor):
401
+ raise RuntimeError("model/list response has an invalid next cursor")
402
+ return raw_models, raw_cursor
403
+
404
+
405
+ def _normalize_codex_model_option(item: Any) -> dict[str, Any] | None:
406
+ if not isinstance(item, dict):
407
+ return None
408
+ model = item.get("model") or item.get("id") or item.get("value")
409
+ if not isinstance(model, str) or not model:
410
+ return None
411
+ supported = item.get("supportedReasoningEfforts")
412
+ if supported is None:
413
+ supported = item.get("supported_reasoning_efforts")
414
+ if supported is not None and not isinstance(supported, list):
415
+ raise RuntimeError(f"model/list returned malformed efforts for {model}")
416
+ normalized_efforts: list[str] | None = None
417
+ if supported is not None:
418
+ normalized_efforts = []
419
+ for effort in supported:
420
+ if isinstance(effort, str):
421
+ value = effort
422
+ elif isinstance(effort, dict):
423
+ value = effort.get("reasoningEffort") or effort.get("effort")
424
+ else:
425
+ value = None
426
+ if not isinstance(value, str) or not value:
427
+ raise RuntimeError(f"model/list returned malformed effort for {model}")
428
+ if value not in normalized_efforts:
429
+ normalized_efforts.append(value)
430
+ default_effort = item.get("defaultReasoningEffort") or item.get("default_reasoning_effort")
431
+ if default_effort is not None and not isinstance(default_effort, str):
432
+ raise RuntimeError(f"model/list returned malformed default effort for {model}")
433
+ return {
434
+ "model": model,
435
+ "displayName": str(item.get("displayName") or item.get("name") or item.get("label") or model),
436
+ "isDefault": bool(item.get("isDefault") or item.get("default")),
437
+ "defaultReasoningEffort": default_effort,
438
+ "supportedReasoningEfforts": normalized_efforts,
439
+ "hidden": bool(item.get("hidden", False)),
440
+ }
441
+
442
+
443
+ def _is_model_list_error(exc: BaseException) -> bool:
444
+ return "model/list" in str(exc)
445
+
446
+
447
+ async def _check_claude_candidate(candidate: dict[str, str] | LaunchTarget) -> dict[str, Any]:
448
+ target = _target_from_candidate(candidate)
449
+ path = target.path
450
+ source = target.source
451
+ base = {"source": source, "path": path}
452
+ if not Path(path).is_file():
453
+ return {**base, "status": "missing", "reason": "file not found"}
454
+ if not path_exists_for_launch(path):
455
+ return {**base, "status": "failed", "reason": "not executable"}
456
+
457
+ version = await _run_version(target.command(["--version"]))
458
+ if version["status"] != "ok":
459
+ return {**base, "status": "failed", "stage": "version", **version}
460
+ help_result = await _run_version(target.command(["--help"]))
461
+ if help_result["status"] != "ok":
462
+ return {
463
+ **base,
464
+ "status": "failed",
465
+ "stage": "help",
466
+ "version": version.get("stdout"),
467
+ "reason": help_result.get("reason"),
468
+ }
469
+ return {**base, "status": "ok", "version": version.get("stdout")}
470
+
471
+
472
+ def _check_claude_history() -> dict[str, Any]:
473
+ source = "claude-agent-sdk"
474
+ api = "list_sessions"
475
+ try:
476
+ sessions = _list_claude_sdk_sessions()
477
+ except Exception as exc:
478
+ return {
479
+ "status": "unavailable",
480
+ "source": source,
481
+ "api": api,
482
+ "reason": _exception_reason(exc),
483
+ }
484
+ return {
485
+ "status": "ok" if sessions else "ok_empty",
486
+ "source": source,
487
+ "api": api,
488
+ "sessionCount": len(sessions),
489
+ }
490
+
491
+
492
+ def _list_claude_sdk_sessions() -> list[Any]:
493
+ sdk = importlib.import_module("claude_agent_sdk")
494
+ list_sessions = getattr(sdk, "list_sessions")
495
+ return list(list_sessions())
496
+
497
+
498
+ async def _run_version(command: list[str]) -> dict[str, Any]:
499
+ try:
500
+ proc = await asyncio.create_subprocess_exec(
501
+ *command,
502
+ stdout=asyncio.subprocess.PIPE,
503
+ stderr=asyncio.subprocess.PIPE,
504
+ )
505
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_COMMAND_CHECK_TIMEOUT_S)
506
+ except Exception as exc:
507
+ return {"status": "failed", "reason": _exception_reason(exc)}
508
+ out = stdout.decode(errors="replace").strip()
509
+ err = stderr.decode(errors="replace").strip()
510
+ if proc.returncode != 0:
511
+ return {
512
+ "status": "failed",
513
+ "reason": f"exit {proc.returncode}",
514
+ "stdout": out[:500],
515
+ "stderr": err[:500],
516
+ }
517
+ return {"status": "ok", "stdout": out[:500], "stderr": err[:500]}
518
+
519
+
520
+ def _claude_candidate_paths() -> list[dict[str, str]]:
521
+ if sys.platform == "win32":
522
+ home = Path.home()
523
+ appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
524
+ return _dedupe_candidates(
525
+ [
526
+ {"source": "custom", "path": os.environ.get("CLAUDE_BIN", "")},
527
+ {"source": "cli", "path": shutil.which("claude") or ""},
528
+ *[
529
+ {"source": "cli", "path": str(home / ".local" / "bin" / name)}
530
+ for name in ("claude.exe", "claude.cmd", "claude.ps1")
531
+ ],
532
+ *[
533
+ {"source": "npm", "path": str(Path(appdata) / "npm" / name)}
534
+ for name in ("claude.cmd", "claude.ps1", "claude.exe")
535
+ ],
536
+ *[
537
+ {"source": "npm", "path": str(home / ".npm-global" / "bin" / name)}
538
+ for name in ("claude.cmd", "claude.ps1", "claude.exe")
539
+ ],
540
+ *[
541
+ {"source": "nvm", "path": str(Path("C:/nvm4w/nodejs") / name)}
542
+ for name in ("claude.cmd", "claude.ps1", "claude.exe")
543
+ ],
544
+ *[
545
+ {"source": "scoop", "path": str(home / "scoop" / "shims" / name)}
546
+ for name in ("claude.exe", "claude.cmd", "claude.ps1")
547
+ ],
548
+ ]
549
+ )
550
+ return _dedupe_candidates(
551
+ [
552
+ {"source": "custom", "path": os.environ.get("CLAUDE_BIN", "")},
553
+ {"source": "cli", "path": shutil.which("claude") or ""},
554
+ {"source": "cli", "path": str(Path.home() / ".npm-global" / "bin" / "claude")},
555
+ {"source": "cli", "path": str(Path.home() / ".local" / "bin" / "claude")},
556
+ {"source": "cli", "path": "/opt/homebrew/bin/claude"},
557
+ {"source": "cli", "path": "/usr/local/bin/claude"},
558
+ ]
559
+ )
560
+
561
+
562
+ def _target_from_candidate(candidate: dict[str, str] | LaunchTarget) -> LaunchTarget:
563
+ if isinstance(candidate, LaunchTarget):
564
+ return candidate
565
+ return launch_target(candidate["source"], candidate["path"])
566
+
567
+
568
+ def _dedupe_candidates(candidates: Iterable[dict[str, str]]) -> list[dict[str, str]]:
569
+ seen: set[str] = set()
570
+ out: list[dict[str, str]] = []
571
+ for candidate in candidates:
572
+ path = candidate.get("path") or ""
573
+ if not path or path in seen:
574
+ continue
575
+ seen.add(path)
576
+ out.append(candidate)
577
+ return out
578
+
579
+
580
+ def _selected_from_check(result: dict[str, Any]) -> dict[str, Any]:
581
+ selected = {
582
+ "source": result["source"],
583
+ "path": result["path"],
584
+ }
585
+ if result.get("version"):
586
+ selected["version"] = result["version"]
587
+ return selected
588
+
589
+
590
+ def _exception_reason(exc: BaseException) -> str:
591
+ if isinstance(exc, TimeoutError):
592
+ return "timeout"
593
+ return str(exc) or exc.__class__.__name__
594
+
595
+
596
+ def _now_iso() -> str:
597
+ from datetime import UTC, datetime
598
+
599
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
600
+
601
+
602
+ async def _noop() -> None:
603
+ 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
+ ]