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
|
@@ -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
|