chartremotely 0.3.1__tar.gz → 0.3.3__tar.gz

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 (66) hide show
  1. {chartremotely-0.3.1 → chartremotely-0.3.3}/PKG-INFO +25 -16
  2. {chartremotely-0.3.1 → chartremotely-0.3.3}/README.md +23 -14
  3. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/displays.py +5 -5
  4. chartremotely-0.3.3/chartremotely/holder.py +123 -0
  5. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/mcpclient.py +14 -4
  6. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/patron.py +20 -24
  7. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/setup.py +38 -38
  8. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/PKG-INFO +25 -16
  9. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/SOURCES.txt +2 -0
  10. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/requires.txt +1 -1
  11. {chartremotely-0.3.1 → chartremotely-0.3.3}/pyproject.toml +2 -2
  12. chartremotely-0.3.3/tests/test_changelog.py +85 -0
  13. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_hearing.py +9 -8
  14. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_patron.py +21 -19
  15. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_setup.py +24 -16
  16. {chartremotely-0.3.1 → chartremotely-0.3.3}/LICENSE +0 -0
  17. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/__init__.py +0 -0
  18. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/answer.py +0 -0
  19. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/assets/shortcut.plist +0 -0
  20. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/ax.py +0 -0
  21. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/cli.py +0 -0
  22. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/config.py +0 -0
  23. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/doctor.py +0 -0
  24. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/guilock.py +0 -0
  25. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/hearing.py +0 -0
  26. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/keystore.py +0 -0
  27. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/layout.py +0 -0
  28. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/popups.py +0 -0
  29. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/push.py +0 -0
  30. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/recent.py +0 -0
  31. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/registry.py +0 -0
  32. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/relay.py +0 -0
  33. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/requestlog.py +0 -0
  34. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/resolve.py +0 -0
  35. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/scales.py +0 -0
  36. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/screen.py +0 -0
  37. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/server.py +0 -0
  38. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/services.py +0 -0
  39. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/shortcut.py +0 -0
  40. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/snapshot.py +0 -0
  41. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/studies.py +0 -0
  42. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/symbol.py +0 -0
  43. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/tailnet.py +0 -0
  44. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/terminal.py +0 -0
  45. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/timeframe.py +0 -0
  46. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/understand.py +0 -0
  47. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/vocab.py +0 -0
  48. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/voice.py +0 -0
  49. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/window.py +0 -0
  50. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/dependency_links.txt +0 -0
  51. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/entry_points.txt +0 -0
  52. {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/top_level.txt +0 -0
  53. {chartremotely-0.3.1 → chartremotely-0.3.3}/setup.cfg +0 -0
  54. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_popups.py +0 -0
  55. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_push.py +0 -0
  56. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_recent.py +0 -0
  57. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_relay.py +0 -0
  58. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_requestlog.py +0 -0
  59. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_resolve.py +0 -0
  60. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_scales.py +0 -0
  61. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_screen.py +0 -0
  62. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_snapshot.py +0 -0
  63. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_stamp_changelog.py +0 -0
  64. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_timeframe.py +0 -0
  65. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_understand.py +0 -0
  66. {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_voice.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -219,7 +219,7 @@ Requires-Dist: pyobjc-framework-Quartz>=10; extra == "macos"
219
219
  Requires-Dist: pyobjc-framework-Security>=10; extra == "macos"
220
220
  Requires-Dist: mlx-whisper>=0.4.3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos"
221
221
  Provides-Extra: setup
222
- Requires-Dist: tollbooth-dpyc[nostr]==0.90.0; extra == "setup"
222
+ Requires-Dist: tollbooth-dpyc[nostr]==0.102.1; extra == "setup"
223
223
  Provides-Extra: dev
224
224
  Requires-Dist: pytest>=8; extra == "dev"
225
225
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -305,6 +305,7 @@ chartremotely serve run the local listener
305
305
  chartremotely pair adopt this display to an operator
306
306
  chartremotely relay hold a connection open for the operator
307
307
  chartremotely doctor check everything the agent needs
308
+ chartremotely token print the local listener's access token
308
309
  ```
309
310
 
310
311
  `serve` and `relay` are two different transports and most setups want both.
@@ -421,19 +422,23 @@ curl -fsSL https://chartremotely.tollbooth-dpyc.com/install.sh | sh
421
422
 
422
423
  That installs [uv](https://docs.astral.sh/uv/) if it is missing, installs this
423
424
  agent from PyPI (`uv tool install --python 3.12 'chartremotely[macos,setup]'`),
424
- and runs `chartremotely setup`, which:
425
-
426
- 1. proves who owns the display — your existing npub by answering a Nostr DM,
427
- a key already saved on this Mac, or a new key it makes and saves for you
428
- in the Keychain and, through Safari, in iCloud Passwords;
429
- 2. pairs this Mac with that identity, with no code to copy;
430
- 3. gives the Mac its tailnet address with `tailscale serve`;
431
- 4. installs the listener and the relay as launchd agents;
432
- 5. asks macOS for Accessibility and Screen Recording for the Python that runs them;
433
- 6. fetches the Whisper speech model (about 1.6 GB, once) and your display names;
434
- 7. makes this Mac's Shortcut, "ChartRemotely" (talk, or type when it asks),
435
- opens it for import, and removes a "ChartRemotely Ask" an older setup left
436
- in your library.
425
+ and runs `chartremotely setup`. Setup first says what is installing — version,
426
+ where it came from, the Python it runs on — lists its six steps, and asks
427
+ before changing anything:
428
+
429
+ 1. **Identity** — proves who owns the display (your existing npub by answering
430
+ a Nostr DM, a key already saved on this Mac, or a new key it makes and saves
431
+ for you in the Keychain and, through Safari, in iCloud Passwords), then
432
+ pairs this Mac with that identity, with no code to copy;
433
+ 2. **Tailscale** — gives the Mac its tailnet address with `tailscale serve`;
434
+ 3. **Services** — installs the listener and the relay as launchd agents;
435
+ 4. **Permissions** — asks macOS for Accessibility and Screen Recording for the
436
+ Python that runs them;
437
+ 5. **Hearing** — fetches the Whisper speech model (about 1.6 GB, once) and
438
+ your display names;
439
+ 6. **Shortcut** — makes this Mac's Shortcut, "ChartRemotely" (talk, or type
440
+ when it asks), opens it for import, and removes a "ChartRemotely Ask" an
441
+ older setup left in your library.
437
442
 
438
443
  Run it again at any time: finished steps are skipped. `chartremotely doctor`
439
444
  reports anything still missing.
@@ -473,7 +478,11 @@ switch to a grid - discovery follows, and takes about a second.
473
478
 
474
479
  ## Status
475
480
 
476
- Early. The thinkorswim adapter works; the interfaces are still moving.
481
+ In service. Releases are published to PyPI and installed with the one-line
482
+ installer above; a paired Mac takes commands by voice, from the web app at
483
+ <https://chartremotely.tollbooth-dpyc.com>, and from any MCP client through the
484
+ operator ([ChartRemotely-mcp](https://github.com/lonniev/ChartRemotely-mcp)).
485
+ thinkorswim is the one charting application it drives today.
477
486
 
478
487
  ## License
479
488
 
@@ -78,6 +78,7 @@ chartremotely serve run the local listener
78
78
  chartremotely pair adopt this display to an operator
79
79
  chartremotely relay hold a connection open for the operator
80
80
  chartremotely doctor check everything the agent needs
81
+ chartremotely token print the local listener's access token
81
82
  ```
82
83
 
83
84
  `serve` and `relay` are two different transports and most setups want both.
@@ -194,19 +195,23 @@ curl -fsSL https://chartremotely.tollbooth-dpyc.com/install.sh | sh
194
195
 
195
196
  That installs [uv](https://docs.astral.sh/uv/) if it is missing, installs this
196
197
  agent from PyPI (`uv tool install --python 3.12 'chartremotely[macos,setup]'`),
197
- and runs `chartremotely setup`, which:
198
-
199
- 1. proves who owns the display — your existing npub by answering a Nostr DM,
200
- a key already saved on this Mac, or a new key it makes and saves for you
201
- in the Keychain and, through Safari, in iCloud Passwords;
202
- 2. pairs this Mac with that identity, with no code to copy;
203
- 3. gives the Mac its tailnet address with `tailscale serve`;
204
- 4. installs the listener and the relay as launchd agents;
205
- 5. asks macOS for Accessibility and Screen Recording for the Python that runs them;
206
- 6. fetches the Whisper speech model (about 1.6 GB, once) and your display names;
207
- 7. makes this Mac's Shortcut, "ChartRemotely" (talk, or type when it asks),
208
- opens it for import, and removes a "ChartRemotely Ask" an older setup left
209
- in your library.
198
+ and runs `chartremotely setup`. Setup first says what is installing — version,
199
+ where it came from, the Python it runs on — lists its six steps, and asks
200
+ before changing anything:
201
+
202
+ 1. **Identity** — proves who owns the display (your existing npub by answering
203
+ a Nostr DM, a key already saved on this Mac, or a new key it makes and saves
204
+ for you in the Keychain and, through Safari, in iCloud Passwords), then
205
+ pairs this Mac with that identity, with no code to copy;
206
+ 2. **Tailscale** — gives the Mac its tailnet address with `tailscale serve`;
207
+ 3. **Services** — installs the listener and the relay as launchd agents;
208
+ 4. **Permissions** — asks macOS for Accessibility and Screen Recording for the
209
+ Python that runs them;
210
+ 5. **Hearing** — fetches the Whisper speech model (about 1.6 GB, once) and
211
+ your display names;
212
+ 6. **Shortcut** — makes this Mac's Shortcut, "ChartRemotely" (talk, or type
213
+ when it asks), opens it for import, and removes a "ChartRemotely Ask" an
214
+ older setup left in your library.
210
215
 
211
216
  Run it again at any time: finished steps are skipped. `chartremotely doctor`
212
217
  reports anything still missing.
@@ -246,7 +251,11 @@ switch to a grid - discovery follows, and takes about a second.
246
251
 
247
252
  ## Status
248
253
 
249
- Early. The thinkorswim adapter works; the interfaces are still moving.
254
+ In service. Releases are published to PyPI and installed with the one-line
255
+ installer above; a paired Mac takes commands by voice, from the web app at
256
+ <https://chartremotely.tollbooth-dpyc.com>, and from any MCP client through the
257
+ operator ([ChartRemotely-mcp](https://github.com/lonniev/ChartRemotely-mcp)).
258
+ thinkorswim is the one charting application it drives today.
250
259
 
251
260
  ## License
252
261
 
@@ -21,7 +21,7 @@ import threading
21
21
  import time
22
22
  from collections.abc import Callable
23
23
 
24
- from . import config, keystore, mcpclient, requestlog
24
+ from . import config, holder, mcpclient, requestlog
25
25
  from .config import DATA_DIR
26
26
 
27
27
  PATH = DATA_DIR / "displays.json"
@@ -81,11 +81,11 @@ def refresh(cfg: dict | None = None, client_for=None, path=None) -> list[str] |
81
81
  npub = cfg.get("owner_npub")
82
82
  if not (base and npub and cfg.get("agent_id")):
83
83
  return None
84
+ if not holder.connected(npub):
85
+ return None # not signed in: a background refresh never opens a connection
84
86
  try:
85
- token = keystore.load_token(npub)
86
- if not token:
87
- return None
88
- payload = (client_for or mcpclient.Client)(base, timeout=30.0).call(TOOL, {"npub": npub, "dpop_token": token})
87
+ bearer = holder.bearer(base, npub)
88
+ payload = (client_for or mcpclient.Client)(base, timeout=30.0, bearer=bearer).call(TOOL, {"npub": npub})
89
89
  except Exception as exc: # noqa: BLE001 - a missing list must never cost a reply
90
90
  requestlog.outcome("serve", TOOL, "", f"display list failed: {type(exc).__name__}")
91
91
  return None
@@ -0,0 +1,123 @@
1
+ """This Mac as a *holder*: a connection the operator knows, into whose wallet
2
+ the owner's npub is proven by Nostr DM.
3
+
4
+ The agent holds no key and keeps no grant. It keeps only this connection's
5
+ OAuth tokens, in the login Keychain (as the owner's voice sign-in), and names
6
+ the npub on each priced call; the operator checks the grant it holds.
7
+
8
+ Becoming a holder is the OAuth Authorization Code flow, run headless: the
9
+ operator's ``/authorize`` approves at once (a holder holds nothing until a DM
10
+ proves an npub into it), so the agent reads the code from the redirect
11
+ instead of opening a browser. A refresh token is single-use, so each refresh
12
+ stores the pair it returns.
13
+
14
+ Stdlib only, like the rest of the listener.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import json
22
+ import secrets
23
+ import time
24
+ import urllib.error
25
+ import urllib.parse
26
+ import urllib.request
27
+
28
+ from . import keystore
29
+
30
+ REDIRECT_URI = "http://127.0.0.1/holder"
31
+ #: Refresh this long before the access token lapses.
32
+ REFRESH_EARLY_SECONDS = 60
33
+
34
+
35
+ class HolderError(RuntimeError):
36
+ """The operator would not connect this Mac."""
37
+
38
+
39
+ def _b64url(raw: bytes) -> str:
40
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
41
+
42
+
43
+ def _post(url: str, body: bytes, content_type: str) -> dict:
44
+ request = urllib.request.Request(url, data=body, method="POST", headers={"Content-Type": content_type})
45
+ try:
46
+ with urllib.request.urlopen(request, timeout=30) as response:
47
+ return json.loads(response.read().decode())
48
+ except urllib.error.HTTPError as exc:
49
+ raise HolderError(f"the operator refused ({exc.code})") from None
50
+ except OSError as exc:
51
+ raise HolderError(f"could not reach the operator: {exc}") from None
52
+
53
+
54
+ class _KeepRedirect(urllib.request.HTTPRedirectHandler):
55
+ def redirect_request(self, *args, **kwargs):
56
+ return None
57
+
58
+
59
+ def _token(base: str, form: dict, client_id: str) -> dict:
60
+ t = _post(f"{base}/token",
61
+ urllib.parse.urlencode({**form, "client_id": client_id, "resource": f"{base}/mcp"}).encode(),
62
+ "application/x-www-form-urlencoded")
63
+ return {"client_id": client_id, "access_token": t["access_token"], "refresh_token": t["refresh_token"],
64
+ "expires_at": time.time() + float(t.get("expires_in") or 3600)}
65
+
66
+
67
+ def _login(base: str) -> dict:
68
+ client_id = _post(f"{base}/register", json.dumps({
69
+ "client_name": "ChartRemotely agent",
70
+ "redirect_uris": [REDIRECT_URI],
71
+ "grant_types": ["authorization_code", "refresh_token"],
72
+ "response_types": ["code"],
73
+ "token_endpoint_auth_method": "none",
74
+ }).encode(), "application/json")["client_id"]
75
+ verifier = _b64url(secrets.token_bytes(48))
76
+ state = _b64url(secrets.token_bytes(16))
77
+ query = urllib.parse.urlencode({
78
+ "response_type": "code", "client_id": client_id, "redirect_uri": REDIRECT_URI, "state": state,
79
+ "code_challenge": _b64url(hashlib.sha256(verifier.encode()).digest()),
80
+ "code_challenge_method": "S256", "resource": f"{base}/mcp",
81
+ })
82
+ try:
83
+ urllib.request.build_opener(_KeepRedirect).open(f"{base}/authorize?{query}", timeout=30)
84
+ raise HolderError("the operator did not redirect back")
85
+ except urllib.error.HTTPError as exc:
86
+ location = exc.headers.get("Location", "")
87
+ back = urllib.parse.parse_qs(urllib.parse.urlparse(location).query)
88
+ if back.get("state") != [state] or not back.get("code"):
89
+ raise HolderError("the operator's reply did not match this connection")
90
+ return _token(base, {"grant_type": "authorization_code", "code": back["code"][0],
91
+ "redirect_uri": REDIRECT_URI, "code_verifier": verifier}, client_id)
92
+
93
+
94
+ def _stored(npub: str) -> dict | None:
95
+ raw = keystore.load_token(npub)
96
+ try:
97
+ t = json.loads(raw) if raw else None
98
+ except ValueError:
99
+ return None # a sign-in saved before this Mac connected as a holder
100
+ return t if isinstance(t, dict) and t.get("refresh_token") else None
101
+
102
+
103
+ def bearer(base: str, npub: str) -> str:
104
+ """The bearer for the next call: refreshed when it is about to lapse, or a
105
+ fresh connection when there is none. Raises HolderError or KeystoreError."""
106
+ t = _stored(npub)
107
+ if t and t["expires_at"] - REFRESH_EARLY_SECONDS <= time.time():
108
+ try:
109
+ t = _token(base, {"grant_type": "refresh_token", "refresh_token": t["refresh_token"]}, t["client_id"])
110
+ except HolderError:
111
+ t = None
112
+ if not t:
113
+ t = _login(base)
114
+ keystore.save_token(npub, json.dumps(t))
115
+ return t["access_token"]
116
+
117
+
118
+ def connected(npub: str) -> bool:
119
+ """True when this Mac has a connection to the operator for ``npub``."""
120
+ try:
121
+ return _stored(npub) is not None
122
+ except keystore.KeystoreError:
123
+ return False
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import itertools
16
16
  import json
17
17
  import urllib.request
18
+ from collections.abc import Callable
18
19
 
19
20
  PROTOCOL_VERSION = "2025-06-18"
20
21
 
@@ -53,19 +54,28 @@ def tool_payload(result: dict) -> dict:
53
54
  class Client:
54
55
  """A session with one operator's ``/mcp`` endpoint."""
55
56
 
56
- def __init__(self, base_url: str, timeout: float = 60.0) -> None:
57
+ def __init__(self, base_url: str, timeout: float = 60.0, *, bearer: str = "",
58
+ signer: Callable[[str, str, bytes], str] | None = None) -> None:
59
+ """``bearer`` is this Mac's holder token; ``signer`` signs each request
60
+ with a key (NIP-98) and wins over it. With neither, the call is anonymous."""
57
61
  self.url = base_url.rstrip("/") + "/mcp"
58
62
  self.timeout = timeout
63
+ self.bearer = bearer
64
+ self.signer = signer
59
65
  self.session: str | None = None
60
66
  self._ids = itertools.count(1)
61
67
 
62
68
  def _send(self, message: dict) -> dict:
69
+ # Identity rides on the connection: a signed request, this Mac's holder
70
+ # token, or (for free tools) an anonymous bearer.
71
+ body = json.dumps(message).encode()
63
72
  headers = {"Content-Type": "application/json",
64
- "Accept": "application/json, text/event-stream"}
73
+ "Accept": "application/json, text/event-stream",
74
+ "Authorization": (self.signer("POST", self.url, body) if self.signer
75
+ else f"Bearer {self.bearer or 'anonymous'}")}
65
76
  if self.session:
66
77
  headers["mcp-session-id"] = self.session
67
- request = urllib.request.Request(
68
- self.url, data=json.dumps(message).encode(), headers=headers, method="POST")
78
+ request = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
69
79
  try:
70
80
  with urllib.request.urlopen(request, timeout=self.timeout) as response:
71
81
  self.session = response.headers.get("mcp-session-id") or self.session
@@ -2,9 +2,9 @@
2
2
 
3
3
  The Mac plays by the operator's rules like any other MCP client. A chart
4
4
  change heard by voice is a call to the operator's own ``chart_show_chart``
5
- (and "read" to ``chart_read_chart``), made with the display owner's ``npub``
6
- and the ``dpop_token`` they earned by answering a Nostr DM - the same
7
- envelope every patron sends. The operator prices it, relays it to the named
5
+ (and "read" to ``chart_read_chart``), made as the display owner's ``npub`` over
6
+ this Mac's holder connection, into which the owner proved that npub by
7
+ answering a Nostr DM. The operator prices it, relays it to the named
8
8
  display (blank "Where?": this Mac, by its agent_id), and that display's relay
9
9
  changes the chart and takes its picture. Nothing here drives the chart.
10
10
 
@@ -17,7 +17,7 @@ while the call finishes on its own and its outcome is logged.
17
17
  When the sign-in has expired the owner hears so, and one renewal runs in the
18
18
  background: a fresh ``chart_request_npub_proof`` DM, then
19
19
  ``chart_receive_npub_proof`` on a bounded, slowing schedule until they answer,
20
- and the new token goes back into the Keychain. The nsec is never asked for.
20
+ which proves the npub into this Mac's connection again. The nsec is never asked for.
21
21
 
22
22
  Stdlib only (the Keychain behind a lazy import), like the relay.
23
23
  """
@@ -28,7 +28,7 @@ import threading
28
28
  import time
29
29
  from collections.abc import Callable
30
30
 
31
- from . import config, keystore, mcpclient, recent, requestlog
31
+ from . import config, holder, keystore, mcpclient, recent, requestlog
32
32
 
33
33
  ERR = "ERR "
34
34
  #: How long a spoken reply waits for an early refusal before handing off.
@@ -39,7 +39,7 @@ READ_SECONDS = 25.0
39
39
  CALL_TIMEOUT = 60.0
40
40
  AS_IS = {"", "as is", "asis"}
41
41
 
42
- #: What the SDK answers when a paid call's dpop_token is missing, unknown or expired.
42
+ #: What the SDK answers when the connection does not (or no longer) prove the npub.
43
43
  PROOF_CODES = frozenset({"proof_missing", "proof_required", "proof_refresh_needed", "proof_invalid"})
44
44
  EXPIRED = ERR + "Your ChartRemotely sign-in expired. Answer the DM on your phone to renew."
45
45
  REASON = "Your Mac's ChartRemotely voice control needs you to sign in again."
@@ -88,10 +88,15 @@ def refusal(payload: dict) -> str | None:
88
88
 
89
89
  # -- the call -----------------------------------------------------------------------
90
90
 
91
- def _call(base: str, tool: str, args: dict) -> dict:
92
- """One tool call. Never raises: an unreachable operator is an ``error`` payload."""
91
+ def _call(base: str, npub: str, tool: str, args: dict) -> dict:
92
+ """One tool call over this Mac's connection for ``npub``. Never raises: an
93
+ unreachable operator is an ``error`` payload."""
93
94
  try:
94
- return mcpclient.Client(base, timeout=CALL_TIMEOUT).call(tool, args)
95
+ return mcpclient.Client(base, timeout=CALL_TIMEOUT, bearer=holder.bearer(base, npub)).call(tool, args)
96
+ except holder.HolderError as exc:
97
+ return {"success": False, "error": str(exc)}
98
+ except keystore.KeystoreError:
99
+ return {"success": False, "error": "The Keychain would not give me your ChartRemotely sign-in."}
95
100
  except mcpclient.McpError as exc:
96
101
  return {"success": False, "error": str(exc)}
97
102
  except Exception as exc: # noqa: BLE001 - a background call must never kill the listener
@@ -117,21 +122,12 @@ def send(command: str, where: str, cfg: dict | None = None) -> str:
117
122
  if isinstance(mapped, str):
118
123
  return mapped
119
124
  tool, args = mapped
120
- try:
121
- token = keystore.load_token(npub)
122
- except keystore.KeystoreError as exc:
123
- requestlog.outcome("serve", tool, wanted, str(exc))
124
- return ERR + "The Keychain would not give me your ChartRemotely sign-in."
125
- if not token:
126
- renew(base, npub)
127
- return EXPIRED
128
-
129
- envelope = {**args, "display": wanted or agent_id, "npub": npub, "dpop_token": token}
125
+ envelope = {**args, "display": wanted or agent_id, "npub": npub}
130
126
  box: dict = {}
131
127
  done = threading.Event()
132
128
 
133
129
  def work() -> None:
134
- payload = _call(base, tool, envelope)
130
+ payload = _call(base, npub, tool, envelope)
135
131
  box["payload"] = payload
136
132
  settle(base, npub, tool, wanted, payload)
137
133
  if tool == "chart_show_chart" and refusal(payload) is None \
@@ -154,7 +150,7 @@ def send(command: str, where: str, cfg: dict | None = None) -> str:
154
150
 
155
151
 
156
152
  def settle(base: str, npub: str, tool: str, where: str, payload: dict) -> None:
157
- """Log how a call ended (no token, no arguments) and renew an expired sign-in."""
153
+ """Log how a call ended (no arguments) and renew an expired sign-in."""
158
154
  if payload.get("error_code") in PROOF_CODES:
159
155
  renew(base, npub)
160
156
  requestlog.outcome("serve", tool, where, refusal(payload))
@@ -175,16 +171,16 @@ def renew(base: str, npub: str, sleep: Callable[[float], None] = time.sleep) ->
175
171
 
176
172
  def _renew(base: str, npub: str, sleep: Callable[[float], None]) -> None:
177
173
  try:
178
- sent = _call(base, "chart_request_npub_proof", {"patron_npub": npub, "reason": REASON})
174
+ sent = _call(base, npub, "chart_request_npub_proof", {"patron_npub": npub, "reason": REASON})
179
175
  phrase = sent.get("dpop_token")
180
176
  if not phrase:
181
177
  requestlog.outcome("serve", "renew", "", refusal(sent) or "no proof request was sent")
182
178
  return
183
179
  for delay in RENEW_DELAYS:
184
180
  sleep(delay)
185
- got = _call(base, "chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
181
+ got = _call(base, npub, "chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
186
182
  if got.get("success") is not False and not got.get("error"):
187
- keystore.save_token(npub, str(got.get("dpop_token") or phrase))
183
+ # Proven into this Mac's connection; nothing to keep here.
188
184
  requestlog.outcome("serve", "renew", "", None)
189
185
  return
190
186
  if got.get("error_code") not in (NOT_YET, None):
@@ -34,7 +34,7 @@ import tempfile
34
34
  from collections.abc import Callable
35
35
  from pathlib import Path
36
36
 
37
- from . import config, mcpclient, relay
37
+ from . import config, holder, mcpclient, relay
38
38
  from .terminal import Terminal, wants_color
39
39
 
40
40
  OPERATOR_URL = "https://chartremotely-mcp.fastmcp.app"
@@ -51,10 +51,12 @@ class SetupError(RuntimeError):
51
51
 
52
52
  # -- identity -----------------------------------------------------------------
53
53
 
54
- def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
55
- """Prove an existing npub by the Secure Courier DM. Returns the dpop_token.
54
+ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> None:
55
+ """Prove an existing npub by the Secure Courier DM, into this Mac's connection.
56
56
 
57
- The human answers from their own Nostr client, so no key passes through here.
57
+ ``client`` carries this Mac's holder token, so the operator keeps the grant
58
+ for this connection. The human answers from their own Nostr client, so no
59
+ key passes through here.
58
60
  """
59
61
  sent = client.call("chart_request_npub_proof", {
60
62
  "patron_npub": npub, "verify_at": SITE,
@@ -71,7 +73,6 @@ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
71
73
  got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
72
74
  if got.get("error"):
73
75
  raise SetupError(got["error"])
74
- return got.get("dpop_token") or phrase
75
76
 
76
77
 
77
78
  def new_key() -> tuple[str, str]:
@@ -82,11 +83,11 @@ def new_key() -> tuple[str, str]:
82
83
  return key.public_key.bech32(), key.bech32()
83
84
 
84
85
 
85
- def sign_once(nsec: str, tool: str) -> str:
86
- """One kind-27235 proof for one tool call, signed by the SDK."""
87
- from tollbooth.identity_proof import create_proof
86
+ def signer_for(nsec: str) -> Callable[[str, str, bytes], str]:
87
+ """Signs each request with ``nsec`` (NIP-98), by the SDK."""
88
+ from tollbooth.identity_proof import sign_request
88
89
 
89
- return create_proof(nsec, tool)
90
+ return lambda method, url, body: sign_request(nsec, method=method, url=url, body=body)
90
91
 
91
92
 
92
93
  def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
@@ -106,11 +107,11 @@ def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
106
107
  keystore.clear_if_unchanged(nsec)
107
108
 
108
109
 
109
- def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bool]:
110
+ def identity(base: str, ask: Ask, say: Say) -> tuple[str, mcpclient.Client, bool]:
110
111
  """Who owns this display, proven.
111
112
 
112
- Returns (npub, proof for chart_pair_agent, whether that proof is a DM
113
- sign-in the listener can keep using).
113
+ Returns (npub, a client that acts as that npub for chart_pair_agent, and
114
+ whether this Mac's connection now holds the npub for voice).
114
115
  """
115
116
  say("Your Nostr identity owns this display:")
116
117
  say(" 1 I have an npub (I'll answer a Nostr DM to prove it)")
@@ -121,7 +122,9 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
121
122
  npub = ask("Your npub: ").strip()
122
123
  if not npub.startswith("npub1"):
123
124
  raise SetupError("that is not an npub")
124
- return npub, prove_by_dm(client, npub, ask, say), True
125
+ client = mcpclient.Client(base, bearer=holder.bearer(base, npub))
126
+ prove_by_dm(client, npub, ask, say)
127
+ return npub, client, True
125
128
  if choice == "2":
126
129
  from . import keystore
127
130
 
@@ -133,7 +136,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
133
136
  picked = saved[int(ask("Which key? ").strip() or "1") - 1]
134
137
  nsec = keystore.load(picked) # macOS asks the human first
135
138
  try:
136
- return picked, sign_once(nsec, "chart_pair_agent"), False
139
+ return picked, mcpclient.Client(base, signer=signer_for(nsec)), False
137
140
  finally:
138
141
  del nsec
139
142
  if choice == "3":
@@ -141,7 +144,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
141
144
  try:
142
145
  say(f"Your new npub is {npub}")
143
146
  keep_for_human(npub, nsec, ask, say)
144
- return npub, sign_once(nsec, "chart_pair_agent"), False
147
+ return npub, mcpclient.Client(base, signer=signer_for(nsec)), False
145
148
  finally:
146
149
  del nsec
147
150
  raise SetupError("choose 1, 2 or 3")
@@ -149,40 +152,38 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
149
152
 
150
153
  # -- the voice sign-in --------------------------------------------------------
151
154
 
152
- def keep_sign_in(npub: str, token: str) -> None:
153
- """The owner's npub into the config; their sign-in into the Keychain, only."""
154
- from . import keystore
155
-
156
- keystore.save_token(npub, token)
155
+ def keep_sign_in(npub: str) -> None:
156
+ """The owner's npub into the config. Their sign-in is this Mac's connection,
157
+ whose tokens the holder keeps in the Keychain."""
157
158
  config.update(owner_npub=npub)
158
159
 
159
160
 
160
161
  def has_sign_in(npub: str) -> bool:
161
- from . import keystore
162
+ return holder.connected(npub)
162
163
 
163
- try:
164
- return bool(keystore.load_token(npub))
165
- except keystore.KeystoreError:
166
- return False
167
164
 
168
-
169
- def voice_sign_in(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> None:
165
+ def voice_sign_in(base: str, npub: str, ask: Ask, say: Say) -> None:
170
166
  """Voice commands are priced tool calls made as the owner: prove the npub by DM for them."""
171
167
  config.update(owner_npub=npub)
172
168
  say(f"Voice commands are paid for by {npub[:12]}…, signed in by a Nostr DM.")
173
169
  if ask("Sign in for voice now? [Y/n] ").strip().lower().startswith("n"):
174
170
  say("Then your first voice command sends the DM.")
175
171
  return
176
- keep_sign_in(npub, prove_by_dm(client, npub, ask, say))
172
+ prove_by_dm(mcpclient.Client(base, bearer=holder.bearer(base, npub)), npub, ask, say)
173
+ keep_sign_in(npub)
177
174
  say("Voice is signed in.")
178
175
 
179
176
 
180
177
  # -- pairing ------------------------------------------------------------------
181
178
 
182
- def pair(client: mcpclient.Client, base: str, npub: str, proof: str, label: str) -> dict:
183
- """Adopt this machine's pairing code with the patron's proof, then collect."""
179
+ def pair(client: mcpclient.Client, base: str, npub: str, label: str) -> dict:
180
+ """Adopt this machine's pairing code as the patron, then collect.
181
+
182
+ ``client`` proves the npub on its connection: signed with their key, or
183
+ this Mac's connection that holds it.
184
+ """
184
185
  code, expires_in = relay.open_code(base)
185
- answer = client.call("chart_pair_agent", {"code": code, "label": label, "npub": npub, "dpop_token": proof})
186
+ answer = client.call("chart_pair_agent", {"code": code, "label": label, "npub": npub})
186
187
  if not answer.get("ok"):
187
188
  raise SetupError(answer.get("error") or "the operator refused the pairing")
188
189
  return relay.collect(base, code, expires_in)
@@ -250,7 +251,6 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
250
251
 
251
252
  # 1. Identity and pairing, unless this Mac is already paired.
252
253
  out.step(1, total, "Identity")
253
- client = mcpclient.Client(base)
254
254
  if cfg.get("agent_id") and cfg.get("agent_secret"):
255
255
  out.ok(f"This Mac is already paired ({cfg['agent_id']}).")
256
256
  npub = cfg.get("owner_npub") or out.ask("Your npub (whose display this is): ").strip()
@@ -260,18 +260,18 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
260
260
  config.update(owner_npub=npub)
261
261
  out.ok("Voice is already signed in.")
262
262
  else:
263
- voice_sign_in(client, npub, out.ask, out.say)
263
+ voice_sign_in(base, npub, out.ask, out.say)
264
264
  else:
265
- npub, proof, by_dm = identity(client, out.ask, out.say)
265
+ npub, client, by_dm = identity(base, out.ask, out.say)
266
266
  label = out.ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
267
- pair(client, base, npub, proof, label)
267
+ pair(client, base, npub, label)
268
268
  out.ok(f"Paired as “{label}”.")
269
269
  if by_dm:
270
- keep_sign_in(npub, proof)
270
+ keep_sign_in(npub)
271
271
  out.ok("Voice is signed in with the same DM.")
272
272
  else:
273
- voice_sign_in(client, npub, out.ask, out.say)
274
- del proof
273
+ voice_sign_in(base, npub, out.ask, out.say)
274
+ del client
275
275
 
276
276
  # 2. Tailscale.
277
277
  from . import tailnet