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,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)."""