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,119 @@
1
+ #!/usr/bin/env python3
2
+ """Render Pairling launchd plists with absolute runtime paths."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import plistlib
8
+ from pathlib import Path
9
+
10
+ PAIRLING_DAEMON_LABEL = "dev.pairling.companiond"
11
+ PAIRLING_GUARDIAN_LABEL = "dev.pairling.power-guardian"
12
+ PAIRLING_CONNECTD_LABEL = "dev.pairling.connectd"
13
+ PAIRLING_RUNTIME_PORT = "7773"
14
+
15
+
16
+ def write_plist(path: Path, payload: dict) -> None:
17
+ path.parent.mkdir(parents=True, exist_ok=True)
18
+ with path.open("wb") as fh:
19
+ plistlib.dump(payload, fh, sort_keys=False)
20
+
21
+
22
+ def daemon_plist(current: Path, logs: Path, python_bin: str) -> dict:
23
+ return {
24
+ "Label": PAIRLING_DAEMON_LABEL,
25
+ "ProgramArguments": [
26
+ python_bin,
27
+ str(current / "companiond" / "pairlingd.py"),
28
+ ],
29
+ "EnvironmentVariables": {
30
+ "PAIRLING_RUNTIME_PORT": PAIRLING_RUNTIME_PORT,
31
+ "COMPANION_DAEMON_PORT": PAIRLING_RUNTIME_PORT,
32
+ "PAIRLING_BIND_MODE": "all",
33
+ "PAIRLING_APP_SUPPORT_ROOT": str(current.parent.parent),
34
+ "PAIRLING_LOGS_ROOT": str(logs),
35
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
36
+ },
37
+ "RunAtLoad": True,
38
+ "KeepAlive": True,
39
+ "ThrottleInterval": 10,
40
+ "StandardOutPath": str(logs / "companiond.log"),
41
+ "StandardErrorPath": str(logs / "companiond.err"),
42
+ }
43
+
44
+
45
+ def guardian_plist(current: Path, logs: Path, python_bin: str) -> dict:
46
+ return {
47
+ "Label": PAIRLING_GUARDIAN_LABEL,
48
+ "ProgramArguments": [
49
+ python_bin,
50
+ str(current / "guardian" / "companion-power-guardian.py"),
51
+ ],
52
+ "EnvironmentVariables": {
53
+ "PAIRLING_RUNTIME_PORT": PAIRLING_RUNTIME_PORT,
54
+ "COMPANION_DAEMON_PORT": PAIRLING_RUNTIME_PORT,
55
+ "COMPANION_COORDINATOR_HOST": "pairling-mac",
56
+ "COMPANION_GUARDIAN_ENFORCE": "0",
57
+ "COMPANION_LOW_POWER_MODE_POLICY": "preserve",
58
+ "COMPANION_POWER_INTERVAL_SECONDS": "20",
59
+ "COMPANION_POWER_STATE_PATH": "/var/run/pairling-power-state.json",
60
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
61
+ },
62
+ "RunAtLoad": True,
63
+ "KeepAlive": True,
64
+ "StandardOutPath": str(logs / "power-guardian.log"),
65
+ "StandardErrorPath": str(logs / "power-guardian.err"),
66
+ }
67
+
68
+
69
+ def connectd_plist(current: Path, logs: Path) -> dict:
70
+ app_support = current.parent.parent
71
+ return {
72
+ "Label": PAIRLING_CONNECTD_LABEL,
73
+ "ProgramArguments": [
74
+ str(current / "connectd" / "pairling-connectd"),
75
+ "--upstream",
76
+ f"http://127.0.0.1:{PAIRLING_RUNTIME_PORT}",
77
+ "--listen",
78
+ f":{PAIRLING_RUNTIME_PORT}",
79
+ "--status-addr",
80
+ "127.0.0.1:7774",
81
+ "--state-dir",
82
+ str(app_support / "connectd" / "tsnet-state"),
83
+ ],
84
+ "EnvironmentVariables": {
85
+ "PAIRLING_RUNTIME_PORT": PAIRLING_RUNTIME_PORT,
86
+ "PAIRLING_APP_SUPPORT_ROOT": str(app_support),
87
+ "PAIRLING_LOGS_ROOT": str(logs),
88
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
89
+ },
90
+ "RunAtLoad": True,
91
+ "KeepAlive": True,
92
+ "ThrottleInterval": 10,
93
+ "StandardOutPath": str(logs / "connectd.log"),
94
+ "StandardErrorPath": str(logs / "connectd.err"),
95
+ }
96
+
97
+
98
+ def main() -> int:
99
+ parser = argparse.ArgumentParser()
100
+ parser.add_argument("--current-root", required=True)
101
+ parser.add_argument("--logs-root", required=True)
102
+ parser.add_argument("--output-dir", required=True)
103
+ parser.add_argument("--daemon-python", default="/usr/local/bin/python3")
104
+ parser.add_argument("--guardian-python", default="/usr/bin/python3")
105
+ parser.add_argument("--mirror-python", default="/usr/local/bin/python3", help=argparse.SUPPRESS)
106
+ args = parser.parse_args()
107
+
108
+ current = Path(args.current_root)
109
+ logs = Path(args.logs_root)
110
+ out = Path(args.output_dir)
111
+
112
+ write_plist(out / f"{PAIRLING_DAEMON_LABEL}.plist", daemon_plist(current, logs, args.daemon_python))
113
+ write_plist(out / f"{PAIRLING_GUARDIAN_LABEL}.plist", guardian_plist(current, logs, args.guardian_python))
114
+ write_plist(out / f"{PAIRLING_CONNECTD_LABEL}.plist", connectd_plist(current, logs))
115
+ return 0
116
+
117
+
118
+ if __name__ == "__main__":
119
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
5
+ PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
6
+ PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
7
+ LEGACY_DAEMON_LABEL="com.mghome.notify-webhook"
8
+ APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
9
+ LOGS_ROOT="${PAIRLING_LOGS_ROOT:-${COMPANION_LOGS_ROOT:-$HOME/Library/Logs/Pairling}}"
10
+ USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
11
+ CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
12
+ LEGACY_USER_PLIST="$HOME/Library/LaunchAgents/$LEGACY_DAEMON_LABEL.plist"
13
+ SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
14
+ LEGACY_SYSTEM_PLIST="/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist"
15
+ YES="false"
16
+ DELETE_STATE="false"
17
+ DELETE_LOGS="false"
18
+ DRY_RUN="${PAIRLING_DRY_RUN:-0}"
19
+
20
+ usage() {
21
+ cat <<EOF
22
+ usage: pairling uninstall [--yes] [--delete-state] [--delete-logs]
23
+
24
+ Default behavior stops Pairling and removes Pairling launchd plists while
25
+ preserving devices, state, logs, provider transcripts, and user projects.
26
+ EOF
27
+ }
28
+
29
+ while [[ $# -gt 0 ]]; do
30
+ case "$1" in
31
+ --yes|-y)
32
+ YES="true"
33
+ ;;
34
+ --delete-state|--remove-runtime)
35
+ DELETE_STATE="true"
36
+ ;;
37
+ --delete-logs)
38
+ DELETE_LOGS="true"
39
+ ;;
40
+ --help|-h)
41
+ usage
42
+ exit 0
43
+ ;;
44
+ *)
45
+ usage >&2
46
+ exit 2
47
+ ;;
48
+ esac
49
+ shift
50
+ done
51
+
52
+ is_dry_run() {
53
+ [[ "$DRY_RUN" == "1" || "$DRY_RUN" == "true" || "$DRY_RUN" == "TRUE" ]]
54
+ }
55
+
56
+ confirm() {
57
+ if [[ "$YES" == "true" ]]; then
58
+ return
59
+ fi
60
+ printf 'This will stop Pairling and remove its LaunchAgent.\n'
61
+ printf 'Preserve state: %s\n' "$APP_SUPPORT"
62
+ printf 'Preserve logs: %s\n' "$LOGS_ROOT"
63
+ printf 'Type "uninstall Pairling" to continue: '
64
+ local answer
65
+ IFS= read -r answer
66
+ if [[ "$answer" != "uninstall Pairling" ]]; then
67
+ printf 'Cancelled.\n' >&2
68
+ exit 1
69
+ fi
70
+ }
71
+
72
+ bootout_user() {
73
+ local label="$1"
74
+ local plist="$2"
75
+ if is_dry_run; then
76
+ printf 'dry-run: would unload %s\n' "$label"
77
+ return
78
+ fi
79
+ launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || true
80
+ launchctl bootout "gui/$(id -u)" "$plist" >/dev/null 2>&1 || true
81
+ }
82
+
83
+ bootout_system() {
84
+ local label="$1"
85
+ local plist="$2"
86
+ if [[ ! -f "$plist" ]]; then
87
+ return
88
+ fi
89
+ if is_dry_run; then
90
+ printf 'dry-run: would unload system/%s\n' "$label"
91
+ return
92
+ fi
93
+ if sudo -n true >/dev/null 2>&1; then
94
+ sudo launchctl bootout "system/$label" >/dev/null 2>&1 || true
95
+ sudo launchctl bootout system "$plist" >/dev/null 2>&1 || true
96
+ sudo rm -f "$plist"
97
+ else
98
+ printf 'Skipping %s removal: passwordless sudo is unavailable.\n' "$plist" >&2
99
+ fi
100
+ }
101
+
102
+ confirm
103
+
104
+ bootout_user "$PAIRLING_DAEMON_LABEL" "$USER_PLIST"
105
+ bootout_user "$PAIRLING_CONNECTD_LABEL" "$CONNECTD_USER_PLIST"
106
+ rm -f "$USER_PLIST"
107
+ rm -f "$CONNECTD_USER_PLIST"
108
+ bootout_system "$PAIRLING_GUARDIAN_LABEL" "$SYSTEM_PLIST"
109
+
110
+ if [[ -f "$LEGACY_USER_PLIST" ]]; then
111
+ bootout_user "$LEGACY_DAEMON_LABEL" "$LEGACY_USER_PLIST"
112
+ printf 'Warning: legacy LaunchAgent still exists and was unloaded if possible: %s\n' "$LEGACY_USER_PLIST"
113
+ fi
114
+
115
+ if [[ -f "$LEGACY_SYSTEM_PLIST" ]]; then
116
+ printf 'Warning: legacy guardian LaunchDaemon still exists: %s\n' "$LEGACY_SYSTEM_PLIST"
117
+ fi
118
+
119
+ rm -rf "$APP_SUPPORT/pair" 2>/dev/null || true
120
+
121
+ if [[ "$DELETE_STATE" == "true" ]]; then
122
+ rm -rf "$APP_SUPPORT"
123
+ printf 'Deleted Pairling state: %s\n' "$APP_SUPPORT"
124
+ else
125
+ printf 'Preserved Pairling state and devices: %s\n' "$APP_SUPPORT"
126
+ fi
127
+
128
+ if [[ "$DELETE_LOGS" == "true" ]]; then
129
+ rm -rf "$LOGS_ROOT"
130
+ printf 'Deleted Pairling logs: %s\n' "$LOGS_ROOT"
131
+ else
132
+ printf 'Preserved Pairling logs: %s\n' "$LOGS_ROOT"
133
+ fi
134
+
135
+ printf 'Provider transcripts and user projects were not removed.\n'
136
+ printf 'Reinstall with: pairling setup\n'
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Pairling daemon-first MCP bridge for phone-tools."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import hmac
8
+ import http.client
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from urllib.parse import urlparse
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ try:
19
+ from mcp.server.fastmcp import FastMCP
20
+ except Exception as exc: # pragma: no cover - exercised only in a broken MCP env
21
+ print(f"FATAL: cannot import FastMCP: {exc}", file=sys.stderr)
22
+ raise
23
+
24
+
25
+ PAIRLING_TOOLS_BASE_URL = os.environ.get("PAIRLING_TOOLS_BASE_URL", "http://127.0.0.1:7773").rstrip("/")
26
+ PAIRLING_TOOLS_TIMEOUT = float(os.environ.get("PAIRLING_TOOLS_TIMEOUT", "15"))
27
+ PAIRLING_MCP_CREDENTIAL = Path(
28
+ os.environ.get(
29
+ "PAIRLING_MCP_CREDENTIAL",
30
+ str(Path.home() / "Library" / "Application Support" / "Pairling" / "mcp-bridge.json"),
31
+ )
32
+ )
33
+
34
+ INSTALL_ID_HEADER = "Pairling-Install-ID"
35
+ REQUEST_ID_HEADER = "Pairling-Request-ID"
36
+ TIMESTAMP_HEADER = "Pairling-Timestamp"
37
+ BODY_SHA256_HEADER = "Pairling-Body-SHA256"
38
+ PROOF_HEADER = "Pairling-Proof"
39
+
40
+
41
+ class PairlingToolsClient:
42
+ def __init__(
43
+ self,
44
+ *,
45
+ base_url: str = PAIRLING_TOOLS_BASE_URL,
46
+ credential_path: Path = PAIRLING_MCP_CREDENTIAL,
47
+ timeout: float = PAIRLING_TOOLS_TIMEOUT,
48
+ ) -> None:
49
+ self.base_url = base_url.rstrip("/")
50
+ self.credential_path = credential_path
51
+ self.timeout = timeout
52
+
53
+ def run(self, tool: str, input_payload: dict[str, Any], *, strategy: str = "auto") -> str:
54
+ if os.environ.get("PAIRLING_TOOLS_DIRECT_IPHONE") == "1":
55
+ return _direct_iphone_diagnostic(tool, input_payload)
56
+ body = json.dumps({
57
+ "tool": tool,
58
+ "input": input_payload,
59
+ "strategy": strategy,
60
+ }, separators=(",", ":")).encode("utf-8")
61
+ credential = self._load_credential()
62
+ url = self.base_url + "/pairling-tools/run"
63
+ headers = {
64
+ "Content-Type": "application/json",
65
+ "Authorization": "Bearer " + credential["token"],
66
+ **_proof_headers(
67
+ credential=credential,
68
+ method="POST",
69
+ path_and_query="/pairling-tools/run",
70
+ body=body,
71
+ ),
72
+ }
73
+ try:
74
+ status, reason, payload = self._post_json(url, body, headers)
75
+ except Exception as exc:
76
+ return f"[phone-tools] Pairling tools unavailable: cannot reach Pairling daemon at {self.base_url}: {type(exc).__name__}: {exc}"
77
+ if not isinstance(payload, dict):
78
+ return f"[phone-tools] Pairling tools unavailable: HTTP {status}: {reason}"
79
+
80
+ if payload.get("ok"):
81
+ return str(payload.get("result") or "")
82
+ error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
83
+ message = error.get("message") or error.get("code") or "unknown error"
84
+ iphone_reason = error.get("iphone_reason")
85
+ mac_reason = error.get("mac_reason")
86
+ detail = ""
87
+ if iphone_reason or mac_reason:
88
+ detail = f" iPhone={iphone_reason or 'not_attempted'} Mac={mac_reason or 'not_attempted'}."
89
+ return f"[phone-tools] Pairling tools unavailable: {message}.{detail}"
90
+
91
+ def _post_json(self, url: str, body: bytes, headers: dict[str, str]) -> tuple[int, str, dict[str, Any] | None]:
92
+ parsed = urlparse(url)
93
+ if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
94
+ raise RuntimeError("Pairling MCP adapter only connects to the local Pairling daemon")
95
+ port = parsed.port or 80
96
+ path = parsed.path or "/"
97
+ if parsed.query:
98
+ path += "?" + parsed.query
99
+ conn = http.client.HTTPConnection(parsed.hostname, port, timeout=self.timeout)
100
+ try:
101
+ conn.request("POST", path, body=body, headers=headers)
102
+ response = conn.getresponse()
103
+ raw = response.read()
104
+ finally:
105
+ conn.close()
106
+ try:
107
+ payload = json.loads(raw.decode("utf-8"))
108
+ except Exception:
109
+ payload = None
110
+ return response.status, response.reason, payload
111
+
112
+ def _load_credential(self) -> dict[str, str]:
113
+ try:
114
+ payload = json.loads(self.credential_path.read_text())
115
+ except Exception as exc:
116
+ raise RuntimeError(
117
+ f"Pairling MCP bridge credential is missing. Run `pairling setup` or `pairling doctor --json`: {exc}"
118
+ ) from exc
119
+ required = ("device_id", "install_id", "token", "proof_secret")
120
+ missing = [key for key in required if not str(payload.get(key) or "").strip()]
121
+ if missing:
122
+ raise RuntimeError("Pairling MCP bridge credential is incomplete: " + ", ".join(missing))
123
+ return {key: str(payload[key]) for key in required}
124
+
125
+
126
+ def _proof_headers(*, credential: dict[str, str], method: str, path_and_query: str, body: bytes) -> dict[str, str]:
127
+ timestamp_ms = str(int(time.time() * 1000))
128
+ request_id = str(uuid.uuid4()).lower()
129
+ body_hash = hashlib.sha256(body).hexdigest()
130
+ canonical = "\n".join([
131
+ method.upper(),
132
+ path_and_query,
133
+ timestamp_ms,
134
+ request_id,
135
+ body_hash,
136
+ credential["install_id"],
137
+ credential["device_id"],
138
+ ])
139
+ proof = hmac.new(
140
+ credential["proof_secret"].encode("utf-8"),
141
+ canonical.encode("utf-8"),
142
+ hashlib.sha256,
143
+ ).hexdigest()
144
+ return {
145
+ INSTALL_ID_HEADER: credential["install_id"],
146
+ REQUEST_ID_HEADER: request_id,
147
+ TIMESTAMP_HEADER: timestamp_ms,
148
+ BODY_SHA256_HEADER: body_hash,
149
+ PROOF_HEADER: proof,
150
+ }
151
+
152
+
153
+ def _direct_iphone_diagnostic(tool: str, input_payload: dict[str, Any]) -> str:
154
+ socket = __import__("socket")
155
+ host = os.environ.get("PHONE_TS_HOST", "iphone-15-pro")
156
+ port = int(os.environ.get("PHONE_TS_PORT", "7724"))
157
+ timeout = float(os.environ.get("PHONE_TIMEOUT", "5"))
158
+ token = os.environ.get("PHONE_TOKEN")
159
+ if not token:
160
+ token_file = Path.home() / ".claude" / "scripts" / ".notify-token"
161
+ try:
162
+ token = token_file.read_text().strip()
163
+ except OSError as exc:
164
+ return f"[phone-tools] Direct iPhone diagnostic unavailable: token missing: {exc}"
165
+ request = json.dumps({"tool": tool, "token": token, "input": input_payload}) + "\n"
166
+ try:
167
+ with socket.create_connection((host, port), timeout=timeout) as sock:
168
+ sock.sendall(request.encode("utf-8"))
169
+ line = sock.recv(65536).split(b"\n", 1)[0]
170
+ except Exception as exc:
171
+ return f"[phone-tools] Direct iPhone diagnostic failed at {host}:{port}: {exc}"
172
+ try:
173
+ payload = json.loads(line.decode("utf-8"))
174
+ except Exception as exc:
175
+ return f"[phone-tools] Direct iPhone diagnostic bad response: {exc}"
176
+ if not payload.get("ok"):
177
+ return f"[phone-tools] Direct iPhone diagnostic tool failed: {payload.get('error', 'unknown error')}"
178
+ return str(payload.get("result") or "")
179
+
180
+
181
+ CLIENT = PairlingToolsClient()
182
+ mcp = FastMCP("phone-tools")
183
+
184
+
185
+ @mcp.tool()
186
+ def second_opinion(claim: str) -> str:
187
+ """Get a skeptical second opinion on a claim."""
188
+ return CLIENT.run("second_opinion", {"claim": claim})
189
+
190
+
191
+ @mcp.tool()
192
+ def vibe_check(draft: str) -> str:
193
+ """Check whether a piece of writing matches the user's typical voice."""
194
+ return CLIENT.run("vibe_check", {"draft": draft})
195
+
196
+
197
+ @mcp.tool()
198
+ def user_likely_prefers(option_a: str, option_b: str) -> str:
199
+ """Predict which of two options the user is likely to prefer."""
200
+ return CLIENT.run("user_likely_prefers", {"option_a": option_a, "option_b": option_b})
201
+
202
+
203
+ @mcp.tool()
204
+ def corpus_recall(query: str) -> str:
205
+ """Search the user's past Pairling-accessible local session corpus."""
206
+ return CLIENT.run("corpus_recall", {"query": query})
207
+
208
+
209
+ if __name__ == "__main__":
210
+ mcp.run()
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ -n "${PAIRLING_REPO_ROOT:-}" ]]; then
5
+ REPO_ROOT="$PAIRLING_REPO_ROOT"
6
+ else
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
9
+ fi
10
+
11
+ export PYTHONDONTWRITEBYTECODE=1
12
+ if [[ -z "${PYTHONPYCACHEPREFIX:-}" ]]; then
13
+ PYTHONPYCACHEPREFIX="${TMPDIR:-/tmp}/pairling-pycache-$(id -u)"
14
+ mkdir -p "$PYTHONPYCACHEPREFIX" 2>/dev/null || true
15
+ export PYTHONPYCACHEPREFIX
16
+ fi
17
+
18
+ if [[ "${1:-}" == "attach" ]]; then
19
+ shift
20
+ exec python3 "$REPO_ROOT/mac/packaging/pairling_attach.py" "$@"
21
+ fi
22
+
23
+ if [[ "${1:-}" == "connect-auth-open" ]]; then
24
+ shift || true
25
+ json_mode="false"
26
+ while [[ $# -gt 0 ]]; do
27
+ case "$1" in
28
+ --json)
29
+ json_mode="true"
30
+ shift
31
+ ;;
32
+ --help|-h)
33
+ printf 'usage: pairling connect-auth-open [--json]\n'
34
+ exit 0
35
+ ;;
36
+ *)
37
+ printf 'usage: pairling connect-auth-open [--json]\n' >&2
38
+ exit 2
39
+ ;;
40
+ esac
41
+ done
42
+ if output="$(/usr/bin/curl -sS --max-time 5 -X POST http://127.0.0.1:7774/auth/open 2>/dev/null)"; then
43
+ if python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("ok") else 1)' <<<"$output"; then
44
+ response_status=0
45
+ else
46
+ response_status=1
47
+ fi
48
+ if [[ "$json_mode" == "true" ]]; then
49
+ printf '%s\n' "$output"
50
+ else
51
+ python3 -c 'import json,sys; data=json.load(sys.stdin); print("Pairling Connect browser approval opened." if data.get("opened") else data.get("error", "Pairling Connect browser approval is not available."))' <<<"$output"
52
+ fi
53
+ exit "$response_status"
54
+ fi
55
+ if [[ "$json_mode" == "true" ]]; then
56
+ printf '{"ok":false,"opened":false,"auth_url_present":false,"error":"Pairling Connect auth endpoint unavailable."}\n'
57
+ else
58
+ printf 'Pairling Connect auth endpoint unavailable.\n' >&2
59
+ fi
60
+ exit 1
61
+ fi
62
+
63
+ exec "$REPO_ROOT/mac/install/install-runtime.sh" "$@"