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,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" "$@"
|