mycloudctl 1.0.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.
cli/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # cli/__init__.py
2
+ __version__ = "1.0.0"
cli/__main__.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ __main__.py myCloud CLI entry point.
3
+
4
+ Usage:
5
+ mycloud <command> [subcommand] [options]
6
+
7
+ Install:
8
+ pip install -e ./cli
9
+ # then just run: mycloud
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from cli.commands import auth, files, folders, stash, share, batch
17
+ from cli.commands import favorites, search, servers, notifications, profile
18
+ from cli.commands import storage, prefs
19
+
20
+ console = Console()
21
+
22
+ app = typer.Typer(
23
+ name="mycloudctl",
24
+ help="""
25
+ [bold cyan]myCloud CLI[/bold cyan] Your cloud storage from the terminal.
26
+
27
+ [dim]Get started:[/dim]
28
+ mycloudctl login Sign in to myCloud
29
+ mycloudctl ls Browse your files
30
+ mycloudctl upload <file> Upload a file
31
+ mycloudctl upload <file> --stash Upload directly to stash
32
+ mycloudctl download <id> Download a file
33
+ mycloudctl info <id> Show file details
34
+ mycloudctl stats Show storage usage
35
+ mycloudctl find <query> Search files and folders
36
+ mycloudctl --help Show this help
37
+ """,
38
+ rich_markup_mode="rich",
39
+ add_completion=True,
40
+ no_args_is_help=True,
41
+ )
42
+
43
+ # ------------------------------------------------------------------ #
44
+ # Top-level auth commands (login, logout, whoami)
45
+ # ------------------------------------------------------------------ #
46
+ app.add_typer(auth.app, name="auth", hidden=True) # namespace for power users
47
+ app.command("login")(auth.login)
48
+ app.command("logout")(auth.logout)
49
+ app.command("whoami")(auth.whoami)
50
+ app.command("register")(auth.register)
51
+
52
+ # ------------------------------------------------------------------ #
53
+ # File operations
54
+ # ------------------------------------------------------------------ #
55
+ app.command("ls")(files.ls)
56
+ app.command("upload")(files.upload)
57
+ app.command("download")(files.download)
58
+ app.command("rm")(files.rm)
59
+ app.command("rename")(files.rename)
60
+ app.command("mv")(files.mv)
61
+ app.command("info")(files.info) # <-- file info shortcut
62
+ app.command("favorite")(files.favorite)
63
+ app.command("cat")(files.cat)
64
+ app.command("download-all")(files.download_all)
65
+ app.command("rm-all")(files.remove_all)
66
+
67
+ # ------------------------------------------------------------------ #
68
+ # Folder operations (sub-group)
69
+ # ------------------------------------------------------------------ #
70
+ app.add_typer(folders.app, name="folders")
71
+ # Also expose mkdir directly at the top level for convenience
72
+ app.command("mkdir")(folders.mkdir)
73
+ app.command("rmdir")(folders.rmdir)
74
+
75
+ # ------------------------------------------------------------------ #
76
+ # Stash
77
+ # ------------------------------------------------------------------ #
78
+ app.add_typer(stash.app, name="stash")
79
+
80
+ # ------------------------------------------------------------------ #
81
+ # Favorites shortcut
82
+ # ------------------------------------------------------------------ #
83
+ app.command("fav")(favorites.fav_toggle)
84
+
85
+ # ------------------------------------------------------------------ #
86
+ # Search
87
+ # ------------------------------------------------------------------ #
88
+ app.command("find")(search.find)
89
+
90
+ # ------------------------------------------------------------------ #
91
+ # Sharing
92
+ # ------------------------------------------------------------------ #
93
+ app.add_typer(share.app, name="share")
94
+
95
+ # ------------------------------------------------------------------ #
96
+ # Batch operations
97
+ # ------------------------------------------------------------------ #
98
+ app.add_typer(batch.app, name="batch")
99
+
100
+ # ------------------------------------------------------------------ #
101
+ # Private servers / nodes
102
+ # ------------------------------------------------------------------ #
103
+ app.add_typer(servers.app, name="servers")
104
+
105
+ # ------------------------------------------------------------------ #
106
+ # Notifications
107
+ # ------------------------------------------------------------------ #
108
+ app.add_typer(notifications.app, name="notify")
109
+
110
+ # ------------------------------------------------------------------ #
111
+ # Profile & settings
112
+ # ------------------------------------------------------------------ #
113
+ app.add_typer(profile.app, name="profile")
114
+
115
+ # ------------------------------------------------------------------ #
116
+ # Storage stats
117
+ # ------------------------------------------------------------------ #
118
+ app.add_typer(storage.app, name="storage")
119
+ app.command("stats")(storage.stats) # <-- top-level shortcut
120
+
121
+ # ------------------------------------------------------------------ #
122
+ # User preferences
123
+ # ------------------------------------------------------------------ #
124
+ app.add_typer(prefs.app, name="prefs")
125
+
126
+ # ------------------------------------------------------------------ #
127
+ # Version
128
+ # ------------------------------------------------------------------ #
129
+
130
+ @app.command("version")
131
+ def version_cmd():
132
+ """Show the myCloud CLI version."""
133
+ from cli import __version__
134
+ console.print(f"[bold]myCloud CLI[/bold] v{__version__}")
135
+
136
+
137
+ if __name__ == "__main__":
138
+ app()
cli/api.py ADDED
@@ -0,0 +1,323 @@
1
+ """
2
+ api.py myCloud HTTP client.
3
+
4
+ All CLI commands use CloudClient to communicate with the server.
5
+ Handles:
6
+ - Bearer token injection
7
+ - 401 friendly re-login message
8
+ - Streaming uploads and downloads with Rich progress bars
9
+ - JSON error unwrapping
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any, Generator, IO
16
+
17
+ import httpx
18
+ from rich.console import Console
19
+ from rich.progress import (
20
+ Progress,
21
+ SpinnerColumn,
22
+ BarColumn,
23
+ DownloadColumn,
24
+ TransferSpeedColumn,
25
+ TimeRemainingColumn,
26
+ TextColumn,
27
+ )
28
+
29
+ from cli.config import get_base_url, get_token
30
+
31
+ console = Console()
32
+
33
+ _CHUNK = 1024 * 64 # 64 KB read/write chunks
34
+
35
+
36
+ class APIError(Exception):
37
+ """Raised when the server returns a non-2xx status."""
38
+ def __init__(self, message: str, status_code: int = 0):
39
+ super().__init__(message)
40
+ self.status_code = status_code
41
+
42
+
43
+ class CloudClient:
44
+ """Thin httpx wrapper with auth + progress for the myCloud CLI."""
45
+
46
+ def __init__(self, base_url: str | None = None, token: str | None = None):
47
+ self.base_url = (base_url or get_base_url()).rstrip("/")
48
+ self.token = token or get_token()
49
+
50
+ # ------------------------------------------------------------------ #
51
+ # Internal helpers
52
+ # ------------------------------------------------------------------ #
53
+
54
+ def _headers(self, extra: dict | None = None) -> dict:
55
+ h = {
56
+ "Accept": "application/json",
57
+ "X-Requested-With": "XMLHttpRequest"
58
+ }
59
+ if self.token:
60
+ h["Authorization"] = f"Bearer {self.token}"
61
+ if extra:
62
+ h.update(extra)
63
+ return h
64
+
65
+ def _url(self, path: str) -> str:
66
+ return f"{self.base_url}{path}"
67
+
68
+ def _raise(self, resp: httpx.Response) -> None:
69
+ if resp.status_code == 401:
70
+ try:
71
+ msg = resp.json().get("error")
72
+ if msg:
73
+ raise APIError(msg, 401)
74
+ except Exception as e:
75
+ # If we've raised APIError inside the try, re-raise it
76
+ if isinstance(e, APIError):
77
+ raise e
78
+ pass
79
+
80
+ raise APIError(
81
+ "Not authenticated. Run [bold]mycloud login[/bold] first.",
82
+ 401,
83
+ )
84
+
85
+ if not resp.is_success:
86
+ try:
87
+ body = resp.json()
88
+ msg = body.get("error") or body.get("message") or resp.text
89
+ except Exception:
90
+ msg = resp.text or f"HTTP {resp.status_code}"
91
+ raise APIError(msg, resp.status_code)
92
+
93
+ # ------------------------------------------------------------------ #
94
+ # JSON endpoints
95
+ # ------------------------------------------------------------------ #
96
+
97
+ def get(self, path: str, **params) -> Any:
98
+ resp = httpx.get(
99
+ self._url(path),
100
+ params=params or None,
101
+ headers=self._headers(),
102
+ follow_redirects=True,
103
+ timeout=30,
104
+ )
105
+ self._raise(resp)
106
+ return resp.json()
107
+
108
+ def get_raw(self, path: str) -> str:
109
+ resp = httpx.get(
110
+ self._url(path),
111
+ headers=self._headers(),
112
+ follow_redirects=True,
113
+ timeout=30,
114
+ )
115
+ self._raise(resp)
116
+ return resp.text
117
+ def post(self, path: str, json: Any = None, data: dict | None = None,
118
+ files: dict | None = None, extra_headers: dict | None = None) -> Any:
119
+ h = self._headers(extra_headers)
120
+ if files:
121
+ # multipart don't set Content-Type, httpx handles it
122
+ resp = httpx.post(
123
+ self._url(path), headers=h, data=data or {},
124
+ files=files, timeout=None, follow_redirects=True,
125
+ )
126
+ else:
127
+ resp = httpx.post(
128
+ self._url(path), headers=h, json=json,
129
+ timeout=30, follow_redirects=True,
130
+ )
131
+ self._raise(resp)
132
+ return resp.json()
133
+
134
+ def delete(self, path: str, json: Any = None) -> Any:
135
+ if json is not None:
136
+ resp = httpx.request(
137
+ "DELETE",
138
+ self._url(path),
139
+ headers=self._headers(),
140
+ json=json,
141
+ follow_redirects=True,
142
+ timeout=30,
143
+ )
144
+ else:
145
+ resp = httpx.delete(
146
+ self._url(path),
147
+ headers=self._headers(),
148
+ follow_redirects=True,
149
+ timeout=30,
150
+ )
151
+ self._raise(resp)
152
+ return resp.json()
153
+
154
+ # ------------------------------------------------------------------ #
155
+ # Streaming upload
156
+ # ------------------------------------------------------------------ #
157
+
158
+ def upload_avatar(self, local_path: Path) -> Any:
159
+ with open(local_path, "rb") as f:
160
+ resp = httpx.post(
161
+ self._url("/upload_pfp"),
162
+ headers=self._headers(),
163
+ files={"file": (local_path.name, f, "image/jpeg")},
164
+ timeout=30, follow_redirects=True,
165
+ )
166
+ self._raise(resp)
167
+ return resp.json()
168
+
169
+ def upload_file(
170
+ self,
171
+ local_path: Path,
172
+ folder_id: int | None = None,
173
+ node: str = "central",
174
+ stash: bool = False,
175
+ ) -> Any:
176
+ """Upload a single file with a Rich progress bar."""
177
+ file_size = local_path.stat().st_size
178
+
179
+ with Progress(
180
+ SpinnerColumn(),
181
+ TextColumn("[bold blue]{task.fields[filename]}"),
182
+ BarColumn(),
183
+ DownloadColumn(),
184
+ TransferSpeedColumn(),
185
+ TimeRemainingColumn(),
186
+ console=console,
187
+ transient=True,
188
+ ) as progress:
189
+ task = progress.add_task(
190
+ "upload", filename=local_path.name, total=file_size
191
+ )
192
+
193
+ class TrackedFile:
194
+ def __init__(self, f: IO[bytes]):
195
+ self._f = f
196
+
197
+ def read(self, n: int = -1) -> bytes:
198
+ chunk = self._f.read(n)
199
+ progress.update(task, advance=len(chunk))
200
+ return chunk
201
+
202
+ def fileno(self):
203
+ return self._f.fileno()
204
+
205
+ with open(local_path, "rb") as f:
206
+ tracked = TrackedFile(f)
207
+ form_data: dict[str, Any] = {}
208
+ if stash:
209
+ form_data["days"] = "7"
210
+ elif folder_id is not None:
211
+ form_data["folder_id"] = str(folder_id)
212
+ if node and node != "central":
213
+ form_data["node"] = str(node)
214
+
215
+ endpoint = "/stash_add" if stash else "/upload"
216
+
217
+ resp = httpx.post(
218
+ self._url(endpoint),
219
+ headers=self._headers(),
220
+ data=form_data,
221
+ files={"file": (local_path.name, tracked, "application/octet-stream")},
222
+ timeout=None,
223
+ follow_redirects=True,
224
+ )
225
+ self._raise(resp)
226
+ return resp.json()
227
+
228
+ # ------------------------------------------------------------------ #
229
+ # Streaming download
230
+ # ------------------------------------------------------------------ #
231
+
232
+ def download_file(
233
+ self,
234
+ file_id: int,
235
+ dest_dir: Path,
236
+ filename: str | None = None,
237
+ ) -> Path:
238
+ """Download a file with a Rich progress bar. Returns the saved path."""
239
+ url = self._url(f"/download/{file_id}")
240
+
241
+ with httpx.stream(
242
+ "GET", url, headers=self._headers(),
243
+ follow_redirects=True, timeout=None,
244
+ ) as resp:
245
+ self._raise(resp)
246
+
247
+ # Determine filename from Content-Disposition or provided name
248
+ if not filename:
249
+ cd = resp.headers.get("content-disposition", "")
250
+ for part in cd.split(";"):
251
+ part = part.strip()
252
+ if part.startswith("filename="):
253
+ filename = part.split("=", 1)[1].strip().strip('"')
254
+ break
255
+ if not filename:
256
+ filename = f"file_{file_id}"
257
+
258
+ dest = dest_dir / filename
259
+ dest_dir.mkdir(parents=True, exist_ok=True)
260
+
261
+ total = int(resp.headers.get("content-length", 0)) or None
262
+
263
+ with Progress(
264
+ SpinnerColumn(),
265
+ TextColumn("[bold green]{task.fields[filename]}"),
266
+ BarColumn(),
267
+ DownloadColumn(),
268
+ TransferSpeedColumn(),
269
+ TimeRemainingColumn(),
270
+ console=console,
271
+ transient=True,
272
+ ) as progress:
273
+ task = progress.add_task("download", filename=filename, total=total)
274
+ with open(dest, "wb") as f:
275
+ for chunk in resp.iter_bytes(_CHUNK):
276
+ f.write(chunk)
277
+ progress.update(task, advance=len(chunk))
278
+
279
+ return dest
280
+
281
+ def download_folder(self, folder_id: int, dest_dir: Path) -> Path:
282
+ """Download a folder as a zip archive."""
283
+ url = self._url(f"/download_folder/{folder_id}")
284
+ return self._stream_zip(url, dest_dir, f"folder_{folder_id}.zip")
285
+
286
+ def download_all(self, dest_dir: Path, node: str = "central",
287
+ folder_id: int | None = None) -> Path:
288
+ """Download all files in the current view as a zip."""
289
+ params: dict[str, Any] = {}
290
+ if folder_id is not None:
291
+ params["folder_id"] = folder_id
292
+ if node and node != "central":
293
+ params["node"] = node
294
+ url = self._url("/download_all")
295
+ return self._stream_zip(url, dest_dir, "mycloud_files.zip", params=params)
296
+
297
+ def _stream_zip(self, url: str, dest_dir: Path, default_name: str,
298
+ params: dict | None = None) -> Path:
299
+ with httpx.stream(
300
+ "GET", url, headers=self._headers(), params=params,
301
+ follow_redirects=True, timeout=None,
302
+ ) as resp:
303
+ self._raise(resp)
304
+ cd = resp.headers.get("content-disposition", "")
305
+ name = default_name
306
+ for part in cd.split(";"):
307
+ part = part.strip()
308
+ if part.startswith("filename="):
309
+ name = part.split("=", 1)[1].strip().strip('"')
310
+ break
311
+ dest_dir.mkdir(parents=True, exist_ok=True)
312
+ dest = dest_dir / name
313
+ total = int(resp.headers.get("content-length", 0)) or None
314
+ with Progress(
315
+ SpinnerColumn(), BarColumn(), DownloadColumn(),
316
+ console=console, transient=True,
317
+ ) as progress:
318
+ task = progress.add_task("zip", total=total)
319
+ with open(dest, "wb") as f:
320
+ for chunk in resp.iter_bytes(_CHUNK):
321
+ f.write(chunk)
322
+ progress.update(task, advance=len(chunk))
323
+ return dest
@@ -0,0 +1 @@
1
+ # cli/commands/__init__.py
cli/commands/auth.py ADDED
@@ -0,0 +1,234 @@
1
+ """commands/auth.py login, logout, whoami commands."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ import time
6
+ import webbrowser
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.prompt import Prompt
12
+ from rich import print as rprint
13
+
14
+ from cli.api import CloudClient, APIError
15
+ from cli.config import (
16
+ save_credentials, clear_credentials, get_base_url,
17
+ get_token, get_stored_user,
18
+ )
19
+
20
+ app = typer.Typer(help="Authentication login, logout, and account info.")
21
+ console = Console()
22
+
23
+
24
+ @app.command("login")
25
+ def login(
26
+ username: str = typer.Option(None, "--username", "-u", help="Username or email"),
27
+ password: str = typer.Option(None, "--password", "-p", help="Password (prefer interactive prompt)"),
28
+ server: str = typer.Option(None, "--server", "-s", help="Override server URL"),
29
+ ):
30
+ """Log in to myCloud and save credentials locally."""
31
+ base_url = server or get_base_url()
32
+ client = CloudClient(base_url=base_url, token="")
33
+
34
+ console.print(Panel.fit(
35
+ "[bold cyan]myCloud CLI[/bold cyan] - Sign in",
36
+ border_style="cyan",
37
+ ))
38
+ console.print(f"[dim]Server:[/dim] {base_url}\n")
39
+
40
+ identifier = username or Prompt.ask("[bold]Username or email[/]")
41
+ pwd = password or Prompt.ask("[bold]Password[/]", password=True)
42
+
43
+ try:
44
+ data = client.post("/api/auth/token", json={
45
+ "username": identifier,
46
+ "password": pwd,
47
+ "label": "CLI",
48
+ })
49
+ except APIError as e:
50
+ console.print(f"[red]- Login failed:[/red] {e}")
51
+ raise typer.Exit(1)
52
+
53
+ if data.get("two_factor_required"):
54
+ _handle_2fa(client, data["login_id"], base_url)
55
+ return
56
+
57
+ _store_and_greet(data, base_url)
58
+
59
+
60
+ def _handle_2fa(client: CloudClient, login_id: str, base_url: str) -> None:
61
+ """Open the 2FA email link in the browser and poll until verified."""
62
+ verify_url = f"{base_url}/2fa/verify"
63
+ console.print(
64
+ "\n[bold yellow] Two-factor authentication required.[/]\n"
65
+ "A verification email has been sent to your inbox.\n"
66
+ "Opening your browser so you can click the link\n"
67
+ )
68
+ try:
69
+ webbrowser.open(f"{base_url}/login")
70
+ except Exception:
71
+ pass
72
+
73
+ console.print("[dim]Waiting for you to click the email link[/dim]")
74
+
75
+ with console.status("[cyan]Polling for 2FA confirmation[/cyan]", spinner="dots"):
76
+ while True:
77
+ time.sleep(2)
78
+ try:
79
+ poll = client.get("/api/auth/2fa-poll", login_id=login_id, label="CLI")
80
+ except APIError as e:
81
+ console.print(f"[red]Poll error:[/] {e}")
82
+ raise typer.Exit(1)
83
+
84
+ status = poll.get("status")
85
+ if status == "verified":
86
+ _store_and_greet(poll, client.base_url)
87
+ return
88
+ elif status == "expired":
89
+ console.print("[red]- 2FA link expired. Please run login again.[/]")
90
+ raise typer.Exit(1)
91
+ elif status in ("invalid",):
92
+ console.print("[red]- 2FA session invalid.[/]")
93
+ raise typer.Exit(1)
94
+ # status == "pending" keep waiting
95
+
96
+
97
+ def _store_and_greet(data: dict, base_url: str) -> None:
98
+ user = data.get("user", {})
99
+ save_credentials({
100
+ "token": data["token"],
101
+ "expires_at": data.get("expires_at", ""),
102
+ "base_url": base_url,
103
+ "user": user,
104
+ })
105
+ console.print(
106
+ f"\n[bold green] Logged in as [cyan]{user.get('username', '?')}[/cyan]"
107
+ f" ({user.get('name', '')})[/bold green]"
108
+ )
109
+ console.print(f"[dim]Token expires: {data.get('expires_at', 'unknown')}[/dim]\n")
110
+ console.print("Run [bold]mycloud ls[/bold] to browse your files.")
111
+
112
+
113
+ @app.command("logout")
114
+ def logout():
115
+ """Revoke the current token and clear local credentials."""
116
+ token = get_token()
117
+ if not token:
118
+ console.print("[yellow]Not logged in.[/yellow]")
119
+ raise typer.Exit()
120
+
121
+ client = CloudClient()
122
+ try:
123
+ client.delete("/api/auth/token")
124
+ console.print("[green] Logged out (token revoked on server).[/green]")
125
+ except APIError:
126
+ console.print("[yellow]Could not reach server clearing local credentials anyway.[/yellow]")
127
+ finally:
128
+ clear_credentials()
129
+
130
+
131
+ @app.command("whoami")
132
+ def whoami():
133
+ """Show the currently authenticated user and storage usage."""
134
+ user = get_stored_user()
135
+ if not user:
136
+ console.print("[yellow]Not logged in. Run [bold]mycloud login[/bold].[/yellow]")
137
+ raise typer.Exit(1)
138
+
139
+ console.print(Panel(
140
+ f"[bold cyan]{user.get('name', user.get('username'))}[/bold cyan]\n"
141
+ f"[dim]Username:[/dim] {user.get('username', '?')}\n"
142
+ f"[dim]Email:[/dim] {user.get('email', '?')}\n"
143
+ f"[dim]Server:[/dim] {get_base_url()}",
144
+ title="[bold]Signed In[/bold]",
145
+ border_style="green",
146
+ ))
147
+
148
+
149
+ @app.command("register")
150
+ def register(
151
+ name: str = typer.Option(None, "--name", "-n", help="Full name"),
152
+ username: str = typer.Option(None, "--username", "-u", help="Username"),
153
+ email: str = typer.Option(None, "--email", "-e", help="Email address"),
154
+ password: str = typer.Option(None, "--password", "-p", help="Password"),
155
+ ):
156
+ """Register a new myCloud account."""
157
+ client = CloudClient(base_url=get_base_url(), token="")
158
+
159
+ console.print(Panel.fit(
160
+ "[bold cyan]myCloud CLI[/bold cyan] - Register",
161
+ border_style="cyan",
162
+ ))
163
+
164
+ _name = name or Prompt.ask("[bold]Full name[/]")
165
+ _username = username or Prompt.ask("[bold]Username[/]")
166
+ _email = email or Prompt.ask("[bold]Email[/]")
167
+ _pwd = password or Prompt.ask("[bold]Password[/]", password=True)
168
+ _pwd_confirm = Prompt.ask("[bold]Confirm Password[/]", password=True) if not password else password
169
+
170
+ if _pwd != _pwd_confirm:
171
+ console.print("[red]- Passwords do not match.[/red]")
172
+ raise typer.Exit(1)
173
+
174
+ try:
175
+ data = client.post("/register", json={
176
+ "name": _name,
177
+ "username": _username,
178
+ "email": _email,
179
+ "password": _pwd,
180
+ "confirm_password": _pwd_confirm
181
+ })
182
+ if data.get("success"):
183
+ console.print(f"[green][OK][/green] {data.get('message')}")
184
+ else:
185
+ console.print(f"[red][FAIL][/red] {data.get('error') or data.get('message', 'Registration failed')}")
186
+ except APIError as e:
187
+ console.print(f"[red]- Registration failed:[/red] {e}")
188
+
189
+
190
+ @app.command("forgot-password")
191
+ def forgot_password(
192
+ email: str = typer.Option(None, "--email", "-e", help="Account email address"),
193
+ ):
194
+ """Request a password reset link."""
195
+ client = CloudClient(base_url=get_base_url(), token="")
196
+
197
+ _email = email or Prompt.ask("[bold]Email[/]")
198
+
199
+ try:
200
+ data = client.post("/forgot_password", json={"email": _email})
201
+ if data.get("success"):
202
+ console.print(f"[green][OK][/green] {data.get('message')}")
203
+ else:
204
+ console.print(f"[red][FAIL][/red] {data.get('error', 'Request failed')}")
205
+ except APIError as e:
206
+ console.print(f"[red]- Request failed:[/red] {e}")
207
+
208
+
209
+ @app.command("reset-password")
210
+ def reset_password(
211
+ token: str = typer.Argument(..., help="Reset token from your email"),
212
+ password: str = typer.Option(None, "--password", "-p", help="New Password"),
213
+ ):
214
+ """Reset your password using a token."""
215
+ client = CloudClient(base_url=get_base_url(), token="")
216
+
217
+ _pwd = password or Prompt.ask("[bold]New Password[/]", password=True)
218
+ _pwd_confirm = Prompt.ask("[bold]Confirm Password[/]", password=True) if not password else password
219
+
220
+ if _pwd != _pwd_confirm:
221
+ console.print("[red]- Passwords do not match.[/red]")
222
+ raise typer.Exit(1)
223
+
224
+ try:
225
+ data = client.post(f"/reset_password/{token}", json={
226
+ "password": _pwd,
227
+ "confirm_password": _pwd_confirm
228
+ })
229
+ if data.get("success"):
230
+ console.print(f"[green][OK][/green] {data.get('message')}")
231
+ else:
232
+ console.print(f"[red][FAIL][/red] {data.get('error', 'Reset failed')}")
233
+ except APIError as e:
234
+ console.print(f"[red]- Reset failed:[/red] {e}")