chartremotely 0.2.15__tar.gz → 0.2.17__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 (64) hide show
  1. {chartremotely-0.2.15 → chartremotely-0.2.17}/PKG-INFO +3 -1
  2. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/assets/shortcut.plist +4 -0
  3. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/hearing.py +16 -3
  4. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/services.py +4 -1
  5. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/setup.py +112 -47
  6. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/shortcut.py +3 -0
  7. chartremotely-0.2.17/chartremotely/terminal.py +75 -0
  8. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely.egg-info/PKG-INFO +3 -1
  9. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely.egg-info/SOURCES.txt +1 -0
  10. {chartremotely-0.2.15 → chartremotely-0.2.17}/pyproject.toml +5 -1
  11. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_hearing.py +3 -0
  12. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_setup.py +50 -6
  13. {chartremotely-0.2.15 → chartremotely-0.2.17}/LICENSE +0 -0
  14. {chartremotely-0.2.15 → chartremotely-0.2.17}/README.md +0 -0
  15. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/__init__.py +0 -0
  16. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/answer.py +0 -0
  17. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/ax.py +0 -0
  18. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/cli.py +0 -0
  19. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/config.py +0 -0
  20. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/displays.py +0 -0
  21. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/doctor.py +0 -0
  22. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/guilock.py +0 -0
  23. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/keystore.py +0 -0
  24. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/layout.py +0 -0
  25. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/mcpclient.py +0 -0
  26. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/patron.py +0 -0
  27. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/popups.py +0 -0
  28. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/push.py +0 -0
  29. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/recent.py +0 -0
  30. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/registry.py +0 -0
  31. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/relay.py +0 -0
  32. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/requestlog.py +0 -0
  33. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/resolve.py +0 -0
  34. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/scales.py +0 -0
  35. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/screen.py +0 -0
  36. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/server.py +0 -0
  37. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/snapshot.py +0 -0
  38. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/studies.py +0 -0
  39. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/symbol.py +0 -0
  40. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/tailnet.py +0 -0
  41. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/timeframe.py +0 -0
  42. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/understand.py +0 -0
  43. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/vocab.py +0 -0
  44. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/voice.py +0 -0
  45. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely/window.py +0 -0
  46. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely.egg-info/dependency_links.txt +0 -0
  47. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely.egg-info/entry_points.txt +0 -0
  48. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely.egg-info/requires.txt +0 -0
  49. {chartremotely-0.2.15 → chartremotely-0.2.17}/chartremotely.egg-info/top_level.txt +0 -0
  50. {chartremotely-0.2.15 → chartremotely-0.2.17}/setup.cfg +0 -0
  51. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_patron.py +0 -0
  52. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_popups.py +0 -0
  53. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_push.py +0 -0
  54. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_recent.py +0 -0
  55. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_relay.py +0 -0
  56. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_requestlog.py +0 -0
  57. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_resolve.py +0 -0
  58. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_scales.py +0 -0
  59. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_screen.py +0 -0
  60. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_snapshot.py +0 -0
  61. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_stamp_changelog.py +0 -0
  62. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_timeframe.py +0 -0
  63. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_understand.py +0 -0
  64. {chartremotely-0.2.15 → chartremotely-0.2.17}/tests/test_voice.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.15
3
+ Version: 0.2.17
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -206,6 +206,8 @@ License:
206
206
  See the License for the specific language governing permissions and
207
207
  limitations under the License.
208
208
 
209
+ Project-URL: Repository, https://github.com/lonniev/ChartRemotely-agent
210
+ Project-URL: Changelog, https://github.com/lonniev/ChartRemotely-agent/blob/main/CHANGELOG.md
209
211
  Keywords: thinkorswim,accessibility,automation,charting
210
212
  Requires-Python: >=3.12
211
213
  Description-Content-Type: text/markdown
@@ -669,6 +669,8 @@
669
669
  <string>is.workflow.actions.speaktext</string>
670
670
  <key>WFWorkflowActionParameters</key>
671
671
  <dict>
672
+ <key>WFSpeakTextRate</key>
673
+ <real>0.6</real>
672
674
  <key>WFSpeakTextWait</key>
673
675
  <true/>
674
676
  <key>WFText</key>
@@ -751,6 +753,8 @@
751
753
  <string>is.workflow.actions.speaktext</string>
752
754
  <key>WFWorkflowActionParameters</key>
753
755
  <dict>
756
+ <key>WFSpeakTextRate</key>
757
+ <real>0.6</real>
754
758
  <key>WFSpeakTextWait</key>
755
759
  <true/>
756
760
  <key>WFText</key>
@@ -243,7 +243,9 @@ def warm_in_background() -> threading.Thread:
243
243
 
244
244
 
245
245
  def fetch_model(say: Callable[[str], None] = print) -> bool:
246
- """Download the model ahead of the first sentence (setup's step). Shows progress.
246
+ """Download the model ahead of the first sentence (setup's step).
247
+
248
+ Silent when it is already on this Mac; a download shows its progress.
247
249
 
248
250
  Returns whether it is ready. Skipped, with a reason, where it cannot run.
249
251
  """
@@ -255,7 +257,18 @@ def fetch_model(say: Callable[[str], None] = print) -> bool:
255
257
  except ImportError:
256
258
  say("The speech model's downloader is missing; reinstall ChartRemotely, then run setup.")
257
259
  return False
258
- say(f"Fetching the speech model ({MODEL_SIZE}, once; later runs skip this)…")
260
+ from huggingface_hub.utils import disable_progress_bars, enable_progress_bars
261
+
262
+ disable_progress_bars()
263
+ try:
264
+ snapshot_download(repo_id=MODEL, local_files_only=True)
265
+ cached = True
266
+ except Exception: # noqa: BLE001 - not cached yet: fetch it, with progress
267
+ cached = False
268
+ finally:
269
+ enable_progress_bars()
270
+ if cached:
271
+ return True
272
+ say(f"Fetching the speech model ({MODEL_SIZE}, once)…")
259
273
  snapshot_download(repo_id=MODEL)
260
- say("The speech model is on this Mac.")
261
274
  return True
@@ -105,7 +105,10 @@ def probe_permissions(request: bool, out: Path, timeout: float = 30.0) -> dict:
105
105
  args = [executable(), "permissions", "--out", str(out)] + (["--request"] if request else [])
106
106
  path.write_text(render(PROBE_LABEL, args, keep_alive=False, log=LOG_DIR / "chartremotely-permissions.log"))
107
107
  subprocess.run(["launchctl", "bootout", f"{_domain()}/{PROBE_LABEL}"], capture_output=True, check=False)
108
- subprocess.run(["launchctl", "bootstrap", _domain(), str(path)], check=True)
108
+ ran = subprocess.run(["launchctl", "bootstrap", _domain(), str(path)],
109
+ capture_output=True, text=True, check=False)
110
+ if ran.returncode != 0:
111
+ raise ServiceError(f"launchd would not run the permission check: {(ran.stderr or ran.stdout).strip()[:200]}")
109
112
  try:
110
113
  deadline = time.time() + timeout
111
114
  while time.time() < deadline and not out.exists():
@@ -1,24 +1,25 @@
1
1
  """``chartremotely setup``: from a fresh Mac to a paired, voice-driven display.
2
2
 
3
- Each step checks first and skips what is already done, so running setup
4
- again is always safe:
3
+ It first shows what is installing - version, where it came from (PyPI and
4
+ its release, or a local checkout), the Python it runs on - and the steps
5
+ ahead, and asks before changing anything. Each step checks first and skips
6
+ what is already done, so running setup again is always safe:
5
7
 
6
8
  1. **Identity.** Either the patron's existing npub, proven by the Nostr DM
7
9
  challenge (setup never sees their nsec), or a key made here for them.
8
10
  A made key is kept for the human - their Keychain, then Safari's saved
9
11
  passwords - and used once, in memory, to sign the pairing. It is never
10
- written to this agent's config.
11
- 2. **Pairing**, without a code to copy: setup adopts this machine's code itself.
12
- **Voice sign-in**: the ``dpop_token`` from the owner's DM proof, kept in
12
+ written to this agent's config. Then **pairing**, without a code to copy: setup adopts this machine's code itself.
13
+ and **voice sign-in**: the ``dpop_token`` from the owner's DM proof, kept in
13
14
  the login Keychain (their npub goes in the config), so the listener can
14
15
  call the operator's priced tools as that patron.
15
- 3. **Tailscale**: the Mac's tailnet address, with ``/chart`` forwarded to the agent.
16
- 4. **Services**: the listener and the relay, under launchd.
17
- 5. **Permissions**: Accessibility and Screen Recording, asked for by the
16
+ 2. **Tailscale**: the Mac's tailnet address, with ``/chart`` forwarded to the agent.
17
+ 3. **Services**: the listener and the relay, under launchd.
18
+ 4. **Permissions**: Accessibility and Screen Recording, asked for by the
18
19
  services' own Python, since that is the process macOS grants them to.
19
- 6. **Hearing**: the Whisper speech model, fetched once (about 1.6 GB), and
20
+ 5. **Hearing**: the Whisper speech model, fetched once (about 1.6 GB), and
20
21
  the owner's display names, so a sentence can say where.
21
- 7. **The Shortcut**: this Mac's "ChartRemotely" - talk, or type when it
22
+ 6. **The Shortcut**: this Mac's "ChartRemotely" - talk, or type when it
22
23
  asks - opened for import. A "ChartRemotely Ask" left by an older setup is
23
24
  removed from the library (by "Shortcuts Events"; named for deleting by
24
25
  hand if that fails).
@@ -26,12 +27,15 @@ again is always safe:
26
27
 
27
28
  from __future__ import annotations
28
29
 
30
+ import json
29
31
  import subprocess
32
+ import sys
30
33
  import tempfile
31
34
  from collections.abc import Callable
32
35
  from pathlib import Path
33
36
 
34
37
  from . import config, mcpclient, relay
38
+ from .terminal import Terminal, wants_color
35
39
 
36
40
  OPERATOR_URL = "https://chartremotely-mcp.fastmcp.app"
37
41
  SITE = "https://chartremotely.tollbooth-dpyc.com"
@@ -60,9 +64,9 @@ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
60
64
  raise SetupError(sent.get("error") or "the operator did not send a proof request")
61
65
  say(f"A Nostr DM is on its way to {npub[:12]}…")
62
66
  say(f"Its confirmation code is: {phrase}")
63
- say("Reply to the DM from your Nostr client only if the codes match.")
64
- say("In your reply you may set cache_duration - e.g. 30 days, or unlimited - so voice")
65
- say("keeps working that long; left alone, the sign-in lasts 2 hours.")
67
+ say("Reply to it from your Nostr client only if the codes match. In the reply you")
68
+ say("may set cache_duration (30 days, or unlimited) so voice keeps working that")
69
+ say("long; left alone, the sign-in lasts 2 hours.")
66
70
  ask("Press Return once you have replied… ")
67
71
  got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
68
72
  if got.get("error"):
@@ -108,7 +112,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
108
112
  Returns (npub, proof for chart_pair_agent, whether that proof is a DM
109
113
  sign-in the listener can keep using).
110
114
  """
111
- say("Your Nostr identity owns this display.")
115
+ say("Your Nostr identity owns this display:")
112
116
  say(" 1 I have an npub (I'll answer a Nostr DM to prove it)")
113
117
  say(" 2 Use a key saved on this Mac")
114
118
  say(" 3 Make me a new key")
@@ -184,90 +188,151 @@ def pair(client: mcpclient.Client, base: str, npub: str, proof: str, label: str)
184
188
  return relay.collect(base, code, expires_in)
185
189
 
186
190
 
191
+ # -- what is being installed -------------------------------------------------
192
+
193
+ PLAN = [
194
+ ("Identity", "prove who owns this display, by Nostr DM or a key made here"),
195
+ ("Tailscale", "reach this Mac's listener at its tailnet address, /chart"),
196
+ ("Services", "run the listener and the relay under launchd, from login"),
197
+ ("Permissions", "allow Accessibility and Screen Recording"),
198
+ ("Hearing", "fetch the Whisper speech model (about 1.6 GB, once)"),
199
+ ("Shortcut", "make “ChartRemotely” and open it for you to add"),
200
+ ]
201
+
202
+
203
+ def provenance() -> list[tuple[str, str]]:
204
+ """What is running setup, and where it came from: version, source, release, runtime."""
205
+ from importlib import metadata
206
+
207
+ dist = metadata.distribution("chartremotely")
208
+ version = dist.version
209
+ urls = dict(u.split(", ", 1) for u in dist.metadata.get_all("Project-URL") or [] if ", " in u)
210
+ direct = json.loads(dist.read_text("direct_url.json") or "null")
211
+ running = Path(__file__).resolve().parent
212
+ rows = [("Version", version)]
213
+ if "site-packages" not in running.parts:
214
+ # Imported straight from a source tree: its metadata may be a stale
215
+ # egg-info beside it, so claim nothing about PyPI.
216
+ rows = [("Version", f"{version} (source; releases stamp their own)"),
217
+ ("From", f"source checkout {running.parent}")]
218
+ elif direct is None:
219
+ rows.append(("From", f"PyPI https://pypi.org/project/{dist.metadata['Name']}/{version}/"))
220
+ if urls.get("Repository"):
221
+ rows.append(("Release", f"{urls['Repository']}/releases/tag/v{version}"))
222
+ else:
223
+ where = direct.get("url", "").removeprefix("file://")
224
+ commit = (direct.get("vcs_info") or {}).get("commit_id")
225
+ editable = (direct.get("dir_info") or {}).get("editable")
226
+ rows.append(("From", f"{where}{'@' + commit[:12] if commit else ''}{' (editable)' if editable else ''}"))
227
+ rows.append(("Python", f"{sys.executable} ({sys.version.split()[0]})"))
228
+ home = str(Path.home())
229
+ return [(label, value.replace(home, "~")) for label, value in rows]
230
+
231
+
187
232
  # -- the whole run ------------------------------------------------------------
188
233
 
189
234
  def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) -> int:
235
+ out = Terminal(write=say, read=ask, color=say is print and wants_color())
236
+ from importlib import metadata
237
+
238
+ out.title("ChartRemotely setup", metadata.distribution("chartremotely").metadata["Summary"])
239
+ out.facts(provenance())
240
+ out.plan("Setup will, skipping anything already done:", PLAN)
241
+ out.write("")
242
+ if not out.confirm("Continue?"):
243
+ out.say("Nothing was changed.")
244
+ return 0
245
+ total = len(PLAN)
246
+
190
247
  cfg = config.load()
191
248
  base = (cfg.get("operator_url") or operator_url).rstrip("/")
192
249
  token = config.ensure_token()
193
250
 
194
- # 1-2. Identity and pairing, unless this Mac is already paired.
251
+ # 1. Identity and pairing, unless this Mac is already paired.
252
+ out.step(1, total, "Identity")
195
253
  client = mcpclient.Client(base)
196
254
  if cfg.get("agent_id") and cfg.get("agent_secret"):
197
- say(f"This Mac is already paired ({cfg['agent_id']}).")
198
- npub = cfg.get("owner_npub") or ask("Your npub (whose display this is): ").strip()
255
+ out.ok(f"This Mac is already paired ({cfg['agent_id']}).")
256
+ npub = cfg.get("owner_npub") or out.ask("Your npub (whose display this is): ").strip()
199
257
  if not npub.startswith("npub1"):
200
258
  raise SetupError("that is not an npub")
201
259
  if has_sign_in(npub):
202
260
  config.update(owner_npub=npub)
203
- say("Voice is already signed in.")
261
+ out.ok("Voice is already signed in.")
204
262
  else:
205
- voice_sign_in(client, npub, ask, say)
263
+ voice_sign_in(client, npub, out.ask, out.say)
206
264
  else:
207
- npub, proof, by_dm = identity(client, ask, say)
208
- label = ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
265
+ npub, proof, by_dm = identity(client, out.ask, out.say)
266
+ label = out.ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
209
267
  pair(client, base, npub, proof, label)
210
- say(f"Paired as “{label}”.")
268
+ out.ok(f"Paired as “{label}”.")
211
269
  if by_dm:
212
270
  keep_sign_in(npub, proof)
213
- say("Voice is signed in with the same DM.")
271
+ out.ok("Voice is signed in with the same DM.")
214
272
  else:
215
- voice_sign_in(client, npub, ask, say)
273
+ voice_sign_in(client, npub, out.ask, out.say)
216
274
  del proof
217
275
 
218
- # 3. Tailscale.
276
+ # 2. Tailscale.
219
277
  from . import tailnet
220
278
 
279
+ out.step(2, total, "Tailscale")
221
280
  url = tailnet.chart_url(tailnet.status())
222
281
  tailnet.serve(int(cfg.get("port") or 8899))
223
- say(f"Your Shortcut will reach this Mac at {url}")
282
+ out.ok(f"The Shortcut reaches this Mac at {url}")
224
283
 
225
- # 4. Services.
284
+ # 3. Services.
226
285
  from . import services
227
286
 
287
+ out.step(3, total, "Services")
228
288
  for command in ("serve", "relay"):
229
289
  services.install(command)
230
- say("The listener and the relay are running, and start with this Mac.")
290
+ out.ok("The listener and the relay are running, and start with this Mac.")
231
291
 
232
- # 5. Permissions, as the services' own Python sees them.
292
+ # 4. Permissions, as the services' own Python sees them.
293
+ out.step(4, total, "Permissions")
233
294
  probe = Path(tempfile.gettempdir()) / "chartremotely-permissions.json"
234
295
  granted = services.probe_permissions(request=True, out=probe)
235
296
  while not (granted.get("accessibility") and granted.get("screen_recording")):
236
297
  python = granted.get("python", "the Python that runs ChartRemotely")
237
298
  missing = [name for name, key in (("Accessibility", "accessibility"),
238
299
  ("Screen Recording", "screen_recording")) if not granted.get(key)]
239
- say(f"macOS needs you to allow {' and '.join(missing)} for: {python}")
300
+ out.warn(f"macOS needs you to allow {' and '.join(missing)} for:")
301
+ out.say(python)
240
302
  pane = "Privacy_Accessibility" if not granted.get("accessibility") else "Privacy_ScreenCapture"
241
303
  subprocess.run(["open", f"x-apple.systempreferences:com.apple.preference.security?{pane}"], check=False)
242
- ask("Turn it on in System Settings, then press Return… ")
304
+ out.ask("Turn it on in System Settings, then press Return… ")
243
305
  granted = services.probe_permissions(request=False, out=probe)
244
- say("Accessibility and Screen Recording are allowed.")
306
+ out.ok("Accessibility and Screen Recording are allowed.")
245
307
 
246
- # 6. Hearing: the speech model, and the display names a sentence may say.
308
+ # 5. Hearing: the speech model, and the display names a sentence may say.
247
309
  from . import displays, hearing
248
310
 
311
+ out.step(5, total, "Hearing")
249
312
  try:
250
- hearing.fetch_model(say)
313
+ if hearing.fetch_model(out.say):
314
+ out.ok("The speech model is on this Mac.")
251
315
  except Exception as exc: # noqa: BLE001 - the typing box works without it
252
- say(f"The speech model could not be fetched ({type(exc).__name__}); run setup again later.")
316
+ out.warn(f"The speech model could not be fetched ({type(exc).__name__}); run setup again later.")
253
317
  if displays.refresh() is not None:
254
- say(f"Displays you can name: {', '.join(displays.load()) or 'none yet'}.")
318
+ out.ok(f"Displays you can name: {', '.join(displays.load()) or 'none yet'}.")
255
319
 
256
- # 7. The Shortcut.
320
+ # 6. The Shortcut.
257
321
  from . import shortcut
258
322
 
323
+ out.step(6, total, "Shortcut")
259
324
  subprocess.run(["open", str(shortcut.build(url, token))], check=False)
260
- say("Add the Shortcut when it opens; iCloud brings it to your iPhone, iPad and Watch.")
325
+ out.ok("“ChartRemotely” is open: add it (or Replace). iCloud brings it to your iPhone, iPad and Watch.")
261
326
  removed, left = shortcut.remove_retired()
262
327
  if removed:
263
- say(f"Removed {', '.join(f'“{n}”' for n in removed)}; “ChartRemotely” does it all now.")
328
+ out.ok(f"Removed {', '.join(f'“{n}”' for n in removed)}; “ChartRemotely” does it all now.")
264
329
  if left:
265
- say(f"Delete {', '.join(f'“{n}”' for n in left)} in the Shortcuts app; “ChartRemotely” does it all now.")
266
-
267
- say("")
268
- say("Done. Say “Hey Siri, ChartRemotely”, then one sentence: “Palantir, half, on mac mini”.")
269
- say("No microphone, or it didn't hear you? It shows a box: type the same sentence.")
270
- if npub:
271
- say(f"Your npub: {npub}")
272
- say(f"Sign in at {SITE} to see your screens.")
330
+ out.warn(f"Delete {', '.join(f'“{n}”' for n in left)} in the Shortcuts app; “ChartRemotely” does it all now.")
331
+
332
+ out.write("")
333
+ out.write(out.bold("Ready."))
334
+ out.say("Say “Hey Siri, ChartRemotely”, then one sentence: “Palantir, half, on mac mini”.")
335
+ out.say("No microphone, or it didn't hear you? It shows a box: type the same sentence.")
336
+ out.facts([("Your npub", npub), ("Your screens", SITE)])
337
+ out.write("")
273
338
  return 0
@@ -32,6 +32,9 @@ NAME = "ChartRemotely"
32
32
  #: The Shortcut older setups made, removed from the library by setup. The
33
33
  #: ``shortcuts`` command can list but not delete; "Shortcuts Events" can.
34
34
  RETIRED = "ChartRemotely Ask"
35
+ #: Speak Text's rate (0 to 1; Siri's own is 0.5). Replies are short and
36
+ #: expected, so a person follows them brisker than Siri's default.
37
+ SPEAK_RATE = 0.6
35
38
 
36
39
 
37
40
  def template() -> bytes:
@@ -0,0 +1,75 @@
1
+ """How ``chartremotely setup`` looks: a header, numbered steps, quiet detail.
2
+
3
+ Colour only on a terminal that wants it (not piped, no ``NO_COLOR``, not
4
+ ``TERM=dumb``); everywhere else the same lines print plain, so tests read
5
+ exactly what a person reads.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from collections.abc import Callable
13
+
14
+ INDENT = " "
15
+
16
+
17
+ def wants_color(stream=sys.stdout) -> bool:
18
+ return (hasattr(stream, "isatty") and stream.isatty()
19
+ and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb")
20
+
21
+
22
+ class Terminal:
23
+ """Lines for a person: :meth:`step` headings, :meth:`ok` and :meth:`warn` marks,
24
+ :meth:`say` detail and :meth:`ask` prompts, all indented under their step."""
25
+
26
+ def __init__(self, write: Callable[[str], None] = print, read: Callable[[str], str] = input,
27
+ color: bool = False):
28
+ self.write, self.read, self.color = write, read, color
29
+
30
+ def _paint(self, code: str, text: str) -> str:
31
+ return f"\033[{code}m{text}\033[0m" if self.color else text
32
+
33
+ def bold(self, text: str) -> str:
34
+ return self._paint("1", text)
35
+
36
+ def dim(self, text: str) -> str:
37
+ return self._paint("2", text)
38
+
39
+ def title(self, name: str, summary: str) -> None:
40
+ self.write("")
41
+ self.write(self.bold(name))
42
+ self.write(self.dim(summary))
43
+
44
+ def facts(self, rows: list[tuple[str, str]]) -> None:
45
+ """Label/value rows, labels aligned."""
46
+ width = max(len(label) for label, _ in rows)
47
+ self.write("")
48
+ for label, value in rows:
49
+ self.write(f" {self.dim(label.ljust(width))} {value}")
50
+
51
+ def plan(self, heading: str, steps: list[tuple[str, str]]) -> None:
52
+ width = max(len(name) for name, _ in steps)
53
+ self.write("")
54
+ self.write(heading)
55
+ for i, (name, what) in enumerate(steps, 1):
56
+ self.write(f" {self._paint('36', str(i))} {self.bold(name.ljust(width))} {what}")
57
+
58
+ def step(self, number: int, total: int, name: str) -> None:
59
+ self.write("")
60
+ self.write(f"{self._paint('36', f'[{number}/{total}]')} {self.bold(name)}")
61
+
62
+ def say(self, text: str) -> None:
63
+ self.write(f"{INDENT}{text}" if text else "")
64
+
65
+ def ok(self, text: str) -> None:
66
+ self.write(f" {self._paint('32', '✓')} {text}")
67
+
68
+ def warn(self, text: str) -> None:
69
+ self.write(f" {self._paint('33', '!')} {text}")
70
+
71
+ def ask(self, prompt: str) -> str:
72
+ return self.read(f" {self._paint('36', '›')} {prompt}")
73
+
74
+ def confirm(self, prompt: str) -> bool:
75
+ return not self.ask(f"{prompt} [Y/n] ").strip().lower().startswith("n")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.15
3
+ Version: 0.2.17
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -206,6 +206,8 @@ License:
206
206
  See the License for the specific language governing permissions and
207
207
  limitations under the License.
208
208
 
209
+ Project-URL: Repository, https://github.com/lonniev/ChartRemotely-agent
210
+ Project-URL: Changelog, https://github.com/lonniev/ChartRemotely-agent/blob/main/CHANGELOG.md
209
211
  Keywords: thinkorswim,accessibility,automation,charting
210
212
  Requires-Python: >=3.12
211
213
  Description-Content-Type: text/markdown
@@ -31,6 +31,7 @@ chartremotely/snapshot.py
31
31
  chartremotely/studies.py
32
32
  chartremotely/symbol.py
33
33
  chartremotely/tailnet.py
34
+ chartremotely/terminal.py
34
35
  chartremotely/timeframe.py
35
36
  chartremotely/understand.py
36
37
  chartremotely/vocab.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.15"
3
+ version = "0.2.17"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -28,6 +28,10 @@ macos = [
28
28
  setup = ["tollbooth-dpyc[nostr]==0.90.0"]
29
29
  dev = ["pytest>=8", "ruff>=0.6"]
30
30
 
31
+ [project.urls]
32
+ Repository = "https://github.com/lonniev/ChartRemotely-agent"
33
+ Changelog = "https://github.com/lonniev/ChartRemotely-agent/blob/main/CHANGELOG.md"
34
+
31
35
  [project.scripts]
32
36
  chartremotely = "chartremotely.cli:main"
33
37
 
@@ -246,6 +246,9 @@ def test_the_shortcut_records_posts_branches_on_the_marker_asks_one_line_posts_t
246
246
  strip = actions[11]["WFWorkflowActionParameters"]
247
247
  assert (strip["WFReplaceTextFind"], strip["WFReplaceTextReplace"]) == (voice.TYPE, "")
248
248
 
249
+ speaks = _params(actions, "speaktext")
250
+ assert [s["WFSpeakTextRate"] for s in speaks] == [shortcut.SPEAK_RATE] * 2, "brisk, not slow"
251
+
249
252
  asks = _params(actions, "ask")
250
253
  assert len(asks) == 1, "one text box"
251
254
  ask = asks[0]
@@ -264,7 +264,7 @@ def test_a_made_key_never_reaches_the_agents_config(monkeypatch, tmp_path):
264
264
  _no_hearing(monkeypatch)
265
265
  monkeypatch.setattr(setup.subprocess, "run", lambda *a, **k: None)
266
266
 
267
- answers = iter(["3", "Desk", "n"])
267
+ answers = iter(["", "3", "Desk", "n"])
268
268
  said = []
269
269
  assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
270
270
 
@@ -311,7 +311,7 @@ def test_a_dm_proven_owner_keeps_the_sign_in_in_the_keychain_not_the_config(monk
311
311
  "chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
312
312
  "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"},
313
313
  "chart_pair_agent": {"ok": True, "display": "Desk", "agent_id": "a1"}})
314
- answers = iter(["1", NPUB, "", "Desk"])
314
+ answers = iter(["", "1", NPUB, "", "Desk"])
315
315
  said = []
316
316
  assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
317
317
  assert vault == {NPUB: "seven-amber-door"}
@@ -325,7 +325,7 @@ def test_an_already_paired_mac_is_signed_in_for_voice_by_dm(monkeypatch, tmp_pat
325
325
  "chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
326
326
  "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
327
327
  config.update(operator_url="https://op.test", agent_id="a1", agent_secret="agent-secret")
328
- answers = iter([NPUB, "y", ""])
328
+ answers = iter(["", NPUB, "y", ""])
329
329
  assert setup.run(ask=lambda q: next(answers), say=lambda s: None) == 0
330
330
  assert vault == {NPUB: "seven-amber-door"}
331
331
  assert "seven-amber-door" not in (tmp_path / "config.json").read_text()
@@ -337,11 +337,55 @@ def test_setup_removes_a_retired_ask_shortcut_and_names_any_it_could_not(monkeyp
337
337
  "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
338
338
  config.update(operator_url="https://op.test", agent_id="a1", agent_secret="agent-secret")
339
339
  monkeypatch.setattr(shortcut, "remove_retired", lambda: (["ChartRemotely Ask"], ["ChartRemotely Ask 2"]))
340
- answers = iter([NPUB, "y", ""])
340
+ answers = iter(["", NPUB, "y", ""])
341
341
  said = []
342
342
  assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
343
- assert "Removed “ChartRemotely Ask”; “ChartRemotely” does it all now." in said
344
- assert "Delete “ChartRemotely Ask 2” in the Shortcuts app; “ChartRemotely” does it all now." in said
343
+ assert any(s.endswith("Removed “ChartRemotely Ask”; “ChartRemotely” does it all now.") for s in said)
344
+ assert any(s.endswith("Delete “ChartRemotely Ask 2” in the Shortcuts app; “ChartRemotely” does it all now.")
345
+ for s in said)
346
+
347
+
348
+ def test_setup_says_what_it_installs_and_changes_nothing_when_declined(monkeypatch, tmp_path):
349
+ touched = []
350
+ monkeypatch.setattr(config, "load", lambda: touched.append("config") or {})
351
+ said = []
352
+ assert setup.run(ask=lambda q: "n", say=said.append) == 0
353
+ text = "\n".join(said)
354
+ assert "ChartRemotely setup" in text and "Version" in text and "Python" in text
355
+ assert all(name in text for name, _ in setup.PLAN)
356
+ assert said[-1].strip() == "Nothing was changed." and touched == []
357
+ assert not any("\033[" in s for s in said), "no colour codes when not printing to a terminal"
358
+
359
+
360
+ def test_provenance_names_pypi_and_its_release_or_the_local_checkout(monkeypatch):
361
+ from importlib import metadata
362
+
363
+ class Dist:
364
+ version = "9.9.9"
365
+
366
+ def __init__(self, direct):
367
+ self.direct = direct
368
+ self.metadata = type("M", (), {"__getitem__": lambda s, k: "chartremotely",
369
+ "get_all": lambda s, k: ["Repository, https://example.test/repo"]})()
370
+
371
+ def read_text(self, name):
372
+ return self.direct
373
+
374
+ monkeypatch.setattr(metadata, "distribution", lambda name: Dist(None))
375
+ monkeypatch.setattr(setup, "__file__", "/venv/lib/python3.12/site-packages/chartremotely/setup.py")
376
+ rows = dict(setup.provenance())
377
+ assert rows["Version"] == "9.9.9"
378
+ assert rows["From"] == "PyPI https://pypi.org/project/chartremotely/9.9.9/"
379
+ assert rows["Release"] == "https://example.test/repo/releases/tag/v9.9.9"
380
+
381
+ monkeypatch.setattr(metadata, "distribution", lambda name: Dist(json.dumps(
382
+ {"url": "file:///src/agent", "dir_info": {"editable": True}})))
383
+ rows = dict(setup.provenance())
384
+ assert rows["From"] == "/src/agent (editable)" and "Release" not in rows
385
+
386
+ monkeypatch.setattr(setup, "__file__", "/src/agent/chartremotely/setup.py")
387
+ rows = dict(setup.provenance())
388
+ assert rows["From"] == "source checkout /src/agent", "a source tree never claims PyPI"
345
389
 
346
390
 
347
391
  class Library:
File without changes
File without changes
File without changes