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.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- 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
|