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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairling",
3
- "version": "0.0.1",
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]])