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/commands/share.py ADDED
@@ -0,0 +1,392 @@
1
+ """commands/share.py -- share, unshare, shared-with-me, accept, decline, link, copy, mute."""
2
+ from __future__ import annotations
3
+ from typing import List, Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich import box
9
+
10
+ from cli.api import CloudClient, APIError
11
+ from cli.ui.table import shares_table
12
+
13
+ app = typer.Typer(help="Sharing -- share files/folders, manage invites and public links.")
14
+ console = Console()
15
+
16
+
17
+ def _client() -> CloudClient:
18
+ return CloudClient()
19
+
20
+
21
+ # ------------------------------------------------------------------ #
22
+ # send (invite)
23
+ # ------------------------------------------------------------------ #
24
+
25
+ @app.command("send")
26
+ def share_send(
27
+ item_id: int = typer.Argument(..., help="File or folder ID to share"),
28
+ user: str = typer.Option(..., "--user", "-u", help="Username or email of recipient"),
29
+ folder: bool = typer.Option(False, "--folder", "-f", help="ID is a folder"),
30
+ permission: str = typer.Option("read", "--permission", "-p", help="read | write"),
31
+ ):
32
+ """Share a file or folder with another user."""
33
+ client = _client()
34
+ try:
35
+ if folder:
36
+ result = client.post(f"/share_folder/{item_id}", json={"shared_with": user, "permission": permission})
37
+ else:
38
+ result = client.post(f"/share_file/{item_id}", json={"shared_with": user, "permission": permission})
39
+ if result.get("success"):
40
+ console.print(f"[green][OK][/] Shared with [bold]{user}[/bold] ({permission}).")
41
+ else:
42
+ console.print(f"[red][FAIL][/] {result.get('message') or 'Error'}")
43
+ except APIError as e:
44
+ console.print(f"[red]-[/] {e}")
45
+
46
+
47
+ # ------------------------------------------------------------------ #
48
+ # link (public link)
49
+ # ------------------------------------------------------------------ #
50
+
51
+ @app.command("link")
52
+ def share_link(
53
+ item_id: int = typer.Argument(..., help="File or folder ID"),
54
+ folder: bool = typer.Option(False, "--folder", "-f", help="ID is a folder"),
55
+ ):
56
+ """Generate a public share link for a file or folder."""
57
+ client = _client()
58
+ try:
59
+ if folder:
60
+ result = client.post(f"/generate_folder_share_link/{item_id}")
61
+ else:
62
+ result = client.post(f"/generate_share_link/{item_id}")
63
+ link = result.get("link") or result.get("share_link") or result.get("url") or result.get("share_url")
64
+ if link:
65
+ console.print(f"[green][OK][/] Share link:\n[bold cyan]{link}[/bold cyan]")
66
+ else:
67
+ console.print(f"[red][FAIL][/] {result.get('message', 'Could not generate link')}")
68
+ except APIError as e:
69
+ console.print(f"[red]-[/] {e}")
70
+
71
+
72
+ # ------------------------------------------------------------------ #
73
+ # disable-link
74
+ # ------------------------------------------------------------------ #
75
+
76
+ @app.command("disable-link")
77
+ def share_disable_link(
78
+ item_id: int = typer.Argument(..., help="File or folder ID"),
79
+ item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
80
+ ):
81
+ """Disable/revoke the public share link for a file or folder."""
82
+ client = _client()
83
+ try:
84
+ result = client.post("/api/share/disable_link", json={"item_id": item_id, "item_type": item_type})
85
+ if result.get("success"):
86
+ console.print("[green][OK][/] Share link disabled.")
87
+ else:
88
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
89
+ except APIError as e:
90
+ console.print(f"[red]-[/] {e}")
91
+
92
+
93
+ # ------------------------------------------------------------------ #
94
+ # info
95
+ # ------------------------------------------------------------------ #
96
+
97
+ @app.command("info")
98
+ def share_info(
99
+ item_id: int = typer.Argument(..., help="File or folder ID"),
100
+ item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
101
+ ):
102
+ """Show share details for a file or folder."""
103
+ client = _client()
104
+ try:
105
+ result = client.get(f"/api/share_details/{item_type}/{item_id}")
106
+ rows = result.get("shares", []) or result.get("data", [])
107
+ if not rows:
108
+ console.print("[dim]Not shared with anyone.[/dim]")
109
+ return
110
+ console.print(shares_table(rows))
111
+ except APIError as e:
112
+ console.print(f"[red]-[/] {e}")
113
+
114
+
115
+ # ------------------------------------------------------------------ #
116
+ # update-perm
117
+ # ------------------------------------------------------------------ #
118
+
119
+ @app.command("update-perm")
120
+ def share_update_perm(
121
+ item_id: int = typer.Argument(..., help="File or folder ID"),
122
+ user_id: int = typer.Option(..., "--user-id", help="User ID whose permission to change"),
123
+ permission: str = typer.Option(..., "--permission", "-p", help="read | write"),
124
+ item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
125
+ ):
126
+ """Update a share's permission level."""
127
+ client = _client()
128
+ try:
129
+ result = client.post("/api/share/update_permissions", json={
130
+ "item_id": item_id,
131
+ "item_type": item_type,
132
+ "shared_with_user_id": user_id,
133
+ "permission": permission,
134
+ })
135
+ if result.get("success"):
136
+ console.print(f"[green][OK][/] Permission updated to '{permission}'.")
137
+ else:
138
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
139
+ except APIError as e:
140
+ console.print(f"[red]-[/] {e}")
141
+
142
+
143
+ # ------------------------------------------------------------------ #
144
+ # rm (remove share)
145
+ # ------------------------------------------------------------------ #
146
+
147
+ @app.command("rm")
148
+ def share_rm(
149
+ item_id: int = typer.Argument(..., help="File or folder ID"),
150
+ item_type: str = typer.Option("file", "--type", "-t", help="'file' or 'folder'"),
151
+ shared_with_id: Optional[int] = typer.Option(None, "--with-id", help="Specific user ID to un-share"),
152
+ ):
153
+ """Remove a share from a file or folder."""
154
+ client = _client()
155
+ try:
156
+ result = client.post("/api/share/remove", json={
157
+ "item_type": item_type,
158
+ "item_id": item_id,
159
+ "shared_with_user_id": shared_with_id,
160
+ })
161
+ if result.get("success"):
162
+ console.print("[green][OK][/] Share removed.")
163
+ else:
164
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
165
+ except APIError as e:
166
+ console.print(f"[red]-[/] {e}")
167
+
168
+
169
+ # ------------------------------------------------------------------ #
170
+ # copy (add shared file to my cloud)
171
+ # ------------------------------------------------------------------ #
172
+
173
+ @app.command("copy")
174
+ def share_copy(
175
+ file_id: int = typer.Argument(..., help="Shared file ID to copy to your cloud"),
176
+ ):
177
+ """Copy a shared file into your own myCloud storage."""
178
+ client = _client()
179
+ try:
180
+ result = client.post(f"/add_shared_to_mycloud/{file_id}")
181
+ if result.get("success"):
182
+ console.print(f"[green][OK][/] {result.get('message', 'File copied to your cloud.')}")
183
+ else:
184
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
185
+ except APIError as e:
186
+ console.print(f"[red]-[/] {e}")
187
+
188
+
189
+ # ------------------------------------------------------------------ #
190
+ # copy-folder (add shared folder to my cloud)
191
+ # ------------------------------------------------------------------ #
192
+
193
+ @app.command("copy-folder")
194
+ def share_copy_folder(
195
+ folder_id: int = typer.Argument(..., help="Shared folder ID to copy to your cloud"),
196
+ ):
197
+ """Copy a shared folder (and contents) into your own myCloud storage."""
198
+ client = _client()
199
+ try:
200
+ result = client.post(f"/copy_shared_folder/{folder_id}")
201
+ if result.get("success"):
202
+ console.print(f"[green][OK][/] {result.get('message', 'Folder copied to your cloud.')}")
203
+ else:
204
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
205
+ except APIError as e:
206
+ console.print(f"[red]-[/] {e}")
207
+
208
+
209
+ # ------------------------------------------------------------------ #
210
+ # with-me
211
+ # ------------------------------------------------------------------ #
212
+
213
+ @app.command("with-me")
214
+ def shared_with_me():
215
+ """List all files and folders shared with you."""
216
+ client = _client()
217
+ try:
218
+ data = client.get("/shared_files")
219
+ except APIError as e:
220
+ console.print(f"[red]-[/] {e}")
221
+ raise typer.Exit(1)
222
+
223
+ files = data.get("shared_files", []) or data.get("files", [])
224
+ folders = data.get("shared_folders", []) or data.get("folders", [])
225
+
226
+ from cli.ui.table import files_table, folders_table
227
+ if folders:
228
+ console.print(folders_table(folders, title="Folders Shared With Me"))
229
+ if files:
230
+ console.print(files_table(files, title="Files Shared With Me"))
231
+ if not files and not folders:
232
+ console.print("[dim]Nothing shared with you yet.[/dim]")
233
+
234
+
235
+ # ------------------------------------------------------------------ #
236
+ # Invites -- list / accept / decline
237
+ # ------------------------------------------------------------------ #
238
+
239
+ @app.command("invites")
240
+ def share_invites():
241
+ """List pending share invites."""
242
+ client = _client()
243
+ try:
244
+ result = client.get("/api/share_invites/details")
245
+ except APIError as e:
246
+ console.print(f"[red]-[/] {e}")
247
+ raise typer.Exit(1)
248
+
249
+ invites = result.get("invites", []) or result.get("data", [])
250
+ if not invites:
251
+ console.print("[dim]No pending share invites.[/dim]")
252
+ return
253
+
254
+ t = Table(title="Pending Share Invites", box=box.ROUNDED, header_style="bold cyan")
255
+ t.add_column("Token", style="dim", no_wrap=True)
256
+ t.add_column("From")
257
+ t.add_column("Item")
258
+ t.add_column("Type")
259
+
260
+ for inv in invites:
261
+ t.add_row(
262
+ inv.get("request_token") or inv.get("token") or "-",
263
+ inv.get("sender_username") or inv.get("sender_email") or "?",
264
+ str(inv.get("item_id", "?")),
265
+ inv.get("item_type", "?"),
266
+ )
267
+ console.print(t)
268
+
269
+
270
+ @app.command("accept")
271
+ def share_accept(
272
+ token: str = typer.Argument(..., help="Share invite token"),
273
+ ):
274
+ """Accept a share invite."""
275
+ client = _client()
276
+ try:
277
+ result = client.post("/api/share_invites/respond", json={"token": token, "action": "accept"})
278
+ if result.get("success"):
279
+ console.print("[green][OK][/] Invite accepted.")
280
+ else:
281
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
282
+ except APIError as e:
283
+ console.print(f"[red]-[/] {e}")
284
+
285
+
286
+ @app.command("decline")
287
+ def share_decline(
288
+ token: str = typer.Argument(..., help="Share invite token"),
289
+ ):
290
+ """Decline a share invite."""
291
+ client = _client()
292
+ try:
293
+ result = client.post("/api/share_invites/respond", json={"token": token, "action": "decline"})
294
+ if result.get("success"):
295
+ console.print("[green][OK][/] Invite declined.")
296
+ else:
297
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
298
+ except APIError as e:
299
+ console.print(f"[red]-[/] {e}")
300
+
301
+
302
+ # ------------------------------------------------------------------ #
303
+ # Mute / unmute senders
304
+ # ------------------------------------------------------------------ #
305
+
306
+ @app.command("mute")
307
+ def share_mute(
308
+ identifier: str = typer.Argument(..., help="Username or email to mute share invites from"),
309
+ ):
310
+ """Mute share invites from a user."""
311
+ client = _client()
312
+ try:
313
+ result = client.post("/api/share_invites/mute", json={"identifier": identifier})
314
+ if result.get("success"):
315
+ console.print(f"[green][OK][/] Muted share invites from {identifier}.")
316
+ else:
317
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
318
+ except APIError as e:
319
+ console.print(f"[red]-[/] {e}")
320
+
321
+
322
+ @app.command("unmute")
323
+ def share_unmute(
324
+ muted_user_id: int = typer.Argument(..., help="User ID to unmute"),
325
+ ):
326
+ """Unmute share invites from a user."""
327
+ client = _client()
328
+ try:
329
+ result = client.post("/api/share_invites/unmute", json={"muted_user_id": muted_user_id})
330
+ if result.get("success"):
331
+ console.print(f"[green][OK][/] Unmuted user {muted_user_id}.")
332
+ else:
333
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
334
+ except APIError as e:
335
+ console.print(f"[red]-[/] {e}")
336
+
337
+
338
+ @app.command("muted")
339
+ def share_muted():
340
+ """List users whose share invites you have muted."""
341
+ client = _client()
342
+ try:
343
+ result = client.get("/api/share_invites/muted_users")
344
+ except APIError as e:
345
+ console.print(f"[red]-[/] {e}")
346
+ raise typer.Exit(1)
347
+
348
+ users = result.get("muted_users") or result.get("data", [])
349
+ if not users:
350
+ console.print("[dim]No muted users.[/dim]")
351
+ return
352
+
353
+ t = Table(title="Muted Users", box=box.ROUNDED, header_style="bold yellow")
354
+ t.add_column("User ID", style="dim", justify="right")
355
+ t.add_column("Username")
356
+ for u in users:
357
+ t.add_row(str(u.get("id", "?")), u.get("username") or u.get("email") or "?")
358
+ console.print(t)
359
+
360
+
361
+ @app.command("claim-link")
362
+ def share_claim_link(
363
+ token: str = typer.Argument(..., help="Public share token to claim"),
364
+ ):
365
+ """Claim a public share link using its token."""
366
+ client = _client()
367
+ endpoints = [
368
+ "/add_share_from_token",
369
+ "/add_folder_share_from_token",
370
+ "/add_batch_share_from_token"
371
+ ]
372
+
373
+ success = False
374
+ errors = []
375
+
376
+ for endpoint in endpoints:
377
+ try:
378
+ result = client.post(endpoint, json={"token": token})
379
+ if result.get("success"):
380
+ console.print(f"[green][OK][/] {result.get('message', 'Share link claimed successfully.')}")
381
+ success = True
382
+ break
383
+ except APIError as e:
384
+ msg = str(e)
385
+ if "Invalid or expired" not in msg:
386
+ errors.append(msg)
387
+
388
+ if not success:
389
+ if errors:
390
+ console.print(f"[red][FAIL][/] {errors[0]}")
391
+ else:
392
+ console.print("[red][FAIL][/] Token is invalid or expired.")
cli/commands/stash.py ADDED
@@ -0,0 +1,92 @@
1
+ """commands/stash.py stash add, ls, pop commands."""
2
+ from __future__ import annotations
3
+ from typing import List, Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich import box
9
+
10
+ from cli.api import CloudClient, APIError
11
+
12
+ app = typer.Typer(help="Stash temporary file storage with auto-expiry.")
13
+ console = Console()
14
+
15
+
16
+ def _client() -> CloudClient:
17
+ return CloudClient()
18
+
19
+
20
+ @app.command("ls")
21
+ def stash_ls():
22
+ """List your stashed files."""
23
+ client = _client()
24
+ try:
25
+ data = client.get("/stash_page")
26
+ except APIError as e:
27
+ console.print(f"[red]-[/] {e}")
28
+ raise typer.Exit(1)
29
+
30
+ files = data.get("stashed_files", []) or data.get("files", [])
31
+ if not files:
32
+ console.print("[dim]No stashed files.[/dim]")
33
+ return
34
+
35
+ t = Table(title="Stashed Files", box=box.ROUNDED, header_style="bold yellow")
36
+ t.add_column("ID", justify="right", style="dim")
37
+ t.add_column("Name")
38
+ t.add_column("Size", justify="right", style="green")
39
+ t.add_column("Expires", style="dim")
40
+
41
+ for f in files:
42
+ t.add_row(
43
+ str(f.get("id", "?")),
44
+ f.get("filename") or f.get("name") or f.get("original_filename") or "-",
45
+ _fmt(f.get("size") or f.get("file_size") or 0),
46
+ str(f.get("stash_expiry_at") or "")[:16],
47
+ )
48
+ console.print(t)
49
+
50
+
51
+ @app.command("add")
52
+ def stash_add(
53
+ file_ids: List[int] = typer.Argument(..., help="File ID(s) to stash"),
54
+ days: int = typer.Option(7, "--days", "-d", help="Expiry in days"),
55
+ ):
56
+ """Move file(s) to the stash (temporary storage with expiry)."""
57
+ client = _client()
58
+ for fid in file_ids:
59
+ try:
60
+ result = client.post("/api/batch_stash", json={"file_ids": [fid], "days": days})
61
+ if result.get("success"):
62
+ console.print(f"[green][OK][/] File {fid} stashed for {days} day(s).")
63
+ else:
64
+ console.print(f"[red][FAIL][/] File {fid}: {result.get('message', 'Error')}")
65
+ except APIError as e:
66
+ console.print(f"[red]-[/] File {fid}: {e}")
67
+
68
+
69
+ @app.command("pop")
70
+ def stash_pop(
71
+ file_ids: List[int] = typer.Argument(..., help="File ID(s) to unstash"),
72
+ ):
73
+ """Restore file(s) from the stash back to your storage."""
74
+ client = _client()
75
+ for fid in file_ids:
76
+ try:
77
+ result = client.post("/api/batch_unstash", json={"file_ids": [fid]})
78
+ if result.get("success"):
79
+ console.print(f"[green][OK][/] File {fid} restored from stash.")
80
+ else:
81
+ console.print(f"[red][FAIL][/] File {fid}: {result.get('message', 'Error')}")
82
+ except APIError as e:
83
+ console.print(f"[red]-[/] File {fid}: {e}")
84
+
85
+
86
+ def _fmt(b: int) -> str:
87
+ import math
88
+ units = ["B", "KB", "MB", "GB"]
89
+ if b == 0:
90
+ return "0 B"
91
+ idx = min(int(math.log(max(b, 1), 1024)), len(units) - 1)
92
+ return f"{b / (1024**idx):.1f} {units[idx]}"
@@ -0,0 +1,88 @@
1
+ """commands/storage.py -- storage stats and nuke commands."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich import box
9
+
10
+ from cli.api import CloudClient, APIError
11
+
12
+ app = typer.Typer(help="Storage -- view stats and manage your storage quota.")
13
+ console = Console()
14
+
15
+
16
+ def _client() -> CloudClient:
17
+ return CloudClient()
18
+
19
+
20
+ def _fmt(b: int | float | None) -> str:
21
+ import math
22
+ if b is None:
23
+ return "-"
24
+ b = float(b)
25
+ if b == 0:
26
+ return "0 B"
27
+ units = ["B", "KB", "MB", "GB", "TB"]
28
+ idx = min(int(math.log(b, 1024)), len(units) - 1)
29
+ return f"{b / (1024 ** idx):.1f} {units[idx]}"
30
+
31
+
32
+ @app.command("stats")
33
+ def stats():
34
+ """Show your storage usage and account statistics."""
35
+ client = _client()
36
+ try:
37
+ data = client.get("/api/user/stats")
38
+ except APIError as e:
39
+ console.print(f"[red][FAIL][/] {e}")
40
+ raise typer.Exit(1)
41
+
42
+ used = int(data.get("storage_used") or 0)
43
+ total = int(data.get("storage_limit") or 0)
44
+ limit_label = data.get("storage_limit_label") or (_fmt(total) if total else "Unlimited")
45
+ files = int(data.get("file_count") or 0)
46
+ folders = int(data.get("folder_count") or 0)
47
+ stashed = int(data.get("stash_count") or 0)
48
+ shared = int(data.get("shared_count") or 0)
49
+
50
+ pct = f"{(used / total * 100):.1f}%" if total else "N/A"
51
+
52
+ bar_width = 30
53
+ filled = int((used / total) * bar_width) if total else 0
54
+ bar = "[green]" + "#" * filled + "[/][dim]" + "-" * (bar_width - filled) + "[/]"
55
+
56
+ console.print(Panel(
57
+ f"[bold]Storage Used:[/bold] {_fmt(used)} / {limit_label} ({pct})\n"
58
+ f"{bar}\n\n"
59
+ f"[dim]Files:[/dim] {files}\n"
60
+ f"[dim]Folders:[/dim] {folders}\n"
61
+ f"[dim]Stashed:[/dim] {stashed}\n"
62
+ f"[dim]Shared:[/dim] {shared}",
63
+ title="[bold cyan]myCloud Storage[/bold cyan]",
64
+ border_style="cyan",
65
+ ))
66
+
67
+
68
+ @app.command("nuke")
69
+ def nuke(
70
+ target: str = typer.Option("all", "--target", "-t",
71
+ help="What to delete: 'all', 'central', or a node ID"),
72
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
73
+ ):
74
+ """[DANGER] Delete ALL files and folders. Cannot be undone."""
75
+ if not yes:
76
+ console.print("[bold red][WARN] This will permanently delete ALL your files and folders![/]")
77
+ typer.confirm("Are you absolutely sure?", abort=True)
78
+ typer.confirm("This CANNOT be undone. Proceed?", abort=True)
79
+
80
+ client = _client()
81
+ try:
82
+ result = client.post("/delete_all_files", data={"target_node": target})
83
+ if result.get("success"):
84
+ console.print("[green][OK][/] All files and folders deleted.")
85
+ else:
86
+ console.print(f"[red][FAIL][/] {result.get('message', 'Error')}")
87
+ except APIError as e:
88
+ console.print(f"[red][FAIL][/] {e}")
cli/config.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ config.py myCloud CLI configuration and credential management.
3
+
4
+ Credentials are stored in ~/.mycloud/credentials.json.
5
+ The server base URL is read from:
6
+ 1. $MYCLOUD_URL environment variable
7
+ 2. ~/.mycloud/config.toml [url = "https://..."]
8
+ 3. Falls back to the default production URL.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Paths
18
+ # ---------------------------------------------------------------------------
19
+ CONFIG_DIR = Path.home() / ".mycloud"
20
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
21
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
22
+
23
+ DEFAULT_URL = "https://cloud.mysphere.co.in"
24
+
25
+
26
+ def get_base_url() -> str:
27
+ """Return the server base URL (trailing slash stripped)."""
28
+ # 1. Environment variable
29
+ env = os.environ.get("MYCLOUD_URL", "").strip()
30
+ if env:
31
+ return env.rstrip("/")
32
+
33
+ # 2. Try credentials.json first since `login` saves it there
34
+ creds = load_credentials()
35
+ if creds.get("base_url"):
36
+ return creds["base_url"].rstrip("/")
37
+
38
+ # 3. Config file (minimal TOML just look for `url = "..."`)
39
+ if CONFIG_FILE.exists():
40
+ for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
41
+ line = line.strip()
42
+ if line.startswith("url") and "=" in line:
43
+ value = line.split("=", 1)[1].strip().strip('"').strip("'")
44
+ if value:
45
+ return value.rstrip("/")
46
+
47
+ return DEFAULT_URL
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Credentials
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def load_credentials() -> dict:
55
+ """Return stored credentials dict or empty dict."""
56
+ if CREDENTIALS_FILE.exists():
57
+ try:
58
+ return json.loads(CREDENTIALS_FILE.read_text(encoding="utf-8"))
59
+ except Exception:
60
+ return {}
61
+ return {}
62
+
63
+
64
+ def save_credentials(data: dict) -> None:
65
+ """Persist credentials to disk (mode 600)."""
66
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
67
+ CREDENTIALS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
68
+ try:
69
+ CREDENTIALS_FILE.chmod(0o600)
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def clear_credentials() -> None:
75
+ """Delete stored credentials."""
76
+ if CREDENTIALS_FILE.exists():
77
+ CREDENTIALS_FILE.unlink()
78
+
79
+
80
+ def get_token() -> str:
81
+ """Return the stored bearer token, or empty string."""
82
+ return load_credentials().get("token", "")
83
+
84
+
85
+ def get_stored_user() -> dict:
86
+ """Return the stored user info dict."""
87
+ return load_credentials().get("user", {})
cli/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # cli/ui/__init__.py