alexa-sync 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Owen Williams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: alexa-sync
3
+ Version: 0.1.0
4
+ Summary: Two-way sync between the Alexa shopping list and Apple Reminders
5
+ Keywords: alexa,amazon,shopping list,apple reminders,macos,sync
6
+ Author: Owen Williams
7
+ Author-email: Owen Williams <owen@willia.ms>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Environment :: MacOS X
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Utilities
16
+ Requires-Dist: aioamazondevices==16.3.0
17
+ Requires-Dist: keyring>=25.7.0
18
+ Requires-Python: >=3.13
19
+ Project-URL: Homepage, https://github.com/ow/Alexa-apple-reminders
20
+ Project-URL: Issues, https://github.com/ow/Alexa-apple-reminders/issues
21
+ Description-Content-Type: text/markdown
22
+
23
+ # alexa-sync
24
+
25
+ Two-way sync between your Alexa shopping list and a list in Apple Reminders, on macOS.
26
+
27
+ Say "Alexa, add oat milk to my shopping list" and it shows up in Reminders a minute or two later. Tick it off in Reminders at the store and it's ticked off on Alexa too.
28
+
29
+ > **Unofficial.** Amazon has no public API for the shopping list anymore, so this uses the same private endpoints as the Alexa app. It can break whenever Amazon changes them. Use at your own risk.
30
+
31
+ ## Requirements
32
+
33
+ - macOS 14+
34
+ - [uv](https://docs.astral.sh/uv/) (or pipx)
35
+ - [`remindctl`](https://github.com/openclaw/remindctl): `brew install steipete/tap/remindctl`, then `remindctl authorize`
36
+ - An Amazon account with **2-Step Verification using an authenticator app** (the login flow needs a code, not an SMS or push approval)
37
+
38
+ ## Setup
39
+
40
+ ```sh
41
+ uv tool install git+https://github.com/ow/Alexa-apple-reminders
42
+
43
+ alexa-sync login # email, password, authenticator code (one time)
44
+ alexa-sync doctor --list Groceries # check everything is ready
45
+ alexa-sync run --list Groceries --dry-run # preview the first sync
46
+ alexa-sync install --list Groceries # sync every 2 minutes in the background
47
+ ```
48
+
49
+ The Reminders list must already exist. `--list` defaults to `Groceries`.
50
+
51
+ Other commands: `alexa-sync run` (sync once), `alexa-sync uninstall` (stop the background job), `install --interval 60` (change how often it runs, in seconds).
52
+
53
+ ## How it works
54
+
55
+ **Amazon login.** `login` registers your Mac as a device on your Amazon account, the same way the Alexa phone app does (via [aioamazondevices](https://github.com/chemelli74/aioamazondevices)). That returns a long-lived token, which is kept in your macOS Keychain. Your password and 2FA code are not stored. The token is used to get fresh session cookies whenever needed. If it ever stops working you'll get a macOS notification asking you to run `alexa-sync login` again. To revoke it, remove the device under *Manage Your Content and Devices* on Amazon.
56
+
57
+ **Sync.** Each run reads both lists and compares them with what they looked like after the last sync, so it can tell which side changed:
58
+
59
+ - Adds, check-offs, un-checks, renames and deletes go both ways.
60
+ - If the same item changed on both sides between syncs, Alexa wins.
61
+ - Items that are already completed when first seen aren't copied. This keeps years of old Alexa history out of Reminders.
62
+ - On the first sync, items with the same name on both sides are paired rather than duplicated.
63
+ - If a single pass would delete more than 5 items it stops and notifies you instead (override with `--max-deletes`).
64
+
65
+ **Files.**
66
+
67
+ | What | Where |
68
+ | --- | --- |
69
+ | Amazon device token | Keychain, service `alexa-sync` |
70
+ | Sync state | `~/Library/Application Support/alexa-sync/state.json` |
71
+ | Log | `~/Library/Logs/alexa-sync.log` |
72
+ | Background job | `~/Library/LaunchAgents/io.github.alexa-sync.plist` |
73
+
74
+ Only the default Alexa shopping list is synced. It has been tested with amazon.com; other Amazon regions should work, since the login library supports them, but are untested.
75
+
76
+ ## Development
77
+
78
+ ```sh
79
+ uv run pytest
80
+ uv run alexa-sync run --dry-run -v
81
+ ```
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,63 @@
1
+ # alexa-sync
2
+
3
+ Two-way sync between your Alexa shopping list and a list in Apple Reminders, on macOS.
4
+
5
+ Say "Alexa, add oat milk to my shopping list" and it shows up in Reminders a minute or two later. Tick it off in Reminders at the store and it's ticked off on Alexa too.
6
+
7
+ > **Unofficial.** Amazon has no public API for the shopping list anymore, so this uses the same private endpoints as the Alexa app. It can break whenever Amazon changes them. Use at your own risk.
8
+
9
+ ## Requirements
10
+
11
+ - macOS 14+
12
+ - [uv](https://docs.astral.sh/uv/) (or pipx)
13
+ - [`remindctl`](https://github.com/openclaw/remindctl): `brew install steipete/tap/remindctl`, then `remindctl authorize`
14
+ - An Amazon account with **2-Step Verification using an authenticator app** (the login flow needs a code, not an SMS or push approval)
15
+
16
+ ## Setup
17
+
18
+ ```sh
19
+ uv tool install git+https://github.com/ow/Alexa-apple-reminders
20
+
21
+ alexa-sync login # email, password, authenticator code (one time)
22
+ alexa-sync doctor --list Groceries # check everything is ready
23
+ alexa-sync run --list Groceries --dry-run # preview the first sync
24
+ alexa-sync install --list Groceries # sync every 2 minutes in the background
25
+ ```
26
+
27
+ The Reminders list must already exist. `--list` defaults to `Groceries`.
28
+
29
+ Other commands: `alexa-sync run` (sync once), `alexa-sync uninstall` (stop the background job), `install --interval 60` (change how often it runs, in seconds).
30
+
31
+ ## How it works
32
+
33
+ **Amazon login.** `login` registers your Mac as a device on your Amazon account, the same way the Alexa phone app does (via [aioamazondevices](https://github.com/chemelli74/aioamazondevices)). That returns a long-lived token, which is kept in your macOS Keychain. Your password and 2FA code are not stored. The token is used to get fresh session cookies whenever needed. If it ever stops working you'll get a macOS notification asking you to run `alexa-sync login` again. To revoke it, remove the device under *Manage Your Content and Devices* on Amazon.
34
+
35
+ **Sync.** Each run reads both lists and compares them with what they looked like after the last sync, so it can tell which side changed:
36
+
37
+ - Adds, check-offs, un-checks, renames and deletes go both ways.
38
+ - If the same item changed on both sides between syncs, Alexa wins.
39
+ - Items that are already completed when first seen aren't copied. This keeps years of old Alexa history out of Reminders.
40
+ - On the first sync, items with the same name on both sides are paired rather than duplicated.
41
+ - If a single pass would delete more than 5 items it stops and notifies you instead (override with `--max-deletes`).
42
+
43
+ **Files.**
44
+
45
+ | What | Where |
46
+ | --- | --- |
47
+ | Amazon device token | Keychain, service `alexa-sync` |
48
+ | Sync state | `~/Library/Application Support/alexa-sync/state.json` |
49
+ | Log | `~/Library/Logs/alexa-sync.log` |
50
+ | Background job | `~/Library/LaunchAgents/io.github.alexa-sync.plist` |
51
+
52
+ Only the default Alexa shopping list is synced. It has been tested with amazon.com; other Amazon regions should work, since the login library supports them, but are untested.
53
+
54
+ ## Development
55
+
56
+ ```sh
57
+ uv run pytest
58
+ uv run alexa-sync run --dry-run -v
59
+ ```
60
+
61
+ ## License
62
+
63
+ MIT
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "alexa-sync"
3
+ version = "0.1.0"
4
+ description = "Two-way sync between the Alexa shopping list and Apple Reminders"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "alexa",
11
+ "amazon",
12
+ "shopping list",
13
+ "apple reminders",
14
+ "macos",
15
+ "sync",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "Environment :: MacOS X",
21
+ "Operating System :: MacOS",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = [
26
+ "aioamazondevices==16.3.0",
27
+ "keyring>=25.7.0",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "Owen Williams"
32
+ email = "owen@willia.ms"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/ow/Alexa-apple-reminders"
36
+ Issues = "https://github.com/ow/Alexa-apple-reminders/issues"
37
+
38
+ [project.scripts]
39
+ alexa-sync = "alexa_sync.cli:main"
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.10.3,<0.11.0"]
43
+ build-backend = "uv_build"
44
+
45
+ [dependency-groups]
46
+ dev = ["pytest>=9.1.1"]
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "alexa-sync"
3
+ version = "0.1.0"
4
+ description = "Two-way sync between the Alexa shopping list and Apple Reminders"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Owen Williams", email = "owen@willia.ms" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["alexa", "amazon", "shopping list", "apple reminders", "macos", "sync"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Environment :: MacOS X",
17
+ "Operating System :: MacOS",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Utilities",
20
+ ]
21
+ dependencies = [
22
+ "aioamazondevices==16.3.0",
23
+ "keyring>=25.7.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/ow/Alexa-apple-reminders"
28
+ Issues = "https://github.com/ow/Alexa-apple-reminders/issues"
29
+
30
+ [project.scripts]
31
+ alexa-sync = "alexa_sync.cli:main"
32
+
33
+ [build-system]
34
+ requires = ["uv_build>=0.10.3,<0.11.0"]
35
+ build-backend = "uv_build"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.1.1",
40
+ ]
@@ -0,0 +1,3 @@
1
+ """Two-way sync between the Alexa shopping list and Apple Reminders."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,211 @@
1
+ """Alexa shopping list side of the sync.
2
+
3
+ Authentication piggybacks on aioamazondevices (the library behind Home
4
+ Assistant's Alexa integration): it registers a virtual Alexa-app device once,
5
+ which yields a long-lived refresh token. From then on we only ever trade that
6
+ token for fresh website cookies; the password and OTP are never stored.
7
+
8
+ The list endpoints are the same undocumented /alexashoppinglists/api/v2 calls
9
+ the library and pyalexatodo use. We call them ourselves so we can paginate and
10
+ see every field, but go through the library's HTTP wrapper for cookies/CSRF.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from collections.abc import Callable, Coroutine
17
+ from http import HTTPMethod
18
+ from typing import Any
19
+
20
+ from aiohttp import ClientSession
21
+ from aioamazondevices.api import AmazonEchoApi
22
+ from aioamazondevices.exceptions import CannotAuthenticate
23
+ from aioamazondevices.structures import AmazonSaveDataConfig
24
+ from yarl import URL
25
+
26
+ from .sync import Item, normalize
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ LISTS_PATH = "alexashoppinglists/api/v2/lists"
31
+ PAGE_SIZE = 100 # API maximum
32
+
33
+
34
+ class AuthExpired(Exception):
35
+ """The stored device registration no longer works; run `alexa-sync login`."""
36
+
37
+
38
+ def make_api(session: ClientSession, email: str, password: str, login_data: dict | None, data_dir: str) -> AmazonEchoApi:
39
+ return AmazonEchoApi(
40
+ client_session=session,
41
+ login_email=email,
42
+ login_password=password,
43
+ login_data=login_data,
44
+ save_data=AmazonSaveDataConfig(path=data_dir),
45
+ )
46
+
47
+
48
+ class AlexaClient:
49
+ """Async client for the shopping list. Refreshes cookies once on auth failure."""
50
+
51
+ def __init__(self, api: AmazonEchoApi) -> None:
52
+ self.api = api
53
+ self.login_data_changed = False
54
+
55
+ @property
56
+ def login_data(self) -> dict[str, Any]:
57
+ return self.api.login._session_state_data.login_stored_data
58
+
59
+ async def connect(self) -> None:
60
+ await self._with_refresh(self.api.login.login_mode_stored_data)
61
+
62
+ async def _refresh_cookies(self) -> None:
63
+ log.info("session cookies rejected; exchanging refresh token for new ones")
64
+ try:
65
+ await self.api.login._refresh_auth_cookies()
66
+ except Exception as exc:
67
+ raise AuthExpired("could not refresh Amazon cookies") from exc
68
+ self.login_data_changed = True
69
+
70
+ async def _with_refresh(self, fn: Callable[[], Coroutine[Any, Any, Any]]) -> Any:
71
+ try:
72
+ return await fn()
73
+ except (CannotAuthenticate, ValueError) as exc:
74
+ # ValueError: we got HTML (a sign-in page) instead of JSON.
75
+ log.debug("request failed with %r", exc)
76
+ await self._refresh_cookies()
77
+ try:
78
+ return await fn()
79
+ except (CannotAuthenticate, ValueError) as exc:
80
+ raise AuthExpired("Amazon rejected freshly refreshed cookies") from exc
81
+
82
+ async def _call(self, method: HTTPMethod, path: str, query: dict | None = None, body: dict | None = None) -> dict:
83
+ wrapper = self.api._http_wrapper
84
+ url = URL.joinpath(self.api.login._session_state_data.retail_site_url, LISTS_PATH, path)
85
+ if query:
86
+ url = url.with_query(query)
87
+
88
+ async def go() -> dict:
89
+ _, resp = await wrapper.session_request(method=method, url=url, input_data=body or {}, json_data=True)
90
+ return await wrapper.response_to_json(resp, "")
91
+
92
+ return await self._with_refresh(go)
93
+
94
+ async def shopping_list_id(self) -> str:
95
+ data = await self._call(HTTPMethod.POST, "fetch")
96
+ lists = data["listInfoList"]
97
+ shop = [l for l in lists if l.get("listType") == "SHOP"]
98
+ if not shop:
99
+ raise RuntimeError(f"no SHOP list among {[l.get('listType') for l in lists]}")
100
+ return (next((l for l in shop if l.get("defaultList")), shop[0]))["listId"]
101
+
102
+ async def items(self, list_id: str) -> list[dict]:
103
+ out: list[dict] = []
104
+ token = None
105
+ while True:
106
+ data = await self._call(
107
+ HTTPMethod.POST, f"{list_id}/items/fetch", {"limit": PAGE_SIZE}, {"nextToken": token} if token else {}
108
+ )
109
+ out += data.get("itemInfoList", [])
110
+ token = data.get("nextToken")
111
+ if not token:
112
+ return out
113
+
114
+ async def add(self, list_id: str, name: str) -> dict:
115
+ return await self._call(
116
+ HTTPMethod.POST, f"{list_id}/items", body={"items": [{"itemType": "KEYWORD", "itemName": name}]}
117
+ )
118
+
119
+ async def update(self, list_id: str, item_id: str, version: int, attrs: list[dict]) -> dict:
120
+ return await self._call(
121
+ HTTPMethod.PUT,
122
+ f"{list_id}/items/{item_id}",
123
+ {"version": version},
124
+ {"itemAttributesToUpdate": attrs, "itemAttributesToRemove": []},
125
+ )
126
+
127
+ async def delete(self, list_id: str, item_id: str, version: int) -> None:
128
+ await self._call(HTTPMethod.DELETE, f"{list_id}/items/{item_id}", {"version": version})
129
+
130
+
131
+ def _find_item_id(obj: Any, name: str) -> str | None:
132
+ """Dig the new item's id out of an add response, whatever its exact shape."""
133
+ if isinstance(obj, dict):
134
+ if "itemId" in obj and normalize(str(obj.get("itemName", name))) == normalize(name):
135
+ return obj["itemId"]
136
+ obj = list(obj.values())
137
+ if isinstance(obj, list):
138
+ for v in obj:
139
+ if found := _find_item_id(v, name):
140
+ return found
141
+ return None
142
+
143
+
144
+ class AlexaSide:
145
+ """Synchronous adapter over AlexaClient for the sync engine."""
146
+
147
+ label = "Alexa"
148
+
149
+ def __init__(self, client: AlexaClient, run: Callable[[Coroutine], Any], list_id: str) -> None:
150
+ self.client = client
151
+ self.run = run
152
+ self.list_id = list_id
153
+ self._versions: dict[str, int] = {}
154
+
155
+ def fetch(self) -> dict[str, Item]:
156
+ rows = self.run(self.client.items(self.list_id))
157
+ self._versions = {r["itemId"]: r["version"] for r in rows}
158
+ return {r["itemId"]: Item(r["itemId"], r["itemName"], r["itemStatus"] == "COMPLETE") for r in rows}
159
+
160
+ def add(self, name: str, completed: bool) -> str:
161
+ before = set(self._versions)
162
+ resp = self.run(self.client.add(self.list_id, name))
163
+ item_id = _find_item_id(resp, name)
164
+ if item_id is None:
165
+ log.debug("add response had no itemId (%r); re-fetching to find it", resp)
166
+ fresh = self.fetch()
167
+ new = [i for i in fresh.values() if i.id not in before and normalize(i.name) == normalize(name)]
168
+ if not new:
169
+ raise RuntimeError(f"added {name!r} but could not find it afterwards")
170
+ item_id = new[0].id
171
+ if completed:
172
+ self.update(item_id, name=None, completed=True)
173
+ return item_id
174
+
175
+ def _version(self, item_id: str) -> int:
176
+ if item_id not in self._versions:
177
+ self.fetch()
178
+ return self._versions[item_id]
179
+
180
+ def update(self, item_id: str, *, name: str | None, completed: bool | None) -> None:
181
+ attrs = []
182
+ if name is not None:
183
+ attrs.append({"type": "itemName", "value": name})
184
+ if completed is not None:
185
+ attrs.append({"type": "itemStatus", "value": "COMPLETE" if completed else "ACTIVE"})
186
+ self.run(self.client.update(self.list_id, item_id, self._version(item_id), attrs))
187
+ self._versions.pop(item_id, None) # version bumped; re-read if needed again
188
+
189
+ def delete(self, item_id: str) -> None:
190
+ self.run(self.client.delete(self.list_id, item_id, self._version(item_id)))
191
+ self._versions.pop(item_id, None)
192
+
193
+
194
+ class LoginFailed(Exception):
195
+ pass
196
+
197
+
198
+ async def interactive_login(email: str, password: str, otp: str, data_dir: str) -> dict[str, Any]:
199
+ async with ClientSession() as session:
200
+ api = make_api(session, email, password, None, data_dir)
201
+ try:
202
+ return await api.login.login_mode_interactive(otp)
203
+ except CannotAuthenticate as exc:
204
+ if "OTP code not found" in str(exc):
205
+ raise LoginFailed(
206
+ "Amazon didn't ask for a 2-Step Verification code. alexa-sync requires 2SV with an "
207
+ "authenticator app: enable it under Your Account > Login & security, then try again. "
208
+ "(It can also mean the password was wrong or Amazon showed a CAPTCHA.)"
209
+ ) from exc
210
+ raise LoginFailed(f"Amazon rejected the login ({exc}). Check the password and code, then retry.") from exc
211
+
@@ -0,0 +1,289 @@
1
+ """alexa-sync: keep the Alexa shopping list and an Apple Reminders list in sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import fcntl
8
+ import getpass
9
+ import json
10
+ import logging
11
+ import os
12
+ import plistlib
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import keyring
20
+
21
+ from . import __version__, amazon
22
+ from .reminders import RemindersSide, RemindersUnavailable, check_access
23
+ from .sync import Pair, TooManyDeletes, sync
24
+
25
+ log = logging.getLogger("alexa_sync")
26
+
27
+ DATA_DIR = Path.home() / "Library/Application Support/alexa-sync"
28
+ STATE_FILE = DATA_DIR / "state.json"
29
+ LOCK_FILE = DATA_DIR / "run.lock"
30
+ LOG_FILE = Path.home() / "Library/Logs/alexa-sync.log"
31
+ KEYCHAIN_SERVICE = "alexa-sync"
32
+ KEYCHAIN_ACCOUNT = "amazon-device"
33
+ AGENT_LABEL = "io.github.alexa-sync"
34
+ LEGACY_AGENT_LABELS = ["ms.willia.alexa-sync"]
35
+ LAUNCH_AGENTS = Path.home() / "Library/LaunchAgents"
36
+ AGENT_PLIST = LAUNCH_AGENTS / f"{AGENT_LABEL}.plist"
37
+ ALERT_EVERY = 6 * 3600
38
+
39
+
40
+ # --- persistence -----------------------------------------------------------
41
+
42
+
43
+ def load_credentials() -> dict | None:
44
+ raw = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
45
+ return json.loads(raw) if raw else None
46
+
47
+
48
+ def save_credentials(email: str, login_data: dict) -> None:
49
+ keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, json.dumps({"email": email, "login_data": login_data}))
50
+
51
+
52
+ def load_state() -> dict:
53
+ try:
54
+ return json.loads(STATE_FILE.read_text())
55
+ except FileNotFoundError:
56
+ return {}
57
+
58
+
59
+ def save_state(state: dict) -> None:
60
+ tmp = STATE_FILE.with_suffix(".tmp")
61
+ tmp.write_text(json.dumps(state, indent=2))
62
+ tmp.replace(STATE_FILE)
63
+
64
+
65
+ def alert(state: dict, key: str, message: str) -> None:
66
+ """macOS notification, at most once per ALERT_EVERY for the same problem."""
67
+ log.error(message)
68
+ alerts = state.setdefault("alerts", {})
69
+ if time.time() - alerts.get(key, 0) < ALERT_EVERY:
70
+ return
71
+ alerts[key] = time.time()
72
+ script = f"display notification {json.dumps(message)} with title \"Alexa sync\""
73
+ subprocess.run(["osascript", "-e", script], check=False, capture_output=True)
74
+
75
+
76
+ # --- commands --------------------------------------------------------------
77
+
78
+
79
+ def cmd_login(args: argparse.Namespace) -> int:
80
+ email = args.email or input("Amazon email: ").strip()
81
+ password = getpass.getpass("Amazon password: ")
82
+ otp = input("2-Step Verification code from your authenticator app: ").strip()
83
+ try:
84
+ login_data = asyncio.run(amazon.interactive_login(email, password, otp, str(DATA_DIR)))
85
+ except amazon.LoginFailed as exc:
86
+ print(f"Login failed: {exc}", file=sys.stderr)
87
+ return 1
88
+ save_credentials(email, login_data)
89
+ print("Logged in. Device registration saved to Keychain (password and code were not stored).")
90
+ return 0
91
+
92
+
93
+ class DryRun:
94
+ """Wraps a side so writes are logged instead of performed."""
95
+
96
+ def __init__(self, side) -> None:
97
+ self.side = side
98
+ self.label = side.label
99
+ self._n = 0
100
+
101
+ def fetch(self):
102
+ return self.side.fetch()
103
+
104
+ def add(self, name, completed):
105
+ print(f"[dry-run] {self.label}: add {name!r}")
106
+ self._n += 1
107
+ return f"dry-{self._n}"
108
+
109
+ def update(self, item_id, *, name, completed):
110
+ print(f"[dry-run] {self.label}: update {item_id} name={name!r} completed={completed!r}")
111
+
112
+ def delete(self, item_id):
113
+ print(f"[dry-run] {self.label}: delete {item_id}")
114
+
115
+
116
+ def cmd_run(args: argparse.Namespace) -> int:
117
+ with open(LOCK_FILE, "w") as lock:
118
+ try:
119
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
120
+ except BlockingIOError:
121
+ log.info("another run is in progress; skipping")
122
+ return 0
123
+ state = load_state()
124
+ try:
125
+ return _run(args, state)
126
+ finally:
127
+ if not args.dry_run:
128
+ save_state(state)
129
+
130
+
131
+ def _run(args: argparse.Namespace, state: dict) -> int:
132
+ creds = load_credentials()
133
+ if not creds:
134
+ alert(state, "auth", "Not logged in to Amazon. Run: alexa-sync login")
135
+ return 2
136
+
137
+ try:
138
+ check_access()
139
+ reminders = RemindersSide(args.list)
140
+ state["reminders_list_id"] = reminders.resolve(state.get("reminders_list_id"))
141
+ except (RemindersUnavailable, RuntimeError) as exc:
142
+ alert(state, "reminders", str(exc))
143
+ return 2
144
+
145
+ with asyncio.Runner() as runner:
146
+ session = runner.run(_make_session())
147
+ try:
148
+ api = amazon.make_api(session, creds["email"], "", creds["login_data"], str(DATA_DIR))
149
+ client = amazon.AlexaClient(api)
150
+ try:
151
+ runner.run(client.connect())
152
+ list_id = runner.run(client.shopping_list_id())
153
+ alexa = amazon.AlexaSide(client, runner.run, list_id)
154
+
155
+ a_side, r_side = (DryRun(alexa), DryRun(reminders)) if args.dry_run else (alexa, reminders)
156
+ pairs = [Pair.from_dict(p) for p in state.get("pairs", [])]
157
+ pairs = sync(a_side, r_side, pairs, max_deletes=args.max_deletes)
158
+ except amazon.AuthExpired:
159
+ alert(state, "auth", "Amazon login expired. Run: alexa-sync login")
160
+ return 2
161
+ except TooManyDeletes as exc:
162
+ alert(state, "deletes", f"Sync paused: {exc}")
163
+ return 3
164
+ finally:
165
+ if client.login_data_changed and not args.dry_run:
166
+ save_credentials(creds["email"], client.login_data)
167
+ finally:
168
+ runner.run(session.close())
169
+
170
+ if not args.dry_run:
171
+ state["pairs"] = [p.to_dict() for p in pairs]
172
+ state.get("alerts", {}).clear()
173
+ log.info("synced %d paired items", len(pairs))
174
+ return 0
175
+
176
+
177
+ async def _make_session():
178
+ from aiohttp import ClientSession
179
+
180
+ return ClientSession()
181
+
182
+
183
+ def _checks(list_name: str) -> list[tuple[str, str | None]]:
184
+ """(description, problem or None) for everything a background run needs."""
185
+ results: list[tuple[str, str | None]] = []
186
+ try:
187
+ check_access()
188
+ results.append(("remindctl installed with Reminders access", None))
189
+ try:
190
+ RemindersSide(list_name).resolve(load_state().get("reminders_list_id"))
191
+ results.append((f"Reminders list {list_name!r} found", None))
192
+ except RuntimeError as exc:
193
+ results.append((f"Reminders list {list_name!r} found", str(exc)))
194
+ except RemindersUnavailable as exc:
195
+ results.append(("remindctl installed with Reminders access", str(exc)))
196
+ results.append(("logged in to Amazon", None if load_credentials() else "run: alexa-sync login"))
197
+ return results
198
+
199
+
200
+ def cmd_doctor(args: argparse.Namespace) -> int:
201
+ results = _checks(args.list)
202
+ for what, problem in results:
203
+ print(f"{'✗' if problem else '✓'} {what}" + (f": {problem}" if problem else ""))
204
+ print(f"{'✓' if AGENT_PLIST.exists() else '·'} background job {'installed' if AGENT_PLIST.exists() else 'not installed'}")
205
+ return 1 if any(p for _, p in results) else 0
206
+
207
+
208
+ def _bootout_all() -> None:
209
+ uid = os.getuid()
210
+ for label in [AGENT_LABEL, *LEGACY_AGENT_LABELS]:
211
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True)
212
+ (LAUNCH_AGENTS / f"{label}.plist").unlink(missing_ok=True)
213
+
214
+
215
+ def cmd_install(args: argparse.Namespace) -> int:
216
+ problems = [f"{what}: {p}" for what, p in _checks(args.list) if p]
217
+ if problems:
218
+ print("Not installing; fix these first:\n " + "\n ".join(problems), file=sys.stderr)
219
+ return 1
220
+ # Keep the PATH entry (e.g. ~/.local/bin/alexa-sync from `uv tool install`)
221
+ # rather than resolving symlinks, so upgrades don't break the job.
222
+ exe = str(Path(shutil.which("alexa-sync") or sys.argv[0]).absolute())
223
+ AGENT_PLIST.parent.mkdir(parents=True, exist_ok=True)
224
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
225
+ plist = {
226
+ "Label": AGENT_LABEL,
227
+ "ProgramArguments": [exe, "run", "--list", args.list],
228
+ "StartInterval": args.interval,
229
+ "RunAtLoad": True,
230
+ "StandardOutPath": str(LOG_FILE),
231
+ "StandardErrorPath": str(LOG_FILE),
232
+ "EnvironmentVariables": {"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"},
233
+ }
234
+ _bootout_all()
235
+ AGENT_PLIST.write_bytes(plistlib.dumps(plist))
236
+ subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(AGENT_PLIST)], check=True)
237
+ print(f"Installed {AGENT_PLIST} (every {args.interval}s). Logs: {LOG_FILE}")
238
+ return 0
239
+
240
+
241
+ def cmd_uninstall(args: argparse.Namespace) -> int:
242
+ _bootout_all()
243
+ print("Uninstalled. Login stays in Keychain and sync state in", DATA_DIR)
244
+ return 0
245
+
246
+
247
+ def main() -> int:
248
+ parser = argparse.ArgumentParser(prog="alexa-sync", description=__doc__)
249
+ parser.add_argument("-v", "--verbose", action="store_true")
250
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
251
+ sub = parser.add_subparsers(dest="cmd", required=True)
252
+
253
+ p = sub.add_parser("login", help="register this Mac with Amazon (one time)")
254
+ p.add_argument("--email")
255
+ p.set_defaults(fn=cmd_login)
256
+
257
+ p = sub.add_parser("run", help="sync once")
258
+ p.add_argument("--list", default="Groceries", help="Reminders list name (default: Groceries)")
259
+ p.add_argument("--max-deletes", type=int, default=5, help="abort if a pass would delete more than this")
260
+ p.add_argument("--dry-run", action="store_true", help="show what would change without changing anything")
261
+ p.set_defaults(fn=cmd_run)
262
+
263
+ p = sub.add_parser("install", help="run automatically via launchd")
264
+ p.add_argument("--list", default="Groceries")
265
+ p.add_argument("--interval", type=int, default=120, help="seconds between runs (default: 120)")
266
+ p.set_defaults(fn=cmd_install)
267
+
268
+ p = sub.add_parser("uninstall", help="remove the launchd job")
269
+ p.set_defaults(fn=cmd_uninstall)
270
+
271
+ p = sub.add_parser("doctor", help="check that everything needed is set up")
272
+ p.add_argument("--list", default="Groceries")
273
+ p.set_defaults(fn=cmd_doctor)
274
+
275
+ args = parser.parse_args()
276
+ if sys.platform != "darwin":
277
+ parser.exit(1, "alexa-sync only runs on macOS (it needs Apple Reminders).\n")
278
+ logging.basicConfig(
279
+ level=logging.DEBUG if args.verbose else logging.INFO,
280
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
281
+ )
282
+ if not args.verbose:
283
+ logging.getLogger("aioamazondevices").setLevel(logging.WARNING)
284
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
285
+ return args.fn(args)
286
+
287
+
288
+ if __name__ == "__main__":
289
+ sys.exit(main())
@@ -0,0 +1,101 @@
1
+ """Apple Reminders side of the sync, via the remindctl CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from .sync import Item
11
+
12
+ INSTALL_HINT = "install it with: brew install steipete/tap/remindctl"
13
+
14
+
15
+ class RemindersUnavailable(Exception):
16
+ pass
17
+
18
+
19
+ def find_remindctl() -> str:
20
+ # launchd jobs get a minimal PATH, so also look in both Homebrew prefixes.
21
+ for candidate in (shutil.which("remindctl"), "/opt/homebrew/bin/remindctl", "/usr/local/bin/remindctl"):
22
+ if candidate and Path(candidate).exists():
23
+ return candidate
24
+ raise RemindersUnavailable(f"remindctl not found; {INSTALL_HINT}")
25
+
26
+
27
+ def check_access() -> None:
28
+ """Raise RemindersUnavailable with a fix-it message if we can't use Reminders."""
29
+ proc = subprocess.run([find_remindctl(), "status", "--json"], capture_output=True, text=True, timeout=30)
30
+ try:
31
+ authorized = json.loads(proc.stdout).get("authorized")
32
+ except ValueError:
33
+ authorized = False
34
+ if not authorized:
35
+ raise RemindersUnavailable("no access to Reminders; run: remindctl authorize")
36
+
37
+
38
+ class RemindersSide:
39
+ label = "Reminders"
40
+
41
+ def __init__(self, list_name: str) -> None:
42
+ self.list_name = list_name
43
+ self.list_id: str | None = None
44
+ self._bin = find_remindctl()
45
+
46
+ def _run(self, *args: str) -> object:
47
+ proc = subprocess.run(
48
+ [self._bin, *args, "--json", "--no-input", "--no-color"],
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=60,
52
+ )
53
+ if proc.returncode != 0:
54
+ raise RuntimeError(f"remindctl {args[0]} failed: {proc.stderr.strip() or proc.stdout.strip()}")
55
+ return json.loads(proc.stdout) if proc.stdout.strip() else None
56
+
57
+ def resolve(self, pinned_id: str | None) -> str:
58
+ """Find the target list by ID (preferred) or unique name. Never creates one.
59
+
60
+ Shared iCloud lists can be missing from EventKit for a moment after a
61
+ process starts, so creating a list whenever the name isn't found would
62
+ make duplicates. Pinning the ID also survives two lists sharing a name.
63
+ """
64
+ lists = self._run("list")
65
+ by_id = {l["id"]: l for l in lists}
66
+ if pinned_id in by_id and by_id[pinned_id]["title"] == self.list_name:
67
+ self.list_id = pinned_id
68
+ return pinned_id
69
+ matches = [l["id"] for l in lists if l["title"] == self.list_name]
70
+ if not matches:
71
+ raise RuntimeError(f"no Reminders list named {self.list_name!r}; create it in Reminders first")
72
+ if len(matches) > 1:
73
+ raise RuntimeError(
74
+ f"{len(matches)} Reminders lists are named {self.list_name!r}; rename or delete the extras"
75
+ )
76
+ self.list_id = matches[0]
77
+ return self.list_id
78
+
79
+ def fetch(self) -> dict[str, Item]:
80
+ rows = self._run("show", "all", "--list", self.list_name)
81
+ return {r["id"]: Item(r["id"], r["title"], r["isCompleted"]) for r in rows if r["listID"] == self.list_id}
82
+
83
+ def add(self, name: str, completed: bool) -> str:
84
+ row = self._run("add", "--title", name, "--list", self.list_name)
85
+ if row["listID"] != self.list_id:
86
+ self._run("delete", row["id"], "--force")
87
+ raise RuntimeError(f"remindctl added {name!r} to list {row['listID']}, not {self.list_id}")
88
+ if completed:
89
+ self._run("edit", row["id"], "--complete")
90
+ return row["id"]
91
+
92
+ def update(self, item_id: str, *, name: str | None, completed: bool | None) -> None:
93
+ args = ["edit", item_id]
94
+ if name is not None:
95
+ args += ["--title", name]
96
+ if completed is not None:
97
+ args.append("--complete" if completed else "--incomplete")
98
+ self._run(*args)
99
+
100
+ def delete(self, item_id: str) -> None:
101
+ self._run("delete", item_id, "--force")
@@ -0,0 +1,189 @@
1
+ """Two-way reconciliation between the Alexa shopping list and a Reminders list.
2
+
3
+ The sync is state-based: we remember what each paired item looked like after the
4
+ last successful sync, so on the next run we can tell which side changed. Neither
5
+ side exposes reliable modification timestamps through the tools we use, so this
6
+ three-way comparison (Alexa now, Reminders now, last synced) is what lets
7
+ changes flow in both directions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import asdict, dataclass
14
+ from typing import Protocol
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Item:
21
+ id: str
22
+ name: str
23
+ completed: bool
24
+
25
+
26
+ @dataclass
27
+ class Pair:
28
+ alexa: str
29
+ reminder: str
30
+ name: str # normalized name at last sync
31
+ completed: bool
32
+
33
+ @classmethod
34
+ def from_dict(cls, d: dict) -> Pair:
35
+ return cls(**d)
36
+
37
+ def to_dict(self) -> dict:
38
+ return asdict(self)
39
+
40
+
41
+ class Side(Protocol):
42
+ """One end of the sync. Implementations raise on failure."""
43
+
44
+ label: str
45
+
46
+ def fetch(self) -> dict[str, Item]: ...
47
+ def add(self, name: str, completed: bool) -> str: ...
48
+ def update(self, item_id: str, *, name: str | None, completed: bool | None) -> None: ...
49
+ def delete(self, item_id: str) -> None: ...
50
+
51
+
52
+ class TooManyDeletes(Exception):
53
+ pass
54
+
55
+
56
+ def normalize(name: str) -> str:
57
+ return " ".join(name.split()).casefold()
58
+
59
+
60
+ def display(name: str) -> str:
61
+ """Alexa stores voice-added items lowercase ("milk"); make them look normal."""
62
+ name = " ".join(name.split())
63
+ return name[:1].upper() + name[1:] if name.islower() else name
64
+
65
+
66
+ @dataclass
67
+ class _Update:
68
+ side: Side
69
+ item_id: str
70
+ name: str | None
71
+ completed: bool | None
72
+
73
+
74
+ def sync(alexa: Side, reminders: Side, pairs: list[Pair], max_deletes: int = 5) -> list[Pair]:
75
+ """Run one sync pass and return the new list of pairs to persist."""
76
+ a_items = alexa.fetch()
77
+ r_items = reminders.fetch()
78
+
79
+ deletes: list[tuple[Side, str, Pair]] = []
80
+ updates: list[tuple[_Update, Pair, Pair]] = [] # (update, old pair, new pair)
81
+ kept: list[Pair] = []
82
+
83
+ for p in pairs:
84
+ a = a_items.pop(p.alexa, None)
85
+ r = r_items.pop(p.reminder, None)
86
+ if a is None and r is None:
87
+ continue
88
+ if a is None:
89
+ deletes.append((reminders, p.reminder, p))
90
+ continue
91
+ if r is None:
92
+ deletes.append((alexa, p.alexa, p))
93
+ continue
94
+
95
+ new = Pair(p.alexa, p.reminder, p.name, p.completed)
96
+ a_update = _Update(alexa, a.id, None, None)
97
+ r_update = _Update(reminders, r.id, None, None)
98
+
99
+ # Name: Alexa wins if both sides renamed.
100
+ a_name, r_name = normalize(a.name), normalize(r.name)
101
+ if a_name != p.name:
102
+ new.name = a_name
103
+ if r_name != a_name:
104
+ r_update.name = display(a.name)
105
+ elif r_name != p.name:
106
+ new.name = r_name
107
+ a_update.name = r.name
108
+
109
+ # Completion: Alexa wins if both sides changed.
110
+ if a.completed != p.completed:
111
+ new.completed = a.completed
112
+ if r.completed != a.completed:
113
+ r_update.completed = a.completed
114
+ elif r.completed != p.completed:
115
+ new.completed = r.completed
116
+ a_update.completed = r.completed
117
+
118
+ pending = [u for u in (a_update, r_update) if u.name is not None or u.completed is not None]
119
+ if not pending:
120
+ kept.append(new)
121
+ for i, u in enumerate(pending):
122
+ # Record the new pair only once, against the last update for this item.
123
+ updates.append((u, p, new if i == len(pending) - 1 else None))
124
+
125
+ if len(deletes) > max_deletes:
126
+ raise TooManyDeletes(
127
+ f"refusing to delete {len(deletes)} items in one pass (limit {max_deletes}); "
128
+ "run with --max-deletes to allow it"
129
+ )
130
+
131
+ # Unpaired items. Completed ones are history, not something to copy over.
132
+ a_new = [a for a in a_items.values() if not a.completed]
133
+ r_new = [r for r in r_items.values() if not r.completed]
134
+
135
+ # Pair up identical names first so an initial sync doesn't duplicate items
136
+ # that already exist on both sides.
137
+ r_by_name: dict[str, list[Item]] = {}
138
+ for r in r_new:
139
+ r_by_name.setdefault(normalize(r.name), []).append(r)
140
+ a_unmatched: list[Item] = []
141
+ for a in a_new:
142
+ matches = r_by_name.get(normalize(a.name))
143
+ if matches:
144
+ r = matches.pop(0)
145
+ kept.append(Pair(a.id, r.id, normalize(a.name), False))
146
+ else:
147
+ a_unmatched.append(a)
148
+ r_unmatched = [r for rs in r_by_name.values() for r in rs]
149
+
150
+ result = list(kept)
151
+
152
+ for side, item_id, p in deletes:
153
+ try:
154
+ side.delete(item_id)
155
+ log.info("deleted %r from %s", p.name, side.label)
156
+ except Exception:
157
+ log.exception("failed to delete %r from %s", p.name, side.label)
158
+ result.append(p)
159
+
160
+ failed: set[int] = set()
161
+ for u, old, new in updates:
162
+ try:
163
+ if id(old) not in failed:
164
+ u.side.update(u.item_id, name=u.name, completed=u.completed)
165
+ log.info("updated %r on %s (name=%r, completed=%r)", old.name, u.side.label, u.name, u.completed)
166
+ except Exception:
167
+ log.exception("failed to update %r on %s", old.name, u.side.label)
168
+ failed.add(id(old))
169
+ if new is not None:
170
+ # If any half failed, keep the old snapshot so the change is retried.
171
+ result.append(old if id(old) in failed else new)
172
+
173
+ for a in a_unmatched:
174
+ try:
175
+ rid = reminders.add(display(a.name), False)
176
+ result.append(Pair(a.id, rid, normalize(a.name), False))
177
+ log.info("added %r to %s", a.name, reminders.label)
178
+ except Exception:
179
+ log.exception("failed to add %r to %s", a.name, reminders.label)
180
+
181
+ for r in r_unmatched:
182
+ try:
183
+ aid = alexa.add(r.name, False)
184
+ result.append(Pair(aid, r.id, normalize(r.name), False))
185
+ log.info("added %r to %s", r.name, alexa.label)
186
+ except Exception:
187
+ log.exception("failed to add %r to %s", r.name, alexa.label)
188
+
189
+ return result