pairling 0.0.1 → 0.1.0

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 (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1 @@
1
+ """Provider runtime adapters for Pairling companiond."""
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import time
8
+ from dataclasses import asdict, dataclass, replace
9
+ from pathlib import Path
10
+ from typing import Iterable, Protocol
11
+
12
+
13
+ ProviderCapability = str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ProviderDescriptor:
18
+ provider_id: str
19
+ display_name: str
20
+ kind: str
21
+ builtin: bool = True
22
+ docs_url: str | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ProviderAvailability:
27
+ provider_id: str
28
+ display_name: str
29
+ kind: str
30
+ installed: bool
31
+ usable: bool
32
+ launchable: bool
33
+ auth_state: str
34
+ config_state: str
35
+ readable_sessions: int
36
+ live_sessions: int
37
+ controllable_sessions: int
38
+ capabilities: tuple[ProviderCapability, ...]
39
+ setup_actions: tuple[str, ...] = ()
40
+ notes: tuple[str, ...] = ()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ProviderDiagnostics:
45
+ cli_path: str | None = None
46
+ cli_path_source: str | None = None
47
+ version: str | None = None
48
+ config_path: str | None = None
49
+ config_exists: bool | None = None
50
+ hook_count: int | None = None
51
+ hooks_configured: bool | None = None
52
+ mcp_count: int | None = None
53
+ plugin_count: int | None = None
54
+ registry_count: int | None = None
55
+ registry_live_count: int | None = None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ProviderProbeResult:
60
+ descriptor: ProviderDescriptor
61
+ availability: ProviderAvailability
62
+ diagnostics: ProviderDiagnostics
63
+ observed_at: float
64
+
65
+ def with_availability(self, **changes) -> "ProviderProbeResult":
66
+ return replace(self, availability=replace(self.availability, **changes))
67
+
68
+ def with_diagnostics(self, **changes) -> "ProviderProbeResult":
69
+ return replace(self, diagnostics=replace(self.diagnostics, **changes))
70
+
71
+
72
+ class ProviderAdapter(Protocol):
73
+ descriptor: ProviderDescriptor
74
+
75
+ def probe(self) -> ProviderProbeResult:
76
+ ...
77
+
78
+ def supports(self, capability: ProviderCapability) -> bool:
79
+ ...
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ResolvedExecutable:
84
+ path: Path
85
+ source: str
86
+
87
+
88
+ def normalize_provider_id(raw: str) -> str:
89
+ return (raw or "").strip().lower()
90
+
91
+
92
+ def is_valid_provider_id(raw: str) -> bool:
93
+ provider_id = normalize_provider_id(raw)
94
+ return bool(provider_id) and len(provider_id) <= 48 and re.fullmatch(r"[a-z0-9_]+", provider_id) is not None
95
+
96
+
97
+ def executable_candidates(name: str, known: Iterable[Path | str], env_var: str | None = None) -> list[tuple[Path, str]]:
98
+ candidates: list[tuple[Path, str]] = []
99
+ if env_var:
100
+ configured = os.environ.get(env_var)
101
+ if configured:
102
+ candidates.append((Path(configured).expanduser(), f"env:{env_var}"))
103
+ for candidate in known:
104
+ candidates.append((Path(candidate).expanduser(), "known"))
105
+ for prefix in os.environ.get("PATH", "").split(":"):
106
+ if prefix:
107
+ candidates.append((Path(prefix).expanduser() / name, "path"))
108
+ return candidates
109
+
110
+
111
+ def resolve_executable(name: str, known: Iterable[Path | str], env_var: str | None = None) -> ResolvedExecutable | None:
112
+ seen: set[str] = set()
113
+ for candidate, source in executable_candidates(name, known, env_var=env_var):
114
+ key = str(candidate)
115
+ if key in seen:
116
+ continue
117
+ seen.add(key)
118
+ if candidate.exists() and os.access(candidate, os.X_OK):
119
+ return ResolvedExecutable(candidate, source)
120
+ return None
121
+
122
+
123
+ def cli_version(bin_path: Path | str | None, args: list[str] | None = None, timeout: int = 3) -> str | None:
124
+ if not bin_path:
125
+ return None
126
+ try:
127
+ proc = subprocess.run([str(bin_path), *(args or ["--version"])], capture_output=True, text=True, timeout=timeout)
128
+ except Exception:
129
+ return None
130
+ if proc.returncode != 0:
131
+ return None
132
+ return (proc.stdout or proc.stderr or "").strip()[:160] or None
133
+
134
+
135
+ def count_dirs(root: Path, excluded: set[str] | None = None) -> int:
136
+ excluded = excluded or set()
137
+ if not root.is_dir():
138
+ return 0
139
+ try:
140
+ return sum(1 for p in root.iterdir() if p.is_dir() and not p.name.startswith(".") and p.name not in excluded)
141
+ except OSError:
142
+ return 0
143
+
144
+
145
+ def command_line_count(bin_path: Path | str | None, args: list[str], timeout: int = 5) -> int | None:
146
+ if not bin_path:
147
+ return None
148
+ try:
149
+ proc = subprocess.run([str(bin_path), *args], capture_output=True, text=True, timeout=timeout)
150
+ except Exception:
151
+ return None
152
+ text = (proc.stdout or proc.stderr or "").strip()
153
+ if not text:
154
+ return 0 if proc.returncode == 0 else None
155
+ lines: list[str] = []
156
+ for line in text.splitlines():
157
+ s = line.strip()
158
+ low = s.lower()
159
+ if not s:
160
+ continue
161
+ if low.startswith("no ") or "no mcp" in low:
162
+ continue
163
+ if low.startswith("name ") or set(s) <= {"-", " "}:
164
+ continue
165
+ lines.append(s)
166
+ return len(lines)
167
+
168
+
169
+ def hook_command_count(obj) -> int:
170
+ if isinstance(obj, dict):
171
+ own = 1 if isinstance(obj.get("command"), str) and obj.get("command") else 0
172
+ return own + sum(hook_command_count(v) for k, v in obj.items() if k != "command")
173
+ if isinstance(obj, list):
174
+ return sum(hook_command_count(v) for v in obj)
175
+ return 0
176
+
177
+
178
+ def json_hook_count(path: Path) -> int:
179
+ if not path.is_file():
180
+ return 0
181
+ try:
182
+ obj = json.loads(path.read_text(errors="replace"))
183
+ except Exception:
184
+ return 0
185
+ return hook_command_count(obj.get("hooks") if isinstance(obj, dict) and "hooks" in obj else obj)
186
+
187
+
188
+ def availability_dict(availability: ProviderAvailability) -> dict:
189
+ data = asdict(availability)
190
+ data["capabilities"] = list(availability.capabilities)
191
+ data["setup_actions"] = list(availability.setup_actions)
192
+ data["notes"] = list(availability.notes)
193
+ return data
194
+
195
+
196
+ def diagnostics_dict(diagnostics: ProviderDiagnostics) -> dict:
197
+ return asdict(diagnostics)
198
+
199
+
200
+ def provider_detail_payload(result: ProviderProbeResult) -> dict:
201
+ payload = availability_dict(result.availability)
202
+ payload.update(diagnostics_dict(result.diagnostics))
203
+ payload["provider"] = result.availability.provider_id
204
+ payload["ok"] = result.availability.usable
205
+ payload["session_count"] = result.availability.readable_sessions
206
+ payload["controllable_count"] = result.availability.controllable_sessions
207
+ return payload
208
+
209
+
210
+ def provider_snapshot_payload(results: list[ProviderProbeResult], source: str = "live_probe", observed_at: float | None = None) -> dict:
211
+ usable = [r.availability for r in results if r.availability.usable]
212
+ default_provider_id: str | None = None
213
+ default_filter = "all"
214
+ if len(usable) == 1:
215
+ default_provider_id = usable[0].provider_id
216
+ default_filter = usable[0].provider_id
217
+ elif len(usable) > 1:
218
+ launchable = [p for p in usable if p.launchable]
219
+ default_provider_id = launchable[0].provider_id if launchable else usable[0].provider_id
220
+
221
+ ts = observed_at if observed_at is not None else time.time()
222
+ return {
223
+ "schema_version": 1,
224
+ "providers": [availability_dict(r.availability) for r in results],
225
+ "default_provider_id": default_provider_id,
226
+ "default_filter": default_filter,
227
+ "observed_at": ts,
228
+ "source": source,
229
+ }
230
+
231
+
232
+ def failed_probe(descriptor: ProviderDescriptor, exc: Exception) -> ProviderProbeResult:
233
+ note = f"{type(exc).__name__}: {str(exc)[:160]}"
234
+ availability = ProviderAvailability(
235
+ provider_id=descriptor.provider_id,
236
+ display_name=descriptor.display_name,
237
+ kind=descriptor.kind,
238
+ installed=False,
239
+ usable=False,
240
+ launchable=False,
241
+ auth_state="unknown",
242
+ config_state="unknown",
243
+ readable_sessions=0,
244
+ live_sessions=0,
245
+ controllable_sessions=0,
246
+ capabilities=("detect",),
247
+ setup_actions=("repair_provider_probe",),
248
+ notes=(note,),
249
+ )
250
+ return ProviderProbeResult(
251
+ descriptor=descriptor,
252
+ availability=availability,
253
+ diagnostics=ProviderDiagnostics(),
254
+ observed_at=time.time(),
255
+ )
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from .base import (
7
+ ProviderAdapter,
8
+ ProviderAvailability,
9
+ ProviderDescriptor,
10
+ ProviderDiagnostics,
11
+ ProviderProbeResult,
12
+ cli_version,
13
+ command_line_count,
14
+ count_dirs,
15
+ json_hook_count,
16
+ resolve_executable,
17
+ )
18
+
19
+
20
+ class ClaudeProviderAdapter(ProviderAdapter):
21
+ descriptor = ProviderDescriptor(
22
+ provider_id="claude",
23
+ display_name="Claude",
24
+ kind="terminal_cli",
25
+ builtin=True,
26
+ docs_url="https://docs.anthropic.com/en/docs/claude-code",
27
+ )
28
+
29
+ def __init__(self, home: Path | None = None):
30
+ self.home = home or Path.home()
31
+
32
+ @property
33
+ def candidates(self) -> list[Path]:
34
+ return [
35
+ self.home / ".local" / "bin" / "claude",
36
+ Path("/opt/homebrew/bin/claude"),
37
+ Path("/usr/local/bin/claude"),
38
+ ]
39
+
40
+ def supports(self, capability: str) -> bool:
41
+ return capability in {
42
+ "detect",
43
+ "status",
44
+ "list_sessions",
45
+ "read_transcript",
46
+ "spawn",
47
+ "live_state",
48
+ "send_text",
49
+ "interrupt",
50
+ "terminate",
51
+ "commands",
52
+ "search",
53
+ "mcp",
54
+ "export",
55
+ "resume",
56
+ "orchestration_launch",
57
+ "worker_telemetry",
58
+ "worker_control",
59
+ "semantic_index",
60
+ }
61
+
62
+ def probe(self) -> ProviderProbeResult:
63
+ resolved = resolve_executable("claude", self.candidates, env_var="PAIRLING_CLAUDE_BIN")
64
+ config_path = self.home / ".claude" / "settings.json"
65
+ hook_count = json_hook_count(config_path)
66
+ installed = resolved is not None
67
+ notes: list[str] = []
68
+ setup_actions: list[str] = []
69
+ if not installed:
70
+ notes.append("Claude CLI not found in configured, known, or daemon PATH locations")
71
+ setup_actions.append("install_cli")
72
+ if not config_path.is_file():
73
+ notes.append("Claude settings.json not found")
74
+ setup_actions.append("configure_provider")
75
+ capabilities = (
76
+ "detect",
77
+ "status",
78
+ "list_sessions",
79
+ "read_transcript",
80
+ "spawn",
81
+ "live_state",
82
+ "send_text",
83
+ "interrupt",
84
+ "terminate",
85
+ "commands",
86
+ "search",
87
+ "mcp",
88
+ "export",
89
+ "resume",
90
+ "orchestration_launch",
91
+ "worker_telemetry",
92
+ "worker_control",
93
+ "semantic_index",
94
+ ) if installed else ("detect", "status")
95
+ availability = ProviderAvailability(
96
+ provider_id=self.descriptor.provider_id,
97
+ display_name=self.descriptor.display_name,
98
+ kind=self.descriptor.kind,
99
+ installed=installed,
100
+ usable=installed,
101
+ launchable=installed,
102
+ auth_state="ready" if installed else "missing_cli",
103
+ config_state="ready" if config_path.is_file() else "missing",
104
+ readable_sessions=0,
105
+ live_sessions=0,
106
+ controllable_sessions=0,
107
+ capabilities=capabilities,
108
+ setup_actions=tuple(dict.fromkeys(setup_actions)),
109
+ notes=tuple(notes),
110
+ )
111
+ diagnostics = ProviderDiagnostics(
112
+ cli_path=str(resolved.path) if resolved else None,
113
+ cli_path_source=resolved.source if resolved else None,
114
+ version=cli_version(resolved.path) if resolved else None,
115
+ config_path=str(config_path),
116
+ config_exists=config_path.is_file(),
117
+ hook_count=hook_count,
118
+ hooks_configured=hook_count > 0,
119
+ mcp_count=command_line_count(resolved.path, ["mcp", "list"], timeout=0.75) if resolved else None,
120
+ plugin_count=count_dirs(self.home / ".claude" / "plugins", excluded={"cache"}),
121
+ )
122
+ return ProviderProbeResult(
123
+ descriptor=self.descriptor,
124
+ availability=availability,
125
+ diagnostics=diagnostics,
126
+ observed_at=time.time(),
127
+ )
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from .base import (
7
+ ProviderAdapter,
8
+ ProviderAvailability,
9
+ ProviderDescriptor,
10
+ ProviderDiagnostics,
11
+ ProviderProbeResult,
12
+ cli_version,
13
+ command_line_count,
14
+ count_dirs,
15
+ json_hook_count,
16
+ resolve_executable,
17
+ )
18
+
19
+
20
+ class CodexProviderAdapter(ProviderAdapter):
21
+ descriptor = ProviderDescriptor(
22
+ provider_id="codex",
23
+ display_name="Codex",
24
+ kind="terminal_cli",
25
+ builtin=True,
26
+ docs_url="https://developers.openai.com/codex",
27
+ )
28
+
29
+ def __init__(self, home: Path | None = None):
30
+ self.home = home or Path.home()
31
+
32
+ @property
33
+ def candidates(self) -> list[Path]:
34
+ return [
35
+ self.home / ".local" / "bin" / "codex",
36
+ Path("/opt/homebrew/bin/codex"),
37
+ Path("/usr/local/bin/codex"),
38
+ ]
39
+
40
+ def supports(self, capability: str) -> bool:
41
+ return capability in {
42
+ "detect",
43
+ "status",
44
+ "list_sessions",
45
+ "read_transcript",
46
+ "spawn",
47
+ "live_state",
48
+ "send_text",
49
+ "interrupt",
50
+ "terminate",
51
+ "commands",
52
+ "search",
53
+ "terminal_output",
54
+ "mcp",
55
+ "export",
56
+ "orchestration_launch",
57
+ "worker_telemetry",
58
+ }
59
+
60
+ def probe(self) -> ProviderProbeResult:
61
+ resolved = resolve_executable("codex", self.candidates, env_var="PAIRLING_CODEX_BIN")
62
+ config_path = self.home / ".codex" / "config.toml"
63
+ hooks_path = self.home / ".codex" / "hooks.json"
64
+ hook_count = json_hook_count(hooks_path)
65
+ installed = resolved is not None
66
+ notes: list[str] = []
67
+ setup_actions: list[str] = []
68
+ if not installed:
69
+ notes.append("Codex CLI not found in configured, known, or daemon PATH locations")
70
+ setup_actions.append("install_cli")
71
+ if not config_path.is_file():
72
+ notes.append("Codex config.toml not found")
73
+ setup_actions.append("configure_provider")
74
+ capabilities = (
75
+ "detect",
76
+ "status",
77
+ "list_sessions",
78
+ "read_transcript",
79
+ "spawn",
80
+ "live_state",
81
+ "send_text",
82
+ "interrupt",
83
+ "terminate",
84
+ "commands",
85
+ "search",
86
+ "terminal_output",
87
+ "mcp",
88
+ "export",
89
+ "orchestration_launch",
90
+ "worker_telemetry",
91
+ ) if installed else ("detect", "status")
92
+ availability = ProviderAvailability(
93
+ provider_id=self.descriptor.provider_id,
94
+ display_name=self.descriptor.display_name,
95
+ kind=self.descriptor.kind,
96
+ installed=installed,
97
+ usable=installed,
98
+ launchable=installed,
99
+ auth_state="ready" if installed else "missing_cli",
100
+ config_state="ready" if config_path.is_file() else "missing",
101
+ readable_sessions=0,
102
+ live_sessions=0,
103
+ controllable_sessions=0,
104
+ capabilities=capabilities,
105
+ setup_actions=tuple(dict.fromkeys(setup_actions)),
106
+ notes=tuple(notes),
107
+ )
108
+ diagnostics = ProviderDiagnostics(
109
+ cli_path=str(resolved.path) if resolved else None,
110
+ cli_path_source=resolved.source if resolved else None,
111
+ version=cli_version(resolved.path) if resolved else None,
112
+ config_path=str(config_path),
113
+ config_exists=config_path.is_file(),
114
+ hook_count=hook_count,
115
+ hooks_configured=hook_count > 0,
116
+ mcp_count=command_line_count(resolved.path, ["mcp", "list"], timeout=0.75) if resolved else None,
117
+ plugin_count=count_dirs(self.home / ".codex" / "plugins"),
118
+ )
119
+ return ProviderProbeResult(
120
+ descriptor=self.descriptor,
121
+ availability=availability,
122
+ diagnostics=diagnostics,
123
+ observed_at=time.time(),
124
+ )
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from .base import ProviderAdapter, ProviderAvailability, ProviderDescriptor, ProviderDiagnostics, ProviderProbeResult
6
+
7
+
8
+ EXTERNAL_DESCRIPTORS = [
9
+ ProviderDescriptor("aider", "Aider", "terminal_cli", builtin=False, docs_url="https://aider.chat/docs/"),
10
+ ProviderDescriptor("opencode", "OpenCode", "terminal_cli", builtin=False, docs_url="https://opencode.ai/docs"),
11
+ ProviderDescriptor("hermes_agent", "Hermes Agent", "terminal_cli", builtin=False, docs_url="https://hermes-agent.nousresearch.com/docs/user-guide/cli"),
12
+ ProviderDescriptor("grok_build", "Grok Build", "terminal_cli", builtin=False, docs_url="https://x.ai/cli"),
13
+ ProviderDescriptor("antigravity", "Antigravity", "agent_platform", builtin=False, docs_url="https://developers.googleblog.com/en/build-with-google-antigravity-our-new-agentic-development-platform/"),
14
+ ]
15
+
16
+
17
+ class DisabledExternalProviderAdapter(ProviderAdapter):
18
+ def __init__(self, descriptor: ProviderDescriptor):
19
+ self.descriptor = descriptor
20
+
21
+ def supports(self, capability: str) -> bool:
22
+ return capability == "detect"
23
+
24
+ def probe(self) -> ProviderProbeResult:
25
+ availability = ProviderAvailability(
26
+ provider_id=self.descriptor.provider_id,
27
+ display_name=self.descriptor.display_name,
28
+ kind=self.descriptor.kind,
29
+ installed=False,
30
+ usable=False,
31
+ launchable=False,
32
+ auth_state="unsupported",
33
+ config_state="unsupported",
34
+ readable_sessions=0,
35
+ live_sessions=0,
36
+ controllable_sessions=0,
37
+ capabilities=("detect",),
38
+ setup_actions=("provider_sprint_required",),
39
+ notes=("Provider descriptor is present for future integration; adapter is disabled.",),
40
+ )
41
+ return ProviderProbeResult(
42
+ descriptor=self.descriptor,
43
+ availability=availability,
44
+ diagnostics=ProviderDiagnostics(),
45
+ observed_at=time.time(),
46
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .base import ProviderAdapter, ProviderDescriptor, failed_probe, is_valid_provider_id, normalize_provider_id
7
+ from .claude import ClaudeProviderAdapter
8
+ from .codex import CodexProviderAdapter
9
+ from .external import DisabledExternalProviderAdapter, EXTERNAL_DESCRIPTORS
10
+
11
+
12
+ def _external_enabled_ids() -> set[str]:
13
+ raw = os.environ.get("PAIRLING_EXPERIMENTAL_PROVIDERS", "")
14
+ if raw.strip().lower() in {"1", "true", "yes", "all", "*"}:
15
+ return {d.provider_id for d in EXTERNAL_DESCRIPTORS}
16
+ return {
17
+ normalize_provider_id(item)
18
+ for item in raw.split(",")
19
+ if is_valid_provider_id(item)
20
+ }
21
+
22
+
23
+ def provider_adapters(home: Path | None = None, include_external: bool | None = None) -> list[ProviderAdapter]:
24
+ adapters: list[ProviderAdapter] = [
25
+ ClaudeProviderAdapter(home=home),
26
+ CodexProviderAdapter(home=home),
27
+ ]
28
+ enabled = _external_enabled_ids() if include_external is None else ({d.provider_id for d in EXTERNAL_DESCRIPTORS} if include_external else set())
29
+ for descriptor in EXTERNAL_DESCRIPTORS:
30
+ if descriptor.provider_id in enabled:
31
+ adapters.append(DisabledExternalProviderAdapter(descriptor))
32
+ return adapters
33
+
34
+
35
+ def provider_ids(include_external: bool | None = None) -> set[str]:
36
+ return {adapter.descriptor.provider_id for adapter in provider_adapters(include_external=include_external)}
37
+
38
+
39
+ def provider_descriptors(include_external: bool | None = None) -> list[ProviderDescriptor]:
40
+ return [adapter.descriptor for adapter in provider_adapters(include_external=include_external)]
41
+
42
+
43
+ def known_provider_ids() -> set[str]:
44
+ return {"claude", "codex", *(d.provider_id for d in EXTERNAL_DESCRIPTORS)}
45
+
46
+
47
+ def get_provider(provider_id: str, home: Path | None = None, include_external: bool | None = None) -> ProviderAdapter | None:
48
+ wanted = normalize_provider_id(provider_id)
49
+ for adapter in provider_adapters(home=home, include_external=include_external):
50
+ if adapter.descriptor.provider_id == wanted:
51
+ return adapter
52
+ return None
53
+
54
+
55
+ def iter_providers(provider_filter: str = "all", home: Path | None = None, include_external: bool | None = None) -> list[ProviderAdapter]:
56
+ provider_filter = normalize_provider_id(provider_filter or "all")
57
+ adapters = provider_adapters(home=home, include_external=include_external)
58
+ if provider_filter == "all":
59
+ return adapters
60
+ return [adapter for adapter in adapters if adapter.descriptor.provider_id == provider_filter]
61
+
62
+
63
+ def probe_all(provider_filter: str = "all", home: Path | None = None, include_external: bool | None = None):
64
+ results = []
65
+ for adapter in iter_providers(provider_filter=provider_filter, home=home, include_external=include_external):
66
+ try:
67
+ results.append(adapter.probe())
68
+ except Exception as exc:
69
+ results.append(failed_probe(adapter.descriptor, exc))
70
+ return results