icloud-cli-tools 0.1.0__py3-none-any.whl

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.
icloud_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """icloud-cli-tools: Access iCloud services from Linux."""
2
+
3
+ __version__ = "0.1.0"
icloud_cli/auth.py ADDED
@@ -0,0 +1,273 @@
1
+ """Authentication management for icloud-cli.
2
+
3
+ Handles Apple ID login, 2FA verification, session caching,
4
+ and credential storage via OS keyring.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+ import keyring
14
+ from pyicloud import PyiCloudService
15
+ from pyicloud.exceptions import (
16
+ PyiCloudFailedLoginException,
17
+ )
18
+
19
+ from icloud_cli.config import Config
20
+ from icloud_cli.output import error, info, success, warning
21
+
22
+ KEYRING_SERVICE = "icloud-cli-tools"
23
+ KEYRING_IMAP_SERVICE = "icloud-cli-tools-imap"
24
+
25
+
26
+ class AuthManager:
27
+ """Manages iCloud authentication, sessions, and credentials."""
28
+
29
+ def __init__(self, config: Config):
30
+ self.config = config
31
+ self._api: PyiCloudService | None = None
32
+
33
+ @property
34
+ def api(self) -> PyiCloudService:
35
+ """Get authenticated PyiCloudService instance."""
36
+ if self._api is None:
37
+ self._api = self._get_session()
38
+ return self._api
39
+
40
+ def login(self) -> bool:
41
+ """Interactive login flow with 2FA support.
42
+
43
+ Returns True if login was successful.
44
+ """
45
+ # Get Apple ID — always allow changing
46
+ apple_id = self.config.apple_id
47
+ if apple_id:
48
+ apple_id = click.prompt("Apple ID (email)", default=apple_id)
49
+ else:
50
+ apple_id = click.prompt("Apple ID (email)")
51
+ self.config.apple_id = apple_id
52
+ self.config.save()
53
+
54
+ # Get password from keyring or prompt
55
+ password = keyring.get_password(KEYRING_SERVICE, apple_id)
56
+ if not password:
57
+ password = click.prompt("Password", hide_input=True)
58
+ if click.confirm("Save password to system keyring?", default=True):
59
+ keyring.set_password(KEYRING_SERVICE, apple_id, password)
60
+ success("Password saved to keyring.")
61
+
62
+ # Authenticate
63
+ try:
64
+ session_dir = Path(self.config.session_dir)
65
+ session_dir.mkdir(parents=True, exist_ok=True)
66
+
67
+ self._api = PyiCloudService(
68
+ apple_id=apple_id,
69
+ password=password,
70
+ cookie_directory=str(session_dir),
71
+ )
72
+ except PyiCloudFailedLoginException as e:
73
+ error(f"Login failed: {e}")
74
+ return False
75
+ except Exception as e:
76
+ error(f"Connection error: {e}")
77
+ return False
78
+
79
+ # Handle 2FA
80
+ if self._api.requires_2fa:
81
+ return self._handle_2fa()
82
+
83
+ if self._api.requires_2sa:
84
+ return self._handle_2sa()
85
+
86
+ success(f"Logged in as {apple_id}")
87
+ return True
88
+
89
+ def _handle_2fa(self) -> bool:
90
+ """Handle two-factor authentication (trusted device code)."""
91
+ info("Two-factor authentication required.")
92
+ info("A verification code has been sent to your trusted devices.")
93
+
94
+ code = click.prompt("Enter 2FA code")
95
+
96
+ try:
97
+ result = self._api.validate_2fa_code(code)
98
+ if not result:
99
+ error("Invalid 2FA code.")
100
+ return False
101
+
102
+ # Trust this session
103
+ if not self._api.is_trusted_session:
104
+ info("Trusting this session...")
105
+ self._api.trust_session()
106
+
107
+ success("2FA verification successful!")
108
+ return True
109
+ except Exception as e:
110
+ error(f"2FA verification failed: {e}")
111
+ return False
112
+
113
+ def _handle_2sa(self) -> bool:
114
+ """Handle two-step authentication (SMS/phone-based)."""
115
+ info("Two-step authentication required.")
116
+
117
+ devices = self._api.trusted_devices
118
+ if not devices:
119
+ error("No trusted devices found.")
120
+ return False
121
+
122
+ # Show available devices
123
+ for i, device in enumerate(devices):
124
+ phone = device.get("phoneNumber", "Unknown")
125
+ info(f" {i + 1}. {phone}")
126
+
127
+ device_idx = click.prompt("Choose device number", type=int, default=1) - 1
128
+ if device_idx < 0 or device_idx >= len(devices):
129
+ error("Invalid device number.")
130
+ return False
131
+
132
+ device = devices[device_idx]
133
+ if not self._api.send_verification_code(device):
134
+ error("Failed to send verification code.")
135
+ return False
136
+
137
+ code = click.prompt("Enter verification code")
138
+ if not self._api.validate_verification_code(device, code):
139
+ error("Invalid verification code.")
140
+ return False
141
+
142
+ success("2SA verification successful!")
143
+ return True
144
+
145
+ def logout(self) -> None:
146
+ """Clear all stored credentials and sessions."""
147
+ # Remove keyring credentials
148
+ apple_id = self.config.apple_id
149
+ if apple_id:
150
+ try:
151
+ keyring.delete_password(KEYRING_SERVICE, apple_id)
152
+ info("Removed password from keyring.")
153
+ except keyring.errors.PasswordDeleteError:
154
+ pass
155
+
156
+ try:
157
+ keyring.delete_password(KEYRING_IMAP_SERVICE, apple_id)
158
+ info("Removed IMAP password from keyring.")
159
+ except keyring.errors.PasswordDeleteError:
160
+ pass
161
+
162
+ # Clear session cookies
163
+ session_dir = Path(self.config.session_dir)
164
+ if session_dir.exists():
165
+ for f in session_dir.iterdir():
166
+ f.unlink()
167
+ info("Cleared session cookies.")
168
+
169
+ self._api = None
170
+ success("Logged out successfully.")
171
+
172
+ def get_status(self) -> dict:
173
+ """Return current authentication status."""
174
+ apple_id = self.config.apple_id
175
+ has_password = bool(
176
+ apple_id and keyring.get_password(KEYRING_SERVICE, apple_id)
177
+ )
178
+ has_imap_password = bool(
179
+ apple_id and keyring.get_password(KEYRING_IMAP_SERVICE, apple_id)
180
+ )
181
+ has_session = self._has_cached_session()
182
+
183
+ return {
184
+ "apple_id": apple_id or "(not set)",
185
+ "password_stored": "Yes" if has_password else "No",
186
+ "imap_password_stored": "Yes" if has_imap_password else "No",
187
+ "session_cached": "Yes" if has_session else "No",
188
+ "session_dir": self.config.session_dir,
189
+ }
190
+
191
+ def setup_imap_password(self) -> bool:
192
+ """Guide user through setting up an app-specific password for Notes (IMAP).
193
+
194
+ Returns True if password was stored successfully.
195
+ """
196
+ apple_id = self.config.apple_id
197
+ if not apple_id:
198
+ error("Please login first with 'icloud-cli login'.")
199
+ return False
200
+
201
+ info("Notes access requires an app-specific password.")
202
+ info("Generate one at: https://appleid.apple.com/account/manage")
203
+ info(" → Sign In & Security → App-Specific Passwords → Generate")
204
+ print()
205
+
206
+ password = click.prompt("Enter app-specific password", hide_input=True)
207
+ keyring.set_password(KEYRING_IMAP_SERVICE, apple_id, password)
208
+ self.config.imap_password_in_keyring = True
209
+ self.config.save()
210
+
211
+ success("IMAP password saved to keyring.")
212
+ return True
213
+
214
+ def get_imap_credentials(self) -> tuple[str, str] | None:
215
+ """Get IMAP credentials (apple_id, app-specific password).
216
+
217
+ Returns None if not configured.
218
+ """
219
+ apple_id = self.config.apple_id
220
+ if not apple_id:
221
+ return None
222
+
223
+ password = keyring.get_password(KEYRING_IMAP_SERVICE, apple_id)
224
+ if not password:
225
+ return None
226
+
227
+ return (apple_id, password)
228
+
229
+ def _get_session(self) -> PyiCloudService:
230
+ """Get or restore a cached PyiCloudService session."""
231
+ apple_id = self.config.apple_id
232
+ if not apple_id:
233
+ error("Not logged in. Run 'icloud-cli login' first.")
234
+ sys.exit(1)
235
+
236
+ password = keyring.get_password(KEYRING_SERVICE, apple_id)
237
+ if not password:
238
+ # No saved password — prompt interactively
239
+ warning("No stored password found.")
240
+ password = click.prompt("Password", hide_input=True)
241
+ if click.confirm("Save password to system keyring?", default=True):
242
+ keyring.set_password(KEYRING_SERVICE, apple_id, password)
243
+ success("Password saved to keyring.")
244
+
245
+ try:
246
+ session_dir = Path(self.config.session_dir)
247
+ session_dir.mkdir(parents=True, exist_ok=True)
248
+
249
+ api = PyiCloudService(
250
+ apple_id=apple_id,
251
+ password=password,
252
+ cookie_directory=str(session_dir),
253
+ )
254
+
255
+ if api.requires_2fa or api.requires_2sa:
256
+ warning("Session expired. Please re-authenticate.")
257
+ error("Run 'icloud-cli login' to refresh your session.")
258
+ sys.exit(1)
259
+
260
+ return api
261
+ except PyiCloudFailedLoginException:
262
+ error("Authentication failed. Run 'icloud-cli login' to re-authenticate.")
263
+ sys.exit(1)
264
+ except Exception as e:
265
+ error(f"Failed to connect to iCloud: {e}")
266
+ sys.exit(1)
267
+
268
+ def _has_cached_session(self) -> bool:
269
+ """Check if a cached session exists."""
270
+ session_dir = Path(self.config.session_dir)
271
+ if not session_dir.exists():
272
+ return False
273
+ return any(session_dir.iterdir())
icloud_cli/config.py ADDED
@@ -0,0 +1,122 @@
1
+ """Configuration management for icloud-cli.
2
+
3
+ Config file: ~/.config/icloud-cli/config.toml
4
+ Cache dir: ~/.local/share/icloud-cli/cache/
5
+ Session dir: ~/.config/icloud-cli/session/
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import toml
15
+
16
+ # XDG-compliant default paths
17
+ DEFAULT_CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "icloud-cli"
18
+ DEFAULT_DATA_DIR = (
19
+ Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "icloud-cli"
20
+ )
21
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.toml"
22
+ DEFAULT_SESSION_DIR = DEFAULT_CONFIG_DIR / "session"
23
+ DEFAULT_CACHE_DIR = DEFAULT_DATA_DIR / "cache"
24
+
25
+
26
+ @dataclass
27
+ class Config:
28
+ """Application configuration."""
29
+
30
+ # General
31
+ default_format: str = "table"
32
+ verbose: bool = False
33
+
34
+ # Auth
35
+ apple_id: str = ""
36
+ session_dir: str = str(DEFAULT_SESSION_DIR)
37
+
38
+ # Notes (IMAP)
39
+ imap_password_in_keyring: bool = False
40
+
41
+ # Sync
42
+ sync_interval_minutes: int = 15
43
+ cache_dir: str = str(DEFAULT_CACHE_DIR)
44
+
45
+ # Calendar
46
+ default_calendar: str = ""
47
+
48
+ # Reminders
49
+ default_reminder_list: str = ""
50
+
51
+ # Paths (not serialized)
52
+ config_file: Path = field(default=DEFAULT_CONFIG_FILE, repr=False)
53
+
54
+ @classmethod
55
+ def load(cls, config_path: Path | None = None) -> Config:
56
+ """Load config from TOML file, falling back to defaults."""
57
+ path = config_path or DEFAULT_CONFIG_FILE
58
+ config = cls(config_file=path)
59
+
60
+ if path.exists():
61
+ data = toml.load(path)
62
+ general = data.get("general", {})
63
+ auth = data.get("auth", {})
64
+ notes = data.get("notes", {})
65
+ sync = data.get("sync", {})
66
+ calendar = data.get("calendar", {})
67
+ reminders = data.get("reminders", {})
68
+
69
+ config.default_format = general.get("default_format", config.default_format)
70
+ config.verbose = general.get("verbose", config.verbose)
71
+ config.apple_id = auth.get("apple_id", config.apple_id)
72
+ config.session_dir = auth.get("session_dir", config.session_dir)
73
+ config.imap_password_in_keyring = notes.get(
74
+ "imap_password_in_keyring", config.imap_password_in_keyring
75
+ )
76
+ config.sync_interval_minutes = sync.get(
77
+ "sync_interval_minutes", config.sync_interval_minutes
78
+ )
79
+ config.cache_dir = sync.get("cache_dir", config.cache_dir)
80
+ config.default_calendar = calendar.get("default_calendar", config.default_calendar)
81
+ config.default_reminder_list = reminders.get(
82
+ "default_reminder_list", config.default_reminder_list
83
+ )
84
+
85
+ return config
86
+
87
+ def save(self) -> None:
88
+ """Save config to TOML file."""
89
+ self.config_file.parent.mkdir(parents=True, exist_ok=True)
90
+
91
+ data = {
92
+ "general": {
93
+ "default_format": self.default_format,
94
+ "verbose": self.verbose,
95
+ },
96
+ "auth": {
97
+ "apple_id": self.apple_id,
98
+ "session_dir": self.session_dir,
99
+ },
100
+ "notes": {
101
+ "imap_password_in_keyring": self.imap_password_in_keyring,
102
+ },
103
+ "sync": {
104
+ "sync_interval_minutes": self.sync_interval_minutes,
105
+ "cache_dir": self.cache_dir,
106
+ },
107
+ "calendar": {
108
+ "default_calendar": self.default_calendar,
109
+ },
110
+ "reminders": {
111
+ "default_reminder_list": self.default_reminder_list,
112
+ },
113
+ }
114
+
115
+ with open(self.config_file, "w") as f:
116
+ toml.dump(data, f)
117
+
118
+ def ensure_dirs(self) -> None:
119
+ """Create required directories."""
120
+ Path(self.session_dir).mkdir(parents=True, exist_ok=True)
121
+ Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
122
+ self.config_file.parent.mkdir(parents=True, exist_ok=True)
icloud_cli/daemon.py ADDED
@@ -0,0 +1,198 @@
1
+ """Background sync daemon for icloud-cli.
2
+
3
+ Provides one-shot sync and a background daemon that periodically
4
+ caches iCloud data to local JSON files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import signal
12
+ import sys
13
+ import time
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from icloud_cli.auth import AuthManager
19
+ from icloud_cli.config import Config
20
+ from icloud_cli.output import error, info, success, warning
21
+
22
+ PID_FILE_NAME = "icloud-cli-daemon.pid"
23
+
24
+
25
+ def sync_all(auth: AuthManager, config: Config) -> None:
26
+ """Run a one-shot sync of all services to local cache."""
27
+ cache_dir = Path(config.cache_dir)
28
+ cache_dir.mkdir(parents=True, exist_ok=True)
29
+
30
+ info("Syncing iCloud data...")
31
+
32
+ # Sync Calendar
33
+ try:
34
+ from icloud_cli.services.calendar import CalendarService
35
+
36
+ cal_service = CalendarService(auth.api, config)
37
+ now = datetime.now()
38
+ events = cal_service.list_events(
39
+ from_date=now.strftime("%Y-%m-%d"),
40
+ to_date=(now + timedelta(days=30)).strftime("%Y-%m-%d"),
41
+ )
42
+ _save_cache(cache_dir / "calendar.json", events)
43
+ success(f"Calendar: {len(events)} events synced.")
44
+ except Exception as e:
45
+ warning(f"Calendar sync failed: {e}")
46
+
47
+ # Sync Reminders
48
+ try:
49
+ from icloud_cli.services.reminders import RemindersService
50
+
51
+ rem_service = RemindersService(auth.api, config)
52
+ reminders = rem_service.list_reminders(show_completed=True)
53
+ _save_cache(cache_dir / "reminders.json", reminders)
54
+ success(f"Reminders: {len(reminders)} items synced.")
55
+ except Exception as e:
56
+ warning(f"Reminders sync failed: {e}")
57
+
58
+ # Sync Notes
59
+ try:
60
+ credentials = auth.get_imap_credentials()
61
+ if credentials:
62
+ from icloud_cli.services.notes import NotesService
63
+
64
+ notes_service = NotesService(*credentials)
65
+ notes = notes_service.list_notes()
66
+ _save_cache(cache_dir / "notes.json", notes)
67
+ success(f"Notes: {len(notes)} notes synced.")
68
+ else:
69
+ info("Notes: Skipped (IMAP not configured).")
70
+ except Exception as e:
71
+ warning(f"Notes sync failed: {e}")
72
+
73
+ # Sync Find My
74
+ try:
75
+ from icloud_cli.services.findmy import FindMyService
76
+
77
+ findmy_service = FindMyService(auth.api)
78
+ devices = findmy_service.list_devices()
79
+ _save_cache(cache_dir / "devices.json", devices)
80
+ success(f"Find My: {len(devices)} devices synced.")
81
+ except Exception as e:
82
+ warning(f"Find My sync failed: {e}")
83
+
84
+ # Write sync timestamp
85
+ _save_cache(cache_dir / "last_sync.json", {
86
+ "timestamp": datetime.now().isoformat(),
87
+ "status": "ok",
88
+ })
89
+
90
+ success("Sync complete!")
91
+
92
+
93
+ def start_daemon(auth: AuthManager, config: Config) -> None:
94
+ """Start the background sync daemon."""
95
+ pid_file = Path(config.cache_dir) / PID_FILE_NAME
96
+
97
+ # Check if already running
98
+ if pid_file.exists():
99
+ try:
100
+ pid = int(pid_file.read_text().strip())
101
+ os.kill(pid, 0) # Check if process exists
102
+ error(f"Daemon already running (PID {pid}).")
103
+ return
104
+ except (ProcessLookupError, ValueError):
105
+ # Stale PID file
106
+ pid_file.unlink(missing_ok=True)
107
+
108
+ interval = config.sync_interval_minutes
109
+ info(f"Starting daemon (sync every {interval} minutes)...")
110
+ info("Press Ctrl+C to stop, or use 'icloud-cli daemon stop'.")
111
+
112
+ # Write PID file
113
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
114
+ pid_file.write_text(str(os.getpid()))
115
+
116
+ # Handle graceful shutdown
117
+ def _shutdown(signum, frame):
118
+ info("\nDaemon stopping...")
119
+ pid_file.unlink(missing_ok=True)
120
+ sys.exit(0)
121
+
122
+ signal.signal(signal.SIGTERM, _shutdown)
123
+ signal.signal(signal.SIGINT, _shutdown)
124
+
125
+ try:
126
+ while True:
127
+ try:
128
+ sync_all(auth, config)
129
+ except Exception as e:
130
+ warning(f"Sync cycle failed: {e}")
131
+
132
+ info(f"Next sync in {interval} minutes...")
133
+ time.sleep(interval * 60)
134
+ finally:
135
+ pid_file.unlink(missing_ok=True)
136
+
137
+
138
+ def stop_daemon(config: Config) -> None:
139
+ """Stop the background sync daemon."""
140
+ pid_file = Path(config.cache_dir) / PID_FILE_NAME
141
+
142
+ if not pid_file.exists():
143
+ error("Daemon is not running.")
144
+ return
145
+
146
+ try:
147
+ pid = int(pid_file.read_text().strip())
148
+ os.kill(pid, signal.SIGTERM)
149
+ success(f"Daemon stopped (PID {pid}).")
150
+ pid_file.unlink(missing_ok=True)
151
+ except ProcessLookupError:
152
+ warning("Daemon process not found (stale PID file).")
153
+ pid_file.unlink(missing_ok=True)
154
+ except ValueError:
155
+ error("Invalid PID file.")
156
+ pid_file.unlink(missing_ok=True)
157
+
158
+
159
+ def get_daemon_status(config: Config) -> dict[str, Any]:
160
+ """Get daemon status information."""
161
+ pid_file = Path(config.cache_dir) / PID_FILE_NAME
162
+ cache_dir = Path(config.cache_dir)
163
+ last_sync_file = cache_dir / "last_sync.json"
164
+
165
+ # Check if daemon is running
166
+ running = False
167
+ pid = None
168
+ if pid_file.exists():
169
+ try:
170
+ pid = int(pid_file.read_text().strip())
171
+ os.kill(pid, 0)
172
+ running = True
173
+ except (ProcessLookupError, ValueError):
174
+ pass
175
+
176
+ # Last sync info
177
+ last_sync = "Never"
178
+ if last_sync_file.exists():
179
+ try:
180
+ data = json.loads(last_sync_file.read_text())
181
+ last_sync = data.get("timestamp", "Unknown")
182
+ except (json.JSONDecodeError, KeyError):
183
+ pass
184
+
185
+ return {
186
+ "running": "Yes" if running else "No",
187
+ "pid": str(pid) if pid and running else "N/A",
188
+ "sync_interval": f"{config.sync_interval_minutes} minutes",
189
+ "last_sync": last_sync,
190
+ "cache_dir": config.cache_dir,
191
+ }
192
+
193
+
194
+ def _save_cache(path: Path, data: Any) -> None:
195
+ """Save data to a JSON cache file."""
196
+ path.parent.mkdir(parents=True, exist_ok=True)
197
+ with open(path, "w") as f:
198
+ json.dump(data, f, indent=2, default=str)