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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairling",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Pair your iPhone with the AI coding agents running on your Mac. CLI and local runtime installer for the Pairling iOS app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pairling",
|
|
@@ -38,5 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"optionalDependencies": {
|
|
43
|
+
"@pairling/runtime-darwin-arm64": "0.1.0",
|
|
44
|
+
"@pairling/runtime-darwin-x64": "0.1.0"
|
|
41
45
|
}
|
|
42
46
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
HEAD
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
false
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
35420e4
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2026.05.07.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pairling runtime integrations."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Read-only Aperture CLI integration for Pairling runtime status."""
|
|
2
|
+
|
|
3
|
+
from .status import (
|
|
4
|
+
APERTURE_CLI_KNOWN_VERSION,
|
|
5
|
+
APERTURE_CLI_PROVIDER_PATH,
|
|
6
|
+
APERTURE_CLI_STATUS_PATH,
|
|
7
|
+
ApertureCLIProbe,
|
|
8
|
+
provider_payload,
|
|
9
|
+
status_payload,
|
|
10
|
+
)
|
|
11
|
+
from .launch import command_for_context, contexts_payload, validate_launch_context
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"APERTURE_CLI_KNOWN_VERSION",
|
|
15
|
+
"APERTURE_CLI_PROVIDER_PATH",
|
|
16
|
+
"APERTURE_CLI_STATUS_PATH",
|
|
17
|
+
"ApertureCLIProbe",
|
|
18
|
+
"command_for_context",
|
|
19
|
+
"contexts_payload",
|
|
20
|
+
"provider_payload",
|
|
21
|
+
"status_payload",
|
|
22
|
+
"validate_launch_context",
|
|
23
|
+
]
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .status import APERTURE_CLI_KNOWN_VERSION, ApertureCLIProbe
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CLAUDE_MANAGED_ENV_VARS = [
|
|
14
|
+
"ANTHROPIC_BASE_URL",
|
|
15
|
+
"ANTHROPIC_MODEL",
|
|
16
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
17
|
+
"ANTHROPIC_BEDROCK_BASE_URL",
|
|
18
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
19
|
+
"CLAUDE_CODE_SKIP_BEDROCK_AUTH",
|
|
20
|
+
"CLOUD_ML_REGION",
|
|
21
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
22
|
+
"CLAUDE_CODE_SKIP_VERTEX_AUTH",
|
|
23
|
+
"ANTHROPIC_VERTEX_PROJECT_ID",
|
|
24
|
+
"ANTHROPIC_VERTEX_BASE_URL",
|
|
25
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
26
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
27
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
28
|
+
"API_TIMEOUT_MS",
|
|
29
|
+
"ANTHROPIC_API_KEY",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CLIENTS: dict[str, dict[str, Any]] = {
|
|
34
|
+
"claude": {
|
|
35
|
+
"id": "claude",
|
|
36
|
+
"display_name": "Claude Code",
|
|
37
|
+
"binary_name": "claude",
|
|
38
|
+
"common_paths": [".local/bin/claude"],
|
|
39
|
+
"danger_arg": "--dangerously-skip-permissions",
|
|
40
|
+
"backends": [
|
|
41
|
+
{"id": "anthropic", "display_name": "Anthropic API", "compatibility_key": "anthropic_messages", "picks_model": True},
|
|
42
|
+
{"id": "bedrock", "display_name": "AWS Bedrock", "compatibility_key": "bedrock_model_invoke", "picks_model": False},
|
|
43
|
+
{"id": "vertex", "display_name": "Google Vertex", "compatibility_key": "google_raw_predict", "picks_model": True},
|
|
44
|
+
{"id": "zai", "display_name": "z.ai", "compatibility_key": "anthropic_messages", "picks_model": True},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
"codex": {
|
|
48
|
+
"id": "codex",
|
|
49
|
+
"display_name": "OpenAI Codex",
|
|
50
|
+
"binary_name": "codex",
|
|
51
|
+
"common_paths": [".local/bin/codex"],
|
|
52
|
+
"danger_arg": "--dangerously-bypass-approvals-and-sandbox",
|
|
53
|
+
"backends": [
|
|
54
|
+
{"id": "openai", "display_name": "OpenAI Responses", "compatibility_key": "openai_responses", "picks_model": True},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _redact_home(path: Path, home: Path) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return "~/" + str(path.resolve().relative_to(home.resolve()))
|
|
63
|
+
except Exception:
|
|
64
|
+
return str(path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _find_binary(home: Path, env: dict[str, str], name: str, common_paths: list[str]) -> tuple[Path | None, str | None]:
|
|
68
|
+
candidates: list[tuple[Path, str]] = []
|
|
69
|
+
for prefix in env.get("PATH", "").split(":"):
|
|
70
|
+
if prefix:
|
|
71
|
+
candidates.append((Path(prefix) / name, "path"))
|
|
72
|
+
for rel in common_paths:
|
|
73
|
+
candidates.append((home / rel, "client_common_path"))
|
|
74
|
+
for rel_dir in [".local/bin", "bin", ".npm-global/bin"]:
|
|
75
|
+
candidates.append((home / rel_dir / name, "common_bin_dir"))
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
for path, source in candidates:
|
|
78
|
+
path = path.expanduser()
|
|
79
|
+
key = str(path)
|
|
80
|
+
if key in seen:
|
|
81
|
+
continue
|
|
82
|
+
seen.add(key)
|
|
83
|
+
if path.exists() and os.access(path, os.X_OK):
|
|
84
|
+
return path, source
|
|
85
|
+
return None, None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _compat_enabled(provider: dict[str, Any], key: str) -> bool:
|
|
89
|
+
compatibility = provider.get("compatibility")
|
|
90
|
+
return isinstance(compatibility, dict) and bool(compatibility.get(key))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _provider_display(provider: dict[str, Any]) -> str:
|
|
94
|
+
value = provider.get("name") or provider.get("id") or ""
|
|
95
|
+
return str(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fqn_models(provider: dict[str, Any]) -> list[dict[str, Any]]:
|
|
99
|
+
provider_id = str(provider.get("id") or "")
|
|
100
|
+
models = provider.get("models")
|
|
101
|
+
if not isinstance(models, list):
|
|
102
|
+
return []
|
|
103
|
+
result = []
|
|
104
|
+
for model in models:
|
|
105
|
+
if not isinstance(model, str) or not model:
|
|
106
|
+
continue
|
|
107
|
+
result.append({
|
|
108
|
+
"fqn": f"{provider_id}/{model}",
|
|
109
|
+
"provider_model": model,
|
|
110
|
+
"selection_source": "phone",
|
|
111
|
+
})
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _strip_provider_prefix(model: str) -> str:
|
|
116
|
+
return model.split("/", 1)[1] if "/" in model else model
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _tier_model_env(provider: dict[str, Any]) -> dict[str, str]:
|
|
120
|
+
models = sorted([m for m in provider.get("models") or [] if isinstance(m, str)], reverse=True)
|
|
121
|
+
env: dict[str, str] = {}
|
|
122
|
+
targets = [
|
|
123
|
+
("opus", "ANTHROPIC_DEFAULT_OPUS_MODEL"),
|
|
124
|
+
("sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL"),
|
|
125
|
+
("haiku", "ANTHROPIC_DEFAULT_HAIKU_MODEL"),
|
|
126
|
+
]
|
|
127
|
+
for model in models:
|
|
128
|
+
lower = model.lower()
|
|
129
|
+
for needle, key in targets:
|
|
130
|
+
if key not in env and needle in lower:
|
|
131
|
+
env[key] = model
|
|
132
|
+
return env
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _claude_env(endpoint_url: str, backend_id: str, provider: dict[str, Any], model: str | None) -> dict[str, str]:
|
|
136
|
+
if backend_id == "anthropic":
|
|
137
|
+
env = {"ANTHROPIC_BASE_URL": endpoint_url, "ANTHROPIC_AUTH_TOKEN": "-"}
|
|
138
|
+
elif backend_id == "bedrock":
|
|
139
|
+
env = {
|
|
140
|
+
"ANTHROPIC_BEDROCK_BASE_URL": endpoint_url.rstrip("/") + "/bedrock",
|
|
141
|
+
"CLAUDE_CODE_USE_BEDROCK": "1",
|
|
142
|
+
"CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1",
|
|
143
|
+
}
|
|
144
|
+
env.update(_tier_model_env(provider))
|
|
145
|
+
elif backend_id == "vertex":
|
|
146
|
+
env = {
|
|
147
|
+
"CLOUD_ML_REGION": "_aperture_auto_vertex_region_",
|
|
148
|
+
"CLAUDE_CODE_USE_VERTEX": "1",
|
|
149
|
+
"CLAUDE_CODE_SKIP_VERTEX_AUTH": "1",
|
|
150
|
+
"ANTHROPIC_VERTEX_PROJECT_ID": "_aperture_auto_vertex_project_id_",
|
|
151
|
+
"ANTHROPIC_VERTEX_BASE_URL": endpoint_url.rstrip("/") + "/v1",
|
|
152
|
+
}
|
|
153
|
+
elif backend_id == "zai":
|
|
154
|
+
env = {
|
|
155
|
+
"ANTHROPIC_BASE_URL": endpoint_url,
|
|
156
|
+
"ANTHROPIC_MODEL": "glm-5.1",
|
|
157
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1",
|
|
158
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.1",
|
|
159
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5-turbo",
|
|
160
|
+
"API_TIMEOUT_MS": "3000000",
|
|
161
|
+
"ANTHROPIC_API_KEY": "-",
|
|
162
|
+
}
|
|
163
|
+
else:
|
|
164
|
+
raise ValueError(f"unsupported Claude Code backend: {backend_id}")
|
|
165
|
+
if model:
|
|
166
|
+
env["ANTHROPIC_MODEL"] = _strip_provider_prefix(model)
|
|
167
|
+
return env
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _codex_home(home: Path, native_id: str) -> Path:
|
|
171
|
+
return home / "Library" / "Application Support" / "Pairling" / "aperture-cli" / "codex-home" / native_id
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _write_codex_config(home: Path, endpoint_url: str, native_id: str) -> list[Path]:
|
|
175
|
+
codex_home = _codex_home(home, native_id)
|
|
176
|
+
codex_home.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
177
|
+
os.chmod(codex_home, 0o700)
|
|
178
|
+
auth_path = codex_home / "auth.json"
|
|
179
|
+
auth_path.write_text(json.dumps({"auth_mode": "apikey", "OPENAI_API_KEY": "not-needed"}, indent=2, sort_keys=True) + "\n")
|
|
180
|
+
os.chmod(auth_path, 0o600)
|
|
181
|
+
config_path = codex_home / "config.toml"
|
|
182
|
+
config_path.write_text(
|
|
183
|
+
'model_provider = "aperture"\n\n'
|
|
184
|
+
"[model_providers.aperture]\n"
|
|
185
|
+
'name = "Aperture"\n'
|
|
186
|
+
f"base_url = {json.dumps(endpoint_url.rstrip('/') + '/v1')}\n"
|
|
187
|
+
'env_key = "OPENAI_API_KEY"\n'
|
|
188
|
+
"supports_websockets = false\n"
|
|
189
|
+
)
|
|
190
|
+
os.chmod(config_path, 0o600)
|
|
191
|
+
return [auth_path, config_path]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _codex_env(home: Path, endpoint_url: str, native_id: str, model: str | None) -> dict[str, str]:
|
|
195
|
+
env = {
|
|
196
|
+
"OPENAI_BASE_URL": endpoint_url.rstrip("/") + "/v1",
|
|
197
|
+
"OPENAI_API_KEY": "not-needed",
|
|
198
|
+
"CODEX_HOME": str(_codex_home(home, native_id)),
|
|
199
|
+
}
|
|
200
|
+
if model:
|
|
201
|
+
env["OPENAI_MODEL"] = _strip_provider_prefix(model)
|
|
202
|
+
return env
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _redacted_env(env: dict[str, str], home: Path) -> dict[str, str]:
|
|
206
|
+
redacted: dict[str, str] = {}
|
|
207
|
+
for key, value in sorted(env.items()):
|
|
208
|
+
if key.endswith("API_KEY") or key.endswith("AUTH_TOKEN"):
|
|
209
|
+
redacted[key] = "<placeholder>"
|
|
210
|
+
elif key.endswith("_HOME") or key == "CODEX_HOME":
|
|
211
|
+
redacted[key] = _redact_home(Path(value), home)
|
|
212
|
+
else:
|
|
213
|
+
redacted[key] = value
|
|
214
|
+
return redacted
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _public_context(context: dict[str, Any]) -> dict[str, Any]:
|
|
218
|
+
public = dict(context)
|
|
219
|
+
generated = dict(public.get("generated") or {})
|
|
220
|
+
generated.pop("env", None)
|
|
221
|
+
public["generated"] = generated
|
|
222
|
+
return public
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def claude_settings_conflicts(home: Path) -> list[str]:
|
|
226
|
+
path = home / ".claude" / "settings.json"
|
|
227
|
+
if not path.is_file():
|
|
228
|
+
return []
|
|
229
|
+
try:
|
|
230
|
+
obj = json.loads(path.read_text(errors="replace"))
|
|
231
|
+
except Exception:
|
|
232
|
+
return []
|
|
233
|
+
env = obj.get("env") if isinstance(obj, dict) else None
|
|
234
|
+
if not isinstance(env, dict):
|
|
235
|
+
return []
|
|
236
|
+
return [key for key in CLAUDE_MANAGED_ENV_VARS if key in env]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ApertureLaunchPlanner:
|
|
240
|
+
def __init__(self, home: Path | None = None, env: dict[str, str] | None = None, now: float | None = None) -> None:
|
|
241
|
+
self.home = (home or Path.home()).expanduser()
|
|
242
|
+
self.env = env if env is not None else os.environ
|
|
243
|
+
self.now = now
|
|
244
|
+
|
|
245
|
+
def _probe(self) -> ApertureCLIProbe:
|
|
246
|
+
return ApertureCLIProbe(home=self.home, env=self.env, now=self.now)
|
|
247
|
+
|
|
248
|
+
def _provider_payload(self) -> dict[str, Any]:
|
|
249
|
+
return self._probe().providers_payload()
|
|
250
|
+
|
|
251
|
+
def _client_payload(self, client_id: str) -> dict[str, Any]:
|
|
252
|
+
client = CLIENTS[client_id]
|
|
253
|
+
binary, source = _find_binary(self.home, self.env, client["binary_name"], client["common_paths"])
|
|
254
|
+
return {
|
|
255
|
+
"id": client_id,
|
|
256
|
+
"display_name": client["display_name"],
|
|
257
|
+
"binary_name": client["binary_name"],
|
|
258
|
+
"binary_path": str(binary) if binary else None,
|
|
259
|
+
"binary_path_source": source,
|
|
260
|
+
"installed": binary is not None,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def _endpoint_payload(self, endpoint: dict[str, Any]) -> dict[str, Any]:
|
|
264
|
+
url = str(endpoint.get("runtime_url") or endpoint.get("normalized_url") or endpoint.get("url") or "").rstrip("/")
|
|
265
|
+
return {
|
|
266
|
+
"url": url,
|
|
267
|
+
"mode": endpoint.get("mode") or "direct",
|
|
268
|
+
"bridge_id": endpoint.get("bridge_id"),
|
|
269
|
+
"runtime_url": url,
|
|
270
|
+
"display_host": endpoint.get("display_host"),
|
|
271
|
+
"normalized_url": endpoint.get("normalized_url") or url,
|
|
272
|
+
"host": endpoint.get("host"),
|
|
273
|
+
"normalized": bool(endpoint.get("normalized")),
|
|
274
|
+
"stale": bool(endpoint.get("stale")),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
def _context(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
client_id: str,
|
|
281
|
+
endpoint: dict[str, Any],
|
|
282
|
+
provider: dict[str, Any],
|
|
283
|
+
backend: dict[str, Any],
|
|
284
|
+
model: dict[str, Any] | None,
|
|
285
|
+
danger_mode: bool = False,
|
|
286
|
+
native_id: str = "<session-id>",
|
|
287
|
+
write_config: bool = False,
|
|
288
|
+
) -> dict[str, Any]:
|
|
289
|
+
model_fqn = str((model or {}).get("fqn") or "")
|
|
290
|
+
client = self._client_payload(client_id)
|
|
291
|
+
endpoint_payload = self._endpoint_payload(endpoint)
|
|
292
|
+
args: list[str] = []
|
|
293
|
+
config_writes: list[str] = []
|
|
294
|
+
if client_id == "codex":
|
|
295
|
+
if write_config:
|
|
296
|
+
config_writes = [_redact_home(path, self.home) for path in _write_codex_config(self.home, endpoint_payload["runtime_url"], native_id)]
|
|
297
|
+
else:
|
|
298
|
+
config_writes = [
|
|
299
|
+
_redact_home(_codex_home(self.home, native_id) / "auth.json", self.home),
|
|
300
|
+
_redact_home(_codex_home(self.home, native_id) / "config.toml", self.home),
|
|
301
|
+
]
|
|
302
|
+
env = _codex_env(self.home, endpoint_payload["runtime_url"], native_id, model_fqn or None)
|
|
303
|
+
if model_fqn:
|
|
304
|
+
args.extend(["--model", model_fqn])
|
|
305
|
+
else:
|
|
306
|
+
env = _claude_env(endpoint_payload["runtime_url"], str(backend["id"]), provider, model_fqn or None)
|
|
307
|
+
if danger_mode:
|
|
308
|
+
args.append(str(CLIENTS[client_id]["danger_arg"]))
|
|
309
|
+
return {
|
|
310
|
+
"strategy": "aperture_cli",
|
|
311
|
+
"client": client,
|
|
312
|
+
"endpoint": endpoint_payload,
|
|
313
|
+
"provider": {
|
|
314
|
+
"id": provider["id"],
|
|
315
|
+
"name": _provider_display(provider),
|
|
316
|
+
"display_name": _provider_display(provider),
|
|
317
|
+
"description": str(provider.get("description") or ""),
|
|
318
|
+
"models": [m for m in provider.get("models") or [] if isinstance(m, str)],
|
|
319
|
+
"compatibility": provider.get("compatibility") or {},
|
|
320
|
+
},
|
|
321
|
+
"backend": {
|
|
322
|
+
"id": backend["id"],
|
|
323
|
+
"display_name": backend["display_name"],
|
|
324
|
+
"compatibility_key": backend["compatibility_key"],
|
|
325
|
+
},
|
|
326
|
+
"model": model,
|
|
327
|
+
"danger_mode": {
|
|
328
|
+
"enabled": bool(danger_mode),
|
|
329
|
+
"arg": CLIENTS[client_id]["danger_arg"] if danger_mode else None,
|
|
330
|
+
"source": "pairling",
|
|
331
|
+
},
|
|
332
|
+
"generated": {
|
|
333
|
+
"env": env,
|
|
334
|
+
"env_redacted": _redacted_env(env, self.home),
|
|
335
|
+
"args": args,
|
|
336
|
+
"config_writes": config_writes,
|
|
337
|
+
},
|
|
338
|
+
"aperture_cli_version": APERTURE_CLI_KNOWN_VERSION,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def contexts_payload(self) -> dict[str, Any]:
|
|
342
|
+
providers_payload = self._provider_payload()
|
|
343
|
+
endpoint = providers_payload.get("source_endpoint") or {"url": "http://ai", "mode": "direct", "bridge_id": None}
|
|
344
|
+
contexts: list[dict[str, Any]] = []
|
|
345
|
+
warnings: list[str] = []
|
|
346
|
+
if endpoint.get("mode") == "bridge":
|
|
347
|
+
warnings.append("Bridge endpoints are visible but native Pairling launch requires a direct local runtime URL.")
|
|
348
|
+
for provider in providers_payload.get("items") or []:
|
|
349
|
+
if not isinstance(provider, dict):
|
|
350
|
+
continue
|
|
351
|
+
for client_id, client in CLIENTS.items():
|
|
352
|
+
seen_compat: set[str] = set()
|
|
353
|
+
for backend in client["backends"]:
|
|
354
|
+
key = backend["compatibility_key"]
|
|
355
|
+
if key in seen_compat:
|
|
356
|
+
continue
|
|
357
|
+
seen_compat.add(key)
|
|
358
|
+
if not _compat_enabled(provider, key):
|
|
359
|
+
continue
|
|
360
|
+
models = _fqn_models(provider) if backend.get("picks_model") else []
|
|
361
|
+
if models:
|
|
362
|
+
for model in models:
|
|
363
|
+
contexts.append(_public_context(self._context(client_id=client_id, endpoint=endpoint, provider=provider, backend=backend, model=model)))
|
|
364
|
+
else:
|
|
365
|
+
contexts.append(_public_context(self._context(client_id=client_id, endpoint=endpoint, provider=provider, backend=backend, model=None)))
|
|
366
|
+
return {
|
|
367
|
+
"ok": True,
|
|
368
|
+
"schema_version": 1,
|
|
369
|
+
"launch_strategy": "aperture_cli",
|
|
370
|
+
"source_endpoint": endpoint,
|
|
371
|
+
"reachable": providers_payload.get("reachable", False),
|
|
372
|
+
"last_error": providers_payload.get("last_error"),
|
|
373
|
+
"clients": [self._client_payload(client_id) for client_id in CLIENTS],
|
|
374
|
+
"contexts": contexts,
|
|
375
|
+
"count": len(contexts),
|
|
376
|
+
"warnings": warnings,
|
|
377
|
+
"observed_at": self.now if self.now is not None else time.time(),
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
def validate_request(self, aperture: dict[str, Any], native_id: str, *, write_config: bool) -> dict[str, Any]:
|
|
381
|
+
client_id = str(aperture.get("client_id") or aperture.get("client") or "").strip().lower()
|
|
382
|
+
if client_id not in CLIENTS:
|
|
383
|
+
raise ValueError("aperture.client_id must be claude or codex")
|
|
384
|
+
endpoint_url = str(aperture.get("endpoint_url") or "").strip().rstrip("/")
|
|
385
|
+
provider_id = str(aperture.get("provider_id") or "").strip()
|
|
386
|
+
backend_id = str(aperture.get("backend_id") or "").strip()
|
|
387
|
+
requested_model = str(aperture.get("model") or "").strip()
|
|
388
|
+
danger_mode = bool(aperture.get("danger_mode") is True)
|
|
389
|
+
|
|
390
|
+
providers_payload = self._provider_payload()
|
|
391
|
+
endpoint = providers_payload.get("source_endpoint") or {"url": "http://ai", "mode": "direct", "bridge_id": None}
|
|
392
|
+
if endpoint.get("mode") == "bridge":
|
|
393
|
+
raise ValueError("Aperture CLI bridge endpoints are not supported for native Pairling launch yet")
|
|
394
|
+
if endpoint_url and endpoint_url != str(endpoint.get("url") or "").rstrip("/"):
|
|
395
|
+
raise ValueError("aperture.endpoint_url does not match active Aperture CLI endpoint")
|
|
396
|
+
if not providers_payload.get("reachable"):
|
|
397
|
+
raise ValueError(f"Aperture providers are not reachable: {providers_payload.get('last_error') or 'unknown error'}")
|
|
398
|
+
|
|
399
|
+
provider = next((p for p in providers_payload.get("items") or [] if isinstance(p, dict) and p.get("id") == provider_id), None)
|
|
400
|
+
if not provider:
|
|
401
|
+
raise ValueError("aperture.provider_id is not available from the active Aperture endpoint")
|
|
402
|
+
backend = next((b for b in CLIENTS[client_id]["backends"] if b["id"] == backend_id), None)
|
|
403
|
+
if not backend:
|
|
404
|
+
raise ValueError("aperture.backend_id is not supported for this client")
|
|
405
|
+
if not _compat_enabled(provider, backend["compatibility_key"]):
|
|
406
|
+
raise ValueError("selected provider does not support the requested client/backend")
|
|
407
|
+
models = _fqn_models(provider)
|
|
408
|
+
model = None
|
|
409
|
+
if backend.get("picks_model"):
|
|
410
|
+
if requested_model:
|
|
411
|
+
model = next((m for m in models if m["fqn"] == requested_model), None)
|
|
412
|
+
if model is None:
|
|
413
|
+
raise ValueError("aperture.model is not available for the selected provider")
|
|
414
|
+
elif len(models) == 1:
|
|
415
|
+
model = models[0]
|
|
416
|
+
elif client_id == "codex":
|
|
417
|
+
raise ValueError("aperture.model is required for Codex when multiple or zero models are present")
|
|
418
|
+
elif requested_model:
|
|
419
|
+
raise ValueError("aperture.model must be omitted for this backend")
|
|
420
|
+
|
|
421
|
+
client_payload = self._client_payload(client_id)
|
|
422
|
+
if not client_payload["installed"]:
|
|
423
|
+
raise ValueError(f"{client_payload['display_name']} binary is not installed")
|
|
424
|
+
if client_id == "claude":
|
|
425
|
+
conflicts = claude_settings_conflicts(self.home)
|
|
426
|
+
if conflicts:
|
|
427
|
+
raise ValueError("~/.claude/settings.json conflicts with Aperture-managed Claude env: " + ", ".join(conflicts))
|
|
428
|
+
|
|
429
|
+
return self._context(
|
|
430
|
+
client_id=client_id,
|
|
431
|
+
endpoint=endpoint,
|
|
432
|
+
provider=provider,
|
|
433
|
+
backend=backend,
|
|
434
|
+
model=model,
|
|
435
|
+
danger_mode=danger_mode,
|
|
436
|
+
native_id=native_id,
|
|
437
|
+
write_config=write_config,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def contexts_payload(home: Path | None = None, env: dict[str, str] | None = None) -> dict[str, Any]:
|
|
442
|
+
return ApertureLaunchPlanner(home=home, env=env).contexts_payload()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def validate_launch_context(aperture: dict[str, Any], native_id: str, *, home: Path | None = None, env: dict[str, str] | None = None, write_config: bool = True) -> dict[str, Any]:
|
|
446
|
+
return ApertureLaunchPlanner(home=home, env=env).validate_request(aperture, native_id, write_config=write_config)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def command_for_context(context: dict[str, Any], project: str) -> str:
|
|
450
|
+
client = context.get("client") if isinstance(context.get("client"), dict) else {}
|
|
451
|
+
generated = context.get("generated") if isinstance(context.get("generated"), dict) else {}
|
|
452
|
+
binary = str(client.get("binary_path") or client.get("binary_name") or "")
|
|
453
|
+
args = [str(arg) for arg in generated.get("args") or []]
|
|
454
|
+
if client.get("id") == "codex":
|
|
455
|
+
args = ["-C", project, "--add-dir", project] + args
|
|
456
|
+
return "exec " + " ".join([shlex.quote(binary), *[shlex.quote(arg) for arg in args]])
|