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,175 @@
|
|
|
1
|
+
"""Helpers for ACP session configOptions → AA runtime schema options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_model_options(config_options: list[dict[str, Any]] | None) -> list[dict[str, str]]:
|
|
9
|
+
"""Return [{value, label}, ...] from ACP configOptions model category."""
|
|
10
|
+
option = find_config_option(config_options, category="model", preferred_ids=("model", "llm", "models"))
|
|
11
|
+
if option is None:
|
|
12
|
+
return []
|
|
13
|
+
values = option.get("options") if isinstance(option.get("options"), list) else []
|
|
14
|
+
out: list[dict[str, str]] = []
|
|
15
|
+
for entry in values:
|
|
16
|
+
if not isinstance(entry, dict):
|
|
17
|
+
continue
|
|
18
|
+
value = entry.get("value")
|
|
19
|
+
if value is None:
|
|
20
|
+
continue
|
|
21
|
+
label = entry.get("name") or entry.get("label") or str(value)
|
|
22
|
+
out.append({"value": str(value), "label": str(label)})
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_mode_options(config_options: list[dict[str, Any]] | None) -> list[dict[str, str]]:
|
|
27
|
+
option = find_config_option(
|
|
28
|
+
config_options,
|
|
29
|
+
category="mode",
|
|
30
|
+
preferred_ids=("mode", "permission", "permissionMode"),
|
|
31
|
+
)
|
|
32
|
+
if option is None:
|
|
33
|
+
return []
|
|
34
|
+
values = option.get("options") if isinstance(option.get("options"), list) else []
|
|
35
|
+
out: list[dict[str, str]] = []
|
|
36
|
+
for entry in values:
|
|
37
|
+
if not isinstance(entry, dict):
|
|
38
|
+
continue
|
|
39
|
+
value = entry.get("value")
|
|
40
|
+
if value is None:
|
|
41
|
+
continue
|
|
42
|
+
label = entry.get("name") or entry.get("label") or str(value)
|
|
43
|
+
out.append({"value": str(value), "label": str(label)})
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_config_option(
|
|
48
|
+
config_options: list[dict[str, Any]] | None,
|
|
49
|
+
*,
|
|
50
|
+
category: str | None,
|
|
51
|
+
preferred_ids: tuple[str, ...],
|
|
52
|
+
) -> dict[str, Any] | None:
|
|
53
|
+
options = [opt for opt in (config_options or []) if isinstance(opt, dict)]
|
|
54
|
+
if category:
|
|
55
|
+
for opt in options:
|
|
56
|
+
if str(opt.get("category") or "") == category:
|
|
57
|
+
return opt
|
|
58
|
+
for preferred in preferred_ids:
|
|
59
|
+
for opt in options:
|
|
60
|
+
option_id = str(opt.get("id") or "")
|
|
61
|
+
if option_id.lower() == preferred.lower():
|
|
62
|
+
return opt
|
|
63
|
+
for preferred in preferred_ids:
|
|
64
|
+
for opt in options:
|
|
65
|
+
option_id = str(opt.get("id") or "").lower()
|
|
66
|
+
name = str(opt.get("name") or "").lower()
|
|
67
|
+
if preferred.lower() in (option_id, name):
|
|
68
|
+
return opt
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def summarize_auth_methods(auth_methods: list[dict[str, Any]] | None) -> list[dict[str, str]]:
|
|
73
|
+
out: list[dict[str, str]] = []
|
|
74
|
+
for method in auth_methods or []:
|
|
75
|
+
if not isinstance(method, dict):
|
|
76
|
+
continue
|
|
77
|
+
method_id = method.get("id") or method.get("methodId")
|
|
78
|
+
if not method_id:
|
|
79
|
+
continue
|
|
80
|
+
mid = str(method_id)
|
|
81
|
+
out.append(
|
|
82
|
+
{
|
|
83
|
+
"id": mid,
|
|
84
|
+
"name": str(method.get("name") or mid),
|
|
85
|
+
"interactive": "true" if is_interactive_auth_method(mid) else "false",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Methods that open a browser / device-code flow. Calling authenticate() with these
|
|
92
|
+
# is a side-effecting action — a short RPC timeout does NOT prevent the browser from opening.
|
|
93
|
+
_INTERACTIVE_AUTH_METHOD_IDS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"ioa",
|
|
96
|
+
"external",
|
|
97
|
+
"internal",
|
|
98
|
+
"selfhosted",
|
|
99
|
+
"oauth",
|
|
100
|
+
"browser",
|
|
101
|
+
"web",
|
|
102
|
+
"device_code",
|
|
103
|
+
"device-code",
|
|
104
|
+
"login", # bare "login" is usually interactive TUI/browser
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_INTERACTIVE_AUTH_TOKENS = (
|
|
109
|
+
"oauth",
|
|
110
|
+
"browser",
|
|
111
|
+
"web_login",
|
|
112
|
+
"web-login",
|
|
113
|
+
"device_code",
|
|
114
|
+
"device-code",
|
|
115
|
+
"interactive",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Known headless / cached-credential methods (tried first when advertised).
|
|
119
|
+
_HEADLESS_AUTH_METHOD_IDS = (
|
|
120
|
+
"cached_token",
|
|
121
|
+
"cursor_login",
|
|
122
|
+
"xai.api_key",
|
|
123
|
+
"api_key",
|
|
124
|
+
"token",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_interactive_auth_method(method_id: str) -> bool:
|
|
129
|
+
"""Return True if calling authenticate(methodId) is expected to open a browser/TUI."""
|
|
130
|
+
mid = (method_id or "").strip().lower()
|
|
131
|
+
if not mid:
|
|
132
|
+
return True
|
|
133
|
+
if mid in _INTERACTIVE_AUTH_METHOD_IDS:
|
|
134
|
+
return True
|
|
135
|
+
return any(tok in mid for tok in _INTERACTIVE_AUTH_TOKENS)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def order_headless_auth_method_ids(
|
|
139
|
+
advertised: list[str],
|
|
140
|
+
*,
|
|
141
|
+
preferred: list[str] | tuple[str, ...] = (),
|
|
142
|
+
) -> list[str]:
|
|
143
|
+
"""Order only non-interactive auth method ids that the agent actually advertises.
|
|
144
|
+
|
|
145
|
+
Discovery and session start MUST never call interactive OAuth methods — even with
|
|
146
|
+
a short timeout the agent process still opens a browser tab.
|
|
147
|
+
"""
|
|
148
|
+
advertised_set = {m for m in advertised if m}
|
|
149
|
+
ordered: list[str] = []
|
|
150
|
+
for mid in (*preferred, *_HEADLESS_AUTH_METHOD_IDS, *advertised):
|
|
151
|
+
if not mid or mid not in advertised_set:
|
|
152
|
+
continue
|
|
153
|
+
if is_interactive_auth_method(mid):
|
|
154
|
+
continue
|
|
155
|
+
if mid not in ordered:
|
|
156
|
+
ordered.append(mid)
|
|
157
|
+
return ordered
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def order_interactive_auth_method_ids(
|
|
161
|
+
advertised: list[str],
|
|
162
|
+
*,
|
|
163
|
+
preferred: list[str] | tuple[str, ...] = (),
|
|
164
|
+
) -> list[str]:
|
|
165
|
+
"""Order interactive OAuth/browser method ids for user-triggered login only."""
|
|
166
|
+
advertised_set = {m for m in advertised if m}
|
|
167
|
+
ordered: list[str] = []
|
|
168
|
+
for mid in (*preferred, *advertised):
|
|
169
|
+
if not mid or mid not in advertised_set:
|
|
170
|
+
continue
|
|
171
|
+
if not is_interactive_auth_method(mid):
|
|
172
|
+
continue
|
|
173
|
+
if mid not in ordered:
|
|
174
|
+
ordered.append(mid)
|
|
175
|
+
return ordered
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from connector.acp.config_options import (
|
|
12
|
+
extract_mode_options,
|
|
13
|
+
extract_model_options,
|
|
14
|
+
order_headless_auth_method_ids,
|
|
15
|
+
summarize_auth_methods,
|
|
16
|
+
)
|
|
17
|
+
from connector.acp.manifest import AgentManifest
|
|
18
|
+
from connector.acp.rpc import AcpJsonRpcClient, AcpJsonRpcError
|
|
19
|
+
from connector.launch import LaunchTarget, launch_target, path_exists_for_launch
|
|
20
|
+
from connector.logging import logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_COMMAND_CHECK_TIMEOUT_S = 6.0
|
|
24
|
+
# Full auth/model probe is expensive (spawn + session/new). Discovery stays light.
|
|
25
|
+
_LIGHT_INIT_TIMEOUT_S = 10.0
|
|
26
|
+
_DEEP_PROBE_TIMEOUT_S = 12.0
|
|
27
|
+
_AUTH_METHOD_TIMEOUT_S = 4.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def discover_acp_manifest(
|
|
31
|
+
manifest: AgentManifest,
|
|
32
|
+
*,
|
|
33
|
+
extra_candidate: str | None = None,
|
|
34
|
+
deep_probe: bool = False,
|
|
35
|
+
) -> tuple[dict[str, Any], LaunchTarget | None]:
|
|
36
|
+
"""Locate binary and optionally deep-probe ACP initialize/session.
|
|
37
|
+
|
|
38
|
+
Default is *light*: version check only (+ optional short initialize). Full
|
|
39
|
+
session/new auth/model probe is deferred (deep_probe=True or first use).
|
|
40
|
+
"""
|
|
41
|
+
candidates = _candidate_paths(manifest, extra_candidate=extra_candidate)
|
|
42
|
+
checked: list[dict[str, Any]] = []
|
|
43
|
+
for candidate in candidates:
|
|
44
|
+
result = await _check_candidate(manifest, candidate)
|
|
45
|
+
checked.append(result)
|
|
46
|
+
if result["status"] != "ok":
|
|
47
|
+
continue
|
|
48
|
+
target = launch_target(result["source"], result["path"])
|
|
49
|
+
report: dict[str, Any] = {
|
|
50
|
+
"history": "unavailable",
|
|
51
|
+
"execution": "ok",
|
|
52
|
+
"transport": "acp",
|
|
53
|
+
"displayName": manifest.display_name,
|
|
54
|
+
"selected": {
|
|
55
|
+
"source": result["source"],
|
|
56
|
+
"path": result["path"],
|
|
57
|
+
"version": result.get("version"),
|
|
58
|
+
},
|
|
59
|
+
"checked": checked,
|
|
60
|
+
"authHint": manifest.pre_auth_hint,
|
|
61
|
+
"authStatus": "unknown",
|
|
62
|
+
}
|
|
63
|
+
if result.get("versionUnverified"):
|
|
64
|
+
report["versionUnverified"] = True
|
|
65
|
+
report["warnings"] = ["version_check_failed"]
|
|
66
|
+
if deep_probe:
|
|
67
|
+
probe = await probe_acp_agent(manifest, target)
|
|
68
|
+
report.update(probe)
|
|
69
|
+
else:
|
|
70
|
+
# Light: binary is present; auth/models filled on first real session
|
|
71
|
+
# or background deep probe for active runtimes only.
|
|
72
|
+
report["probeMode"] = "light"
|
|
73
|
+
return report, target
|
|
74
|
+
return (
|
|
75
|
+
{
|
|
76
|
+
"history": "unavailable",
|
|
77
|
+
"execution": "unavailable",
|
|
78
|
+
"transport": "acp",
|
|
79
|
+
"displayName": manifest.display_name,
|
|
80
|
+
"error": {
|
|
81
|
+
"code": f"{manifest.id}_unavailable",
|
|
82
|
+
"message": (
|
|
83
|
+
f"{manifest.display_name} is unavailable. "
|
|
84
|
+
f"{manifest.pre_auth_hint or 'Install the CLI and ensure it is on PATH.'}"
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
"checked": checked,
|
|
88
|
+
"authHint": manifest.pre_auth_hint,
|
|
89
|
+
"authStatus": "unknown",
|
|
90
|
+
},
|
|
91
|
+
None,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def probe_acp_agent(manifest: AgentManifest, target: LaunchTarget) -> dict[str, Any]:
|
|
96
|
+
"""Deep probe: initialize (+ optional session/new) for authStatus/models."""
|
|
97
|
+
command = target.command(manifest.launch_args())
|
|
98
|
+
client = AcpJsonRpcClient(command, env=dict(manifest.env) or None, cwd=None)
|
|
99
|
+
probe: dict[str, Any] = {
|
|
100
|
+
"authStatus": "unknown",
|
|
101
|
+
"authMethods": [],
|
|
102
|
+
"probeMode": "deep",
|
|
103
|
+
}
|
|
104
|
+
try:
|
|
105
|
+
await asyncio.wait_for(client.start(), timeout=_LIGHT_INIT_TIMEOUT_S)
|
|
106
|
+
init = await client.request(
|
|
107
|
+
"initialize",
|
|
108
|
+
{
|
|
109
|
+
"protocolVersion": 1,
|
|
110
|
+
"clientCapabilities": {
|
|
111
|
+
**manifest.client_capabilities(),
|
|
112
|
+
"session": {"configOptions": {"boolean": {}}},
|
|
113
|
+
},
|
|
114
|
+
"clientInfo": {"name": "agent-link-discovery", "version": "0.1"},
|
|
115
|
+
},
|
|
116
|
+
timeout=_LIGHT_INIT_TIMEOUT_S,
|
|
117
|
+
)
|
|
118
|
+
auth_methods = init.get("authMethods") if isinstance(init.get("authMethods"), list) else []
|
|
119
|
+
probe["authMethods"] = summarize_auth_methods(auth_methods)
|
|
120
|
+
agent_caps = init.get("agentCapabilities") if isinstance(init.get("agentCapabilities"), dict) else {}
|
|
121
|
+
if agent_caps.get("loadSession"):
|
|
122
|
+
probe["history"] = "ok_empty"
|
|
123
|
+
|
|
124
|
+
# Prefer initialize-only success: many agents are "ok enough" without session/new.
|
|
125
|
+
# session/new is slow/unreliable for Cursor cold start.
|
|
126
|
+
with tempfile.TemporaryDirectory(prefix="agent-link-acp-probe-") as tmp:
|
|
127
|
+
session, auth_err = await _probe_session_new(client, tmp)
|
|
128
|
+
if session is None and auth_err:
|
|
129
|
+
await _try_authenticate(client, manifest, auth_methods)
|
|
130
|
+
session, auth_err = await _probe_session_new(client, tmp)
|
|
131
|
+
|
|
132
|
+
if session is None:
|
|
133
|
+
if auth_err or probe["authMethods"]:
|
|
134
|
+
probe["authStatus"] = "required"
|
|
135
|
+
methods = probe["authMethods"]
|
|
136
|
+
names = ", ".join(
|
|
137
|
+
str(m.get("name") or m.get("id") or "") for m in methods
|
|
138
|
+
) or "login"
|
|
139
|
+
probe["authHint"] = manifest.pre_auth_hint or (
|
|
140
|
+
f"{manifest.display_name} requires authentication ({names}). "
|
|
141
|
+
"Complete CLI login on the device, then refresh."
|
|
142
|
+
)
|
|
143
|
+
probe["execution"] = "ok"
|
|
144
|
+
else:
|
|
145
|
+
# initialize worked — treat as attachable, auth unknown
|
|
146
|
+
probe["authStatus"] = "unknown"
|
|
147
|
+
probe["execution"] = "ok"
|
|
148
|
+
return probe
|
|
149
|
+
|
|
150
|
+
probe["authStatus"] = "ok"
|
|
151
|
+
config_options = (
|
|
152
|
+
session.get("configOptions") if isinstance(session.get("configOptions"), list) else []
|
|
153
|
+
)
|
|
154
|
+
config_options = [opt for opt in config_options if isinstance(opt, dict)]
|
|
155
|
+
if config_options:
|
|
156
|
+
probe["configOptions"] = config_options
|
|
157
|
+
models = extract_model_options(config_options)
|
|
158
|
+
modes = extract_mode_options(config_options)
|
|
159
|
+
if models:
|
|
160
|
+
probe["modelOptions"] = models
|
|
161
|
+
if modes:
|
|
162
|
+
probe["modeOptions"] = modes
|
|
163
|
+
session_id = session.get("sessionId") or session.get("session_id")
|
|
164
|
+
if isinstance(session_id, str) and session_id:
|
|
165
|
+
await _best_effort_close_session(client, session_id)
|
|
166
|
+
return probe
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
logger.warning("ACP probe failed runtime={} error={}", manifest.id, exc)
|
|
169
|
+
probe["authStatus"] = "unknown"
|
|
170
|
+
probe["probeError"] = str(exc)
|
|
171
|
+
# Binary was found; keep execution ok so UI can still attach and retry on use.
|
|
172
|
+
probe["execution"] = "ok"
|
|
173
|
+
return probe
|
|
174
|
+
finally:
|
|
175
|
+
try:
|
|
176
|
+
await client.close()
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _looks_like_auth_error(message: str) -> bool:
|
|
182
|
+
text = (message or "").lower()
|
|
183
|
+
tokens = (
|
|
184
|
+
"auth",
|
|
185
|
+
"login",
|
|
186
|
+
"unauthor",
|
|
187
|
+
"api key",
|
|
188
|
+
"apikey",
|
|
189
|
+
"credential",
|
|
190
|
+
"not configured",
|
|
191
|
+
"missing or not",
|
|
192
|
+
"sign in",
|
|
193
|
+
"signin",
|
|
194
|
+
"token",
|
|
195
|
+
"permission denied",
|
|
196
|
+
"timeout",
|
|
197
|
+
"timed out",
|
|
198
|
+
)
|
|
199
|
+
return any(tok in text for tok in tokens)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _probe_session_new(
|
|
203
|
+
client: AcpJsonRpcClient,
|
|
204
|
+
cwd: str,
|
|
205
|
+
) -> tuple[dict[str, Any] | None, bool]:
|
|
206
|
+
"""Return (session_result, auth_required)."""
|
|
207
|
+
try:
|
|
208
|
+
session = await client.request(
|
|
209
|
+
"session/new",
|
|
210
|
+
{"cwd": cwd, "mcpServers": []},
|
|
211
|
+
timeout=_DEEP_PROBE_TIMEOUT_S,
|
|
212
|
+
)
|
|
213
|
+
return session if isinstance(session, dict) else {}, False
|
|
214
|
+
except AcpJsonRpcError as exc:
|
|
215
|
+
message = str(exc)
|
|
216
|
+
if _looks_like_auth_error(message):
|
|
217
|
+
return None, True
|
|
218
|
+
logger.debug("ACP probe session/new failed non-auth: {}", exc)
|
|
219
|
+
return None, False
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
message = str(exc)
|
|
222
|
+
if "timeout" in message.lower() or _looks_like_auth_error(message):
|
|
223
|
+
return None, True
|
|
224
|
+
logger.debug("ACP probe session/new failed: {}", exc)
|
|
225
|
+
return None, False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def _best_effort_close_session(client: AcpJsonRpcClient, session_id: str) -> None:
|
|
229
|
+
for method in ("session/close", "session/cancel"):
|
|
230
|
+
try:
|
|
231
|
+
if method == "session/cancel":
|
|
232
|
+
await client.notify(method, {"sessionId": session_id})
|
|
233
|
+
else:
|
|
234
|
+
await client.request(method, {"sessionId": session_id}, timeout=2.0)
|
|
235
|
+
break
|
|
236
|
+
except Exception:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _try_authenticate(
|
|
241
|
+
client: AcpJsonRpcClient,
|
|
242
|
+
manifest: AgentManifest,
|
|
243
|
+
auth_methods: list[Any],
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Try headless-only auth during deep probe. Never call interactive OAuth."""
|
|
246
|
+
methods = [m for m in auth_methods if isinstance(m, dict)]
|
|
247
|
+
if not methods:
|
|
248
|
+
return
|
|
249
|
+
method_ids = [str(m.get("id") or m.get("methodId") or "") for m in methods]
|
|
250
|
+
method_ids = [mid for mid in method_ids if mid]
|
|
251
|
+
ordered = order_headless_auth_method_ids(
|
|
252
|
+
method_ids,
|
|
253
|
+
preferred=list(manifest.preferred_auth_method_ids),
|
|
254
|
+
)
|
|
255
|
+
if not ordered:
|
|
256
|
+
logger.debug(
|
|
257
|
+
"ACP discovery skip interactive auth runtime={} methods={}",
|
|
258
|
+
manifest.id,
|
|
259
|
+
method_ids,
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
for mid in ordered:
|
|
263
|
+
try:
|
|
264
|
+
await client.request(
|
|
265
|
+
"authenticate",
|
|
266
|
+
{"methodId": mid, "_meta": {"headless": True}},
|
|
267
|
+
timeout=_AUTH_METHOD_TIMEOUT_S,
|
|
268
|
+
)
|
|
269
|
+
logger.info("ACP discovery authenticated runtime={} method={}", manifest.id, mid)
|
|
270
|
+
return
|
|
271
|
+
except Exception:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _candidate_paths(
|
|
276
|
+
manifest: AgentManifest,
|
|
277
|
+
*,
|
|
278
|
+
extra_candidate: str | None = None,
|
|
279
|
+
) -> list[dict[str, str]]:
|
|
280
|
+
out: list[dict[str, str]] = []
|
|
281
|
+
if extra_candidate:
|
|
282
|
+
out.append({"source": "custom", "path": os.path.expandvars(os.path.expanduser(extra_candidate))})
|
|
283
|
+
for env_name in manifest.env_paths:
|
|
284
|
+
value = os.environ.get(env_name)
|
|
285
|
+
if value:
|
|
286
|
+
out.append({"source": "env", "path": os.path.expandvars(os.path.expanduser(value))})
|
|
287
|
+
for name in manifest.which:
|
|
288
|
+
found = shutil.which(name)
|
|
289
|
+
if found:
|
|
290
|
+
out.append({"source": "cli", "path": found})
|
|
291
|
+
if manifest.command:
|
|
292
|
+
found = shutil.which(manifest.command[0])
|
|
293
|
+
if found:
|
|
294
|
+
out.append({"source": "cli", "path": found})
|
|
295
|
+
if sys.platform == "win32":
|
|
296
|
+
home = Path.home()
|
|
297
|
+
appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
|
|
298
|
+
local = os.environ.get("LOCALAPPDATA", str(home / "AppData" / "Local"))
|
|
299
|
+
names = list(manifest.which) or ([manifest.command[0]] if manifest.command else [])
|
|
300
|
+
for name in names:
|
|
301
|
+
for path in (
|
|
302
|
+
home / ".local" / "bin" / f"{name}.exe",
|
|
303
|
+
home / ".local" / "bin" / f"{name}.cmd",
|
|
304
|
+
Path(appdata) / "npm" / name,
|
|
305
|
+
Path(appdata) / "npm" / f"{name}.cmd",
|
|
306
|
+
Path(appdata) / "npm" / f"{name}.ps1",
|
|
307
|
+
Path(local) / "Programs" / name / f"{name}.exe",
|
|
308
|
+
Path(local) / "cursor-agent" / "agent.cmd",
|
|
309
|
+
Path(local) / "cursor-agent" / "agent.exe",
|
|
310
|
+
):
|
|
311
|
+
out.append({"source": "common", "path": str(path)})
|
|
312
|
+
found = shutil.which(name) or shutil.which(f"{name}.cmd")
|
|
313
|
+
if found:
|
|
314
|
+
out.append({"source": "cli", "path": found})
|
|
315
|
+
return _dedupe(out)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def _check_candidate(manifest: AgentManifest, candidate: dict[str, str]) -> dict[str, Any]:
|
|
319
|
+
path = candidate["path"]
|
|
320
|
+
source = candidate["source"]
|
|
321
|
+
base: dict[str, Any] = {"source": source, "path": path}
|
|
322
|
+
if not path:
|
|
323
|
+
return {**base, "status": "missing", "reason": "empty path"}
|
|
324
|
+
path_obj = Path(path)
|
|
325
|
+
if path_obj.is_absolute() and not path_obj.exists() and not shutil.which(path):
|
|
326
|
+
return {**base, "status": "missing", "reason": "file not found"}
|
|
327
|
+
if path_obj.is_absolute() and path_obj.is_file() and not path_exists_for_launch(path):
|
|
328
|
+
return {**base, "status": "failed", "reason": "not executable"}
|
|
329
|
+
|
|
330
|
+
target = launch_target(source, path)
|
|
331
|
+
version_cmd = target.command(list(manifest.version_args))
|
|
332
|
+
version = await _run_command(version_cmd)
|
|
333
|
+
if version["status"] != "ok":
|
|
334
|
+
if path_obj.is_file() or shutil.which(path):
|
|
335
|
+
return {
|
|
336
|
+
**base,
|
|
337
|
+
"status": "ok",
|
|
338
|
+
"version": None,
|
|
339
|
+
"versionUnverified": True,
|
|
340
|
+
"versionNote": version.get("reason") or version.get("stderr"),
|
|
341
|
+
}
|
|
342
|
+
return {**base, "status": "failed", "stage": "version", **version}
|
|
343
|
+
return {**base, "status": "ok", "version": version.get("stdout"), "versionUnverified": False}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
async def _run_command(command: list[str]) -> dict[str, Any]:
|
|
347
|
+
try:
|
|
348
|
+
process = await asyncio.create_subprocess_exec(
|
|
349
|
+
*command,
|
|
350
|
+
stdout=asyncio.subprocess.PIPE,
|
|
351
|
+
stderr=asyncio.subprocess.PIPE,
|
|
352
|
+
)
|
|
353
|
+
try:
|
|
354
|
+
stdout_b, stderr_b = await asyncio.wait_for(process.communicate(), timeout=_COMMAND_CHECK_TIMEOUT_S)
|
|
355
|
+
except TimeoutError:
|
|
356
|
+
process.kill()
|
|
357
|
+
await process.wait()
|
|
358
|
+
return {"status": "failed", "reason": "timeout"}
|
|
359
|
+
stdout = stdout_b.decode(errors="replace").strip()
|
|
360
|
+
stderr = stderr_b.decode(errors="replace").strip()
|
|
361
|
+
if process.returncode != 0:
|
|
362
|
+
return {
|
|
363
|
+
"status": "failed",
|
|
364
|
+
"reason": f"exit {process.returncode}",
|
|
365
|
+
"stdout": stdout[:500],
|
|
366
|
+
"stderr": stderr[:500],
|
|
367
|
+
}
|
|
368
|
+
return {"status": "ok", "stdout": (stdout or stderr)[:200]}
|
|
369
|
+
except FileNotFoundError:
|
|
370
|
+
return {"status": "missing", "reason": "file not found"}
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
logger.exception("ACP version check failed command={}", command)
|
|
373
|
+
return {"status": "failed", "reason": str(exc)}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _dedupe(candidates: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
377
|
+
seen: set[str] = set()
|
|
378
|
+
out: list[dict[str, str]] = []
|
|
379
|
+
for item in candidates:
|
|
380
|
+
path = item.get("path") or ""
|
|
381
|
+
if not path or path in seen:
|
|
382
|
+
continue
|
|
383
|
+
seen.add(path)
|
|
384
|
+
out.append(item)
|
|
385
|
+
return out
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ProcessModel = Literal["shared", "per_session"]
|
|
11
|
+
CapabilityLevel = Literal["required", "optional", "unsupported"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class AgentManifest:
|
|
16
|
+
"""Declarative description of one ACP-compatible agent binary."""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
display_name: str
|
|
20
|
+
transport: Literal["acp"] = "acp"
|
|
21
|
+
command: tuple[str, ...] = ()
|
|
22
|
+
args: tuple[str, ...] = ()
|
|
23
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
24
|
+
env_paths: tuple[str, ...] = ()
|
|
25
|
+
which: tuple[str, ...] = ()
|
|
26
|
+
version_args: tuple[str, ...] = ("--version",)
|
|
27
|
+
process_model: ProcessModel = "shared"
|
|
28
|
+
preferred_auth_method_ids: tuple[str, ...] = ()
|
|
29
|
+
pre_auth_hint: str | None = None
|
|
30
|
+
capabilities_expected: dict[str, CapabilityLevel] = field(default_factory=dict)
|
|
31
|
+
client_fs_read: bool = True
|
|
32
|
+
client_fs_write: bool = False
|
|
33
|
+
client_terminal: bool = False
|
|
34
|
+
quirks: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def launch_args(self) -> list[str]:
|
|
37
|
+
return list(self.args)
|
|
38
|
+
|
|
39
|
+
def client_capabilities(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"fs": {
|
|
42
|
+
"readTextFile": self.client_fs_read,
|
|
43
|
+
"writeTextFile": self.client_fs_write,
|
|
44
|
+
},
|
|
45
|
+
"terminal": self.client_terminal,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def manifest_from_dict(raw: dict[str, Any]) -> AgentManifest:
|
|
50
|
+
discovery = raw.get("discovery") if isinstance(raw.get("discovery"), dict) else {}
|
|
51
|
+
auth = raw.get("auth") if isinstance(raw.get("auth"), dict) else {}
|
|
52
|
+
client_caps = raw.get("clientCapabilities") if isinstance(raw.get("clientCapabilities"), dict) else {}
|
|
53
|
+
fs_caps = client_caps.get("fs") if isinstance(client_caps.get("fs"), dict) else {}
|
|
54
|
+
caps_expected = raw.get("capabilitiesExpected") if isinstance(raw.get("capabilitiesExpected"), dict) else {}
|
|
55
|
+
command = raw.get("command") or []
|
|
56
|
+
args = raw.get("args") or []
|
|
57
|
+
if not isinstance(command, list) or not command:
|
|
58
|
+
raise ValueError(f"manifest {raw.get('id')!r} needs non-empty command")
|
|
59
|
+
if not isinstance(raw.get("id"), str) or not raw["id"]:
|
|
60
|
+
raise ValueError("manifest needs id")
|
|
61
|
+
return AgentManifest(
|
|
62
|
+
id=raw["id"],
|
|
63
|
+
display_name=str(raw.get("displayName") or raw["id"]),
|
|
64
|
+
transport="acp",
|
|
65
|
+
command=tuple(str(part) for part in command),
|
|
66
|
+
args=tuple(str(part) for part in args) if isinstance(args, list) else (),
|
|
67
|
+
env={str(k): str(v) for k, v in (raw.get("env") or {}).items()}
|
|
68
|
+
if isinstance(raw.get("env"), dict)
|
|
69
|
+
else {},
|
|
70
|
+
env_paths=tuple(str(x) for x in (discovery.get("envPaths") or []) if x),
|
|
71
|
+
which=tuple(str(x) for x in (discovery.get("which") or []) if x),
|
|
72
|
+
version_args=tuple(str(x) for x in (discovery.get("versionArgs") or ["--version"])),
|
|
73
|
+
process_model="per_session" if discovery.get("processModel") == "per_session" or raw.get("processModel") == "per_session" else "shared",
|
|
74
|
+
preferred_auth_method_ids=tuple(str(x) for x in (auth.get("preferredMethodIds") or []) if x),
|
|
75
|
+
pre_auth_hint=str(auth["preAuthHint"]) if auth.get("preAuthHint") else None,
|
|
76
|
+
capabilities_expected={
|
|
77
|
+
str(k): _capability_level(v) for k, v in caps_expected.items()
|
|
78
|
+
},
|
|
79
|
+
client_fs_read=bool(fs_caps.get("readTextFile", True)),
|
|
80
|
+
client_fs_write=bool(fs_caps.get("writeTextFile", False)),
|
|
81
|
+
client_terminal=bool(client_caps.get("terminal", False)),
|
|
82
|
+
quirks=dict(raw.get("quirks") or {}) if isinstance(raw.get("quirks"), dict) else {},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_manifest_file(path: str | Path) -> AgentManifest:
|
|
87
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
raise ValueError(f"manifest file must be a JSON object: {path}")
|
|
90
|
+
return manifest_from_dict(data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_builtin_manifests() -> list[AgentManifest]:
|
|
94
|
+
manifests: list[AgentManifest] = []
|
|
95
|
+
package = resources.files("connector.acp.manifests")
|
|
96
|
+
for entry in sorted(package.iterdir()):
|
|
97
|
+
if not entry.name.endswith(".json"):
|
|
98
|
+
continue
|
|
99
|
+
with entry.open("r", encoding="utf-8") as handle:
|
|
100
|
+
data = json.load(handle)
|
|
101
|
+
if isinstance(data, dict):
|
|
102
|
+
manifests.append(manifest_from_dict(data))
|
|
103
|
+
return manifests
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _capability_level(value: Any) -> CapabilityLevel:
|
|
107
|
+
text = str(value or "optional")
|
|
108
|
+
if text in {"required", "optional", "unsupported"}:
|
|
109
|
+
return text # type: ignore[return-value]
|
|
110
|
+
return "optional"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in ACP agent manifests (JSON)."""
|