akashcli 3.0.0__tar.gz → 3.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: Enterprise Developer SDK and CLI for Akash Vault
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -8,6 +8,7 @@ Requires-Dist: httpx
8
8
  Requires-Dist: typer[all]
9
9
  Requires-Dist: rich
10
10
  Requires-Dist: cryptography
11
+ Requires-Dist: questionary
11
12
 
12
13
  To get started with **AkashCLI**, your users should follow these steps. You can provide this guide as a `README.md` or a "Getting Started" message in your bot.
13
14
 
@@ -0,0 +1,377 @@
1
+ import typer
2
+ import time
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import webbrowser
7
+ import httpx
8
+ import questionary
9
+ from typing import Optional
10
+ from pathlib import Path
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, DownloadColumn, TransferSpeedColumn, FileSizeColumn, TimeRemainingColumn
14
+ from rich.panel import Panel
15
+ from .utils import get_file_emoji
16
+ # Internal Imports
17
+ from .client import AkashClient
18
+ from .auth import AuthManager
19
+ from .config import ConfigManager
20
+ from .exceptions import AkashError
21
+ from .uploader import VaultUploader
22
+
23
+ # 🌟 Removed the 'gh' overrides and panels
24
+ app = typer.Typer(help="🛡️ Akash Vault Enterprise CLI", add_completion=True)
25
+ console = Console()
26
+
27
+ @app.command()
28
+ def login(
29
+ url: str = typer.Option("https://jstore.2bd.net", "--url", help="Custom Vault URL"),
30
+ key: str = typer.Option(..., prompt="🔑 Enter API Key", hide_input=True)
31
+ ):
32
+ """🔑 Link your device to the vault."""
33
+ try:
34
+ with console.status("[bold blue]Verifying identity..."):
35
+ user = AuthManager.perform_login(key, url)
36
+
37
+ console.print(f"[green]✓ Login Successful![/green] Owner: {user.owner}")
38
+ except Exception as e:
39
+ console.print(f"[bold red]✗ Authentication Failed:[/bold red] {e}")
40
+
41
+ @app.command()
42
+ def whoami():
43
+ """👤 Check current login and quota status."""
44
+ try:
45
+ client = AkashClient()
46
+ user = client.whoami()
47
+ console.print(f"Logged in as: [bold cyan]{user.owner}[/bold cyan]")
48
+ console.print(f"Storage Quota: [bold yellow]{user.quota_used}/{user.quota_limit}[/bold yellow]")
49
+ except Exception as e:
50
+ console.print(f"[red]Error:[/red] {e}")
51
+
52
+ @app.command()
53
+ def upload(
54
+ path: Optional[Path] = typer.Argument(None, help="Path to file"),
55
+ text: Optional[str] = typer.Option(None, "--text", "-t", help="Upload raw text content"),
56
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Custom filename"),
57
+ burn: bool = typer.Option(False, "--burn", help="Self-destruct after one view"),
58
+ expiry: int = typer.Option(0, "--expiry", "-e", help="Expiry in days (0 for forever)"),
59
+ password: Optional[str] = typer.Option(None, "--pass", "-p", help="Password protect file")
60
+ ):
61
+ """📤 Vault content and display full file details."""
62
+ try:
63
+ client = AkashClient()
64
+
65
+ # 1. Prepare Metadata
66
+ file_size = os.path.getsize(path) if path else len(text.encode())
67
+ display_name = name or (path.name if path else f"snippet_{int(time.time())}.txt")
68
+ icon = get_file_emoji(display_name)
69
+
70
+ # 2. PHASE 1: Client -> Server (Live Progress)
71
+ with Progress(
72
+ SpinnerColumn(),
73
+ TextColumn("[bold cyan]{task.description}"),
74
+ BarColumn(bar_width=None, pulse_style="blue"),
75
+ "[progress.percentage]{task.percentage:>3.1f}%",
76
+ DownloadColumn(), # 🌟 THE FIX: Use this instead of FileSize/TotalSize
77
+ TransferSpeedColumn(),
78
+ TimeRemainingColumn(),
79
+ console=console,
80
+ transient=True
81
+ ) as progress:
82
+
83
+ task = progress.add_task(description=f"Sending {display_name}", total=file_size)
84
+
85
+ def update_bar(current, total):
86
+ progress.update(task, completed=current)
87
+
88
+ if text:
89
+ res = client.upload_text(text, filename=name, burn=burn, expiry=expiry, password=password)
90
+ else:
91
+ if not path:
92
+ console.print("[red]Error:[/red] Provide a file path or use --text"); return
93
+ res = client.upload_file(str(path), burn=burn, expiry=expiry, password=password, progress_callback=update_bar)
94
+
95
+ # 3. PHASE 2: Server -> Telegram (Sync Monitoring)
96
+ file_code = res.file_code
97
+ with console.status(f"[bold yellow]🧬 Syncing to Cloud Vault (Code: {file_code})...") as status:
98
+ while True:
99
+ sync_status = client.get_upload_status(file_code)
100
+ if sync_status['status'] == "done":
101
+ break
102
+ elif sync_status['status'] == "error":
103
+ console.print(f"[bold red]❌ Cloud Sync Failed:[/bold red] {sync_status.get('detail')}")
104
+ return
105
+ status.update(f"[bold yellow]🧬 Cloud Syncing: [white]{sync_status.get('status', 'processing')}[/white]...[/bold yellow]")
106
+ time.sleep(2)
107
+
108
+ # 4. PHASE 3: THE SUCCESS DASHBOARD
109
+ size_mb = f"{file_size / (1024*1024):.2f} MB"
110
+
111
+ # Create a grid for aligned metadata
112
+ summary = Table.grid(padding=(0, 2))
113
+ summary.add_row("[bold cyan]NAME[/bold cyan]", f"{icon} {display_name}")
114
+ summary.add_row("[bold cyan]CODE[/bold cyan]", f"[bold yellow]{file_code}[/bold yellow]")
115
+ summary.add_row("[bold cyan]SIZE[/bold cyan]", size_mb)
116
+ summary.add_row("[bold cyan]DNA[/bold cyan]", f"[dim]{res.dna.get('sha256', 'N/A')[:32]}...[/dim]")
117
+
118
+ # Security details
119
+ sec_info = f"🔥 Burn: {'ON' if burn else 'OFF'} | ⏳ Expire: {expiry if expiry > 0 else '∞'}d"
120
+ summary.add_row("[bold cyan]SECURITY[/bold cyan]", sec_info)
121
+
122
+ # Print the main panel
123
+ console.print("\n")
124
+ console.print(Panel(
125
+ summary,
126
+ title="[bold green]✓ Vault Sync Successful[/bold green]",
127
+ border_style="green",
128
+ expand=False,
129
+ padding=(1, 3)
130
+ ))
131
+
132
+ # Show actionable links
133
+ console.print(f"🖼️ [bold]Web Player:[/bold] [blue underline]{res.embed_url}[/blue underline]")
134
+ console.print(f"🎬 [bold]Stream Link:[/bold] [blue underline]{res.direct_url}[/blue underline]")
135
+ console.print("\n[dim]💡 Tip: Use 'akash share' to generate a 6-digit access code for this file.[/dim]")
136
+
137
+ except Exception as e:
138
+ console.print(f"[bold red]✗ Failed:[/bold red] {e}")
139
+
140
+ @app.command()
141
+ def stream(code: str, vlc: bool = typer.Option(False, "--vlc", help="Open directly in VLC")):
142
+ """🎬 Get a secure link or play in VLC (Handles processing state)."""
143
+ client = AkashClient()
144
+ try:
145
+ # We use a status spinner if the file is still processing on the server
146
+ with console.status("[bold cyan]Requesting secure stream...") as status:
147
+ while True:
148
+ try:
149
+ resp = client.generate_secure_link(code)
150
+ if resp.get('status') == 'success':
151
+ url = resp['stream_url']
152
+ break
153
+ except Exception as e:
154
+ # If server returns 202 (Accepted/Processing)
155
+ if "202" in str(e):
156
+ status.update("[bold yellow]⏳ File is still vaulting to cloud. Waiting...")
157
+ time.sleep(3)
158
+ continue
159
+ raise e
160
+
161
+ if vlc:
162
+ console.print(f"🎬 [bold blue]Launching VLC...[/bold blue]")
163
+ subprocess.Popen(['vlc', url], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
164
+ else:
165
+ console.print(Panel(f"[bold cyan]Secure Stream URL (30s):[/bold cyan]\n{url}", border_style="blue", title="Media Pipe"))
166
+ except Exception as e:
167
+ console.print(f"[red]Error:[/red] {e}")
168
+
169
+ @app.command()
170
+ def files(json_output: bool = typer.Option(False, "--json", help="Output in raw JSON")):
171
+ """📦 List and search files in your vault."""
172
+ try:
173
+ client = AkashClient()
174
+ file_list = client.list_files()
175
+
176
+ if json_output:
177
+ console.print_json(data=[f.__dict__ for f in file_list])
178
+ return
179
+
180
+ table = Table(title="📦 Encrypted Vault")
181
+ table.add_column("Filename", style="cyan")
182
+ table.add_column("Code", style="yellow")
183
+ table.add_column("Size", justify="right")
184
+
185
+ for f in file_list:
186
+ size_mb = f"{f.size / (1024*1024):.2f} MB"
187
+ table.add_row(f.name, f.code, size_mb)
188
+
189
+ console.print(table)
190
+ except Exception as e:
191
+ console.print(f"[red]Error:[/red] {e}")
192
+
193
+ @app.command()
194
+ def download(
195
+ code: str,
196
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Custom save path")
197
+ ):
198
+ """📥 Download a file from the vault to your local machine."""
199
+ try:
200
+ client = AkashClient()
201
+ # Attach the console to the client for the progress bar
202
+ client.console = console
203
+
204
+ from .downloader import download_file
205
+ path = download_file(client, code, output)
206
+
207
+ console.print(f"[bold green]✓ Download Complete![/bold green]")
208
+ console.print(f"📍 Saved to: [cyan]{path}[/cyan]")
209
+ except Exception as e:
210
+ console.print(f"[bold red]✗ Download Failed:[/bold red] {e}")
211
+
212
+ @app.command()
213
+ def delete(code: str):
214
+ """🗑️ Permanently remove a file."""
215
+ if typer.confirm(f"Are you sure you want to delete {code}?"):
216
+ try:
217
+ client = AkashClient()
218
+ client.delete_file(code)
219
+ console.print(f"[green]✓ Deleted {code}[/green]")
220
+ except Exception as e:
221
+ console.print(f"[red]Error:[/red] {e}")
222
+
223
+ @app.command()
224
+ def manage(
225
+ code: str,
226
+ name: Optional[str] = typer.Option(None, "--name"),
227
+ burn: Optional[bool] = typer.Option(None, "--burn/--no-burn"),
228
+ expiry: Optional[int] = typer.Option(None, "--expiry")
229
+ ):
230
+ """⚙️ Modify settings for an existing file."""
231
+ try:
232
+ client = AkashClient()
233
+ updates = {}
234
+ if name: updates['filename'] = name
235
+ if burn is not None: updates['burn_on_read'] = 1 if burn else 0
236
+ if expiry is not None: updates['expiry_days'] = expiry
237
+ client.update_file(code, **updates)
238
+ console.print(f"[green]✓ Updated {code}[/green]")
239
+ except Exception as e:
240
+ console.print(f"[red]Error:[/red] {e}")
241
+
242
+ @app.command()
243
+ def share(code: Optional[str] = typer.Argument(None)):
244
+ """🔗 Share a file via 6-digit OTP (Sleek Interactive UI)."""
245
+ client = AkashClient()
246
+
247
+ try:
248
+ if not code:
249
+ with console.status("[bold blue]Fetching your vault..."):
250
+ file_list = client.list_files()
251
+
252
+ if not file_list:
253
+ console.print("[yellow]Your vault is empty.[/yellow]")
254
+ return
255
+
256
+ choices = []
257
+ for f in file_list:
258
+ clean_name = f.name.replace("_", " ")
259
+ display_name = (clean_name[:35] + '..') if len(clean_name) > 37 else clean_name
260
+ size_mb = f"{f.size / (1024*1024):.1f}MB"
261
+
262
+ choices.append(questionary.Choice(
263
+ title=[
264
+ ("class:emoji", f"{get_file_emoji(f.name)} "),
265
+ ("class:text", f"{display_name:<38} "),
266
+ ("class:meta", f"{size_mb:>7} "),
267
+ ("class:code", f"({f.code})")
268
+ ],
269
+ value=f.code
270
+ ))
271
+
272
+ choices.append(questionary.Separator())
273
+ choices.append(questionary.Choice("❌ Cancel", value="action_cancel"))
274
+
275
+ custom_style = questionary.Style([
276
+ ('qmark', 'fg:#00d2ff bold'),
277
+ ('question', 'bold'),
278
+ ('pointer', 'fg:#00d2ff bold'),
279
+ ('highlighted', 'fg:#000000 bg:#00d2ff bold'),
280
+ ('emoji', 'fg:#ffffff'),
281
+ ('text', 'fg:#eeeeee'),
282
+ ('meta', 'fg:#888888 italic'),
283
+ ('code', 'fg:#555555'),
284
+ ('selected', 'fg:#00d2ff bold'),
285
+ ])
286
+
287
+ code = questionary.select(
288
+ "Select a file to share:",
289
+ choices=choices,
290
+ style=custom_style,
291
+ pointer=" ➜"
292
+ ).ask()
293
+
294
+ if not code or code == "action_cancel":
295
+ return
296
+
297
+ # 2. OTP Generation
298
+ # Standardize HTTP client usage
299
+ headers = client._get_headers()
300
+ with httpx.Client(base_url=client.base_url) as c:
301
+ resp = c.post(f"/api/otp/generate/{code}", headers=headers)
302
+ resp.raise_for_status()
303
+ otp = resp.json()['otp']
304
+
305
+ # 🌟 THE FIX: Used valid Rich markup tags 🌟
306
+ # We use [reverse] to make the code stand out instead of 'size'
307
+ otp_display = f"[bold yellow] {otp} [/bold yellow]"
308
+
309
+ console.print("\n")
310
+ console.print(Panel(
311
+ f"Temporary vault code generated:\n\n"
312
+ f"{otp_display}\n\n"
313
+ f"Valid for 60 seconds. Use [bold cyan]akash get {otp}[/bold cyan] on the target device.",
314
+ title="✨ Quick Share",
315
+ border_style="yellow",
316
+ expand=False,
317
+ padding=(1, 5)
318
+ ))
319
+
320
+ except Exception as e:
321
+ # Prevent another MarkupError if the error message itself contains brackets
322
+ console.print(f"[bold red]✗ Error:[/bold red] {str(e)}")
323
+
324
+ @app.command()
325
+ def get(otp: str):
326
+ """✨ Download a file using a 6-digit temporary code."""
327
+ url = os.getenv("AKASH_API_URL", "https://jstore.2bd.net").rstrip("/")
328
+ try:
329
+ with console.status("[bold cyan]Redeeming code..."):
330
+ resp = httpx.get(f"{url}/api/otp/redeem/{otp}")
331
+ if resp.status_code != 200:
332
+ console.print("[red]❌ Invalid or expired code.[/red]")
333
+ return
334
+
335
+ data = resp.json()
336
+ stream_url = data['stream_url']
337
+ # We don't have the original filename easily, so we probe the headers
338
+ head = httpx.head(stream_url)
339
+ cd = head.headers.get("Content-Disposition", "")
340
+ filename = cd.split("filename*=UTF-8''")[-1] if "''" in cd else f"shared_{otp}.bin"
341
+ filename = urllib.parse.unquote(filename)
342
+
343
+ # Re-use your high-performance downloader logic
344
+ from .downloader import download_from_url
345
+ download_from_url(stream_url, filename)
346
+
347
+ console.print(f"[bold green]✓ Successfully retrieved: {filename}[/bold green]")
348
+ except Exception as e:
349
+ console.print(f"[red]Error:[/red] {e}")
350
+
351
+ @app.command()
352
+ def bulk(path: Path):
353
+ """🚀 Recursive folder vaulting."""
354
+ try:
355
+ client = AkashClient()
356
+ uploader = VaultUploader(client)
357
+ files = [p for p in path.glob("**/*") if p.is_file() and not p.name.startswith('.')]
358
+ for file in files:
359
+ console.print(f"Vaulting: {file.name}")
360
+ uploader.upload(file_path=str(file))
361
+ console.print("[green]✓ Batch complete.[/green]")
362
+ except Exception as e:
363
+ console.print(f"[red]Error:[/red] {e}")
364
+
365
+ @app.command()
366
+ def docs():
367
+ """📖 View the integration guide."""
368
+ webbrowser.open("https://github.com/juniorsir/akashcli")
369
+
370
+ @app.command()
371
+ def logout():
372
+ """🚪 Clear local credentials."""
373
+ ConfigManager.save(b"")
374
+ console.print("[yellow]Logged out.[/yellow]")
375
+
376
+ if __name__ == "__main__":
377
+ app()
@@ -0,0 +1,164 @@
1
+ import httpx
2
+ import time
3
+ import os
4
+ from typing import Optional, List, Callable
5
+ from datetime import datetime
6
+ import jwt
7
+
8
+ # Internal Imports
9
+ from .models import UploadResult, FileInfo, UserInfo
10
+ from .exceptions import *
11
+ from .config import ConfigManager
12
+
13
+ class AkashClient:
14
+ def __init__(self, api_key: Optional[str] = None, api_url: Optional[str] = None):
15
+ config = ConfigManager.load()
16
+ # API Key can come from: 1. Argument, 2. Env Var, 3. Config File
17
+ self.api_key = api_key or os.getenv("AKASH_API_KEY") or config.get("api_key")
18
+ self.base_url = (api_url or os.getenv("AKASH_API_URL") or config.get("api_url", "https://jstore.2bd.net")).rstrip("/")
19
+
20
+ if not self.api_key:
21
+ raise AuthenticationError("No API Key found. Run 'akash login' or set AKASH_API_KEY env var.")
22
+
23
+ def _get_headers(self, custom: dict = None):
24
+ """Standard headers for all Akash API requests."""
25
+ h = {
26
+ "x-api-key": self.api_key,
27
+ "Origin": "https://akash.cli",
28
+ "User-Agent": "AkashCLI/3.0.0"
29
+ }
30
+ if custom: h.update(custom)
31
+ return h
32
+
33
+ def whoami(self) -> UserInfo:
34
+ """Fetches account identity and quota info."""
35
+ try:
36
+ with httpx.Client(base_url=self.base_url) as client:
37
+ resp = client.get("/api/me", headers=self._get_headers())
38
+ if resp.status_code != 200:
39
+ raise AuthenticationError(f"Invalid API Key or server rejected request ({resp.status_code})")
40
+
41
+ d = resp.json()
42
+ return UserInfo(
43
+ owner=d['owner'],
44
+ quota_used=d['quota']['used'],
45
+ quota_limit=d['quota']['limit'],
46
+ allowed_domains=d['allowed_domains']
47
+ )
48
+ except httpx.RequestError as e:
49
+ raise NetworkError(f"Vault server unreachable: {e}")
50
+
51
+ def upload_file(
52
+ self,
53
+ path: str,
54
+ burn: bool = False,
55
+ expiry: int = 0,
56
+ password: Optional[str] = None,
57
+ progress_callback: Optional[Callable[[int, int], None]] = None
58
+ ) -> UploadResult:
59
+ """
60
+ Uploads a physical file with optional progress tracking.
61
+ 🌟 REFACTORED: Now correctly delegates to VaultUploader.
62
+ """
63
+ from .uploader import VaultUploader
64
+ uploader = VaultUploader(self)
65
+ return uploader.upload(
66
+ file_path=path,
67
+ burn=burn,
68
+ expiry=expiry,
69
+ password=password,
70
+ progress_callback=progress_callback
71
+ )
72
+
73
+ def upload_text(
74
+ self,
75
+ content: str,
76
+ filename: str = None,
77
+ burn: bool = False,
78
+ expiry: int = 0,
79
+ password: Optional[str] = None
80
+ ) -> UploadResult:
81
+ """Vaults raw text as a snippet."""
82
+ from .uploader import VaultUploader
83
+ uploader = VaultUploader(self)
84
+ return uploader.upload(
85
+ content=content,
86
+ filename=filename,
87
+ burn=burn,
88
+ expiry=expiry,
89
+ password=password
90
+ )
91
+
92
+ def list_files(self) -> List[FileInfo]:
93
+ """Returns a list of FileInfo objects from the vault."""
94
+ try:
95
+ with httpx.Client(base_url=self.base_url) as client:
96
+ resp = client.get("/api/files", headers=self._get_headers())
97
+ resp.raise_for_status()
98
+ return [
99
+ FileInfo(f['name'], f['code'], f['size'], f['uploaded_at'])
100
+ for f in resp.json()
101
+ ]
102
+ except Exception as e:
103
+ raise AkashError(f"Failed to list files: {e}")
104
+
105
+ def delete_file(self, code: str) -> bool:
106
+ """Permanently deletes a file by its vault code."""
107
+ try:
108
+ with httpx.Client(base_url=self.base_url) as client:
109
+ resp = client.delete(f"/api/files/{code}", headers=self._get_headers())
110
+ if resp.status_code == 404:
111
+ raise VaultFileNotFound(f"File {code} not found.")
112
+ resp.raise_for_status()
113
+ return True
114
+ except httpx.HTTPStatusError as e:
115
+ raise AkashError(f"Delete failed: {e.response.text}")
116
+
117
+ def update_file(self, code: str, **kwargs) -> dict:
118
+ """
119
+ Modify existing file security (filename, burn_on_read, expiry_days).
120
+ """
121
+ try:
122
+ with httpx.Client(base_url=self.base_url, timeout=10.0) as client:
123
+ resp = client.patch(
124
+ f"/api/files/{code}",
125
+ headers=self._get_headers(),
126
+ json=kwargs
127
+ )
128
+
129
+ if resp.status_code == 404:
130
+ raise VaultFileNotFound(f"File code '{code}' does not exist.")
131
+ if resp.status_code in [401, 403]:
132
+ raise AuthenticationError("Insufficient permissions to modify this content.")
133
+
134
+ resp.raise_for_status()
135
+ return resp.json()
136
+
137
+ except httpx.RequestError as e:
138
+ raise NetworkError(f"Network error: {e}")
139
+
140
+ def get_stats(self) -> dict:
141
+ """Admin Only: Fetch global vault diagnostics."""
142
+ # Note: Reuses whoami logic as per current API structure
143
+ return self.whoami().__dict__
144
+
145
+ # Inside AkashClient class in client.py
146
+
147
+ def get_upload_status(self, code: str) -> dict:
148
+ """Polls the server to check if background vaulting is finished."""
149
+ try:
150
+ with httpx.Client(base_url=self.base_url) as client:
151
+ resp = client.get(f"/api/status/{code}", headers=self._get_headers())
152
+ resp.raise_for_status()
153
+ return resp.json()
154
+ except Exception as e:
155
+ return {"status": "request_error", "detail": str(e)}
156
+
157
+ def generate_secure_link(self, code: str) -> dict:
158
+ """Fetches the short-lived IP-bound token."""
159
+ with httpx.Client(base_url=self.base_url) as client:
160
+ resp = client.get(f"/api/stream/generate/{code}", headers=self._get_headers())
161
+ # If server returns 202, this will raise an error containing '202'
162
+ # which our CLI 'stream' command catches to show the 'Waiting' status.
163
+ resp.raise_for_status()
164
+ return resp.json()
@@ -0,0 +1,28 @@
1
+ # akashcli/downloader.py
2
+ import httpx
3
+ import os
4
+ from pathlib import Path
5
+ from rich.console import Console # 🌟 ADD THIS
6
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, DownloadColumn
7
+
8
+ def download_from_url(url: str, filename: str):
9
+ """Standalone downloader for OTP/Guest links."""
10
+ # 🌟 FIX: Define the path
11
+ final_path = Path(os.getcwd()) / filename
12
+
13
+ with httpx.stream("GET", url, follow_redirects=True) as response:
14
+ total = int(response.headers.get("Content-Length", 0))
15
+
16
+ with Progress(
17
+ TextColumn("[bold blue]{task.fields[filename]}", justify="right"),
18
+ BarColumn(bar_width=None),
19
+ "[progress.percentage]{task.percentage:>3.1f}%",
20
+ DownloadColumn(),
21
+ console=Console()
22
+ ) as progress:
23
+ task = progress.add_task("download", total=total, filename=filename)
24
+ with open(final_path, "wb") as f:
25
+ for chunk in response.iter_bytes():
26
+ f.write(chunk)
27
+ progress.update(task, advance=len(chunk))
28
+ return final_path # 🌟 Now defined
@@ -0,0 +1,113 @@
1
+ import os
2
+ import httpx
3
+ from pathlib import Path
4
+ from typing import Optional, Callable, List
5
+ from .models import UploadResult
6
+ from .exceptions import UploadFailed, QuotaExceeded
7
+
8
+ class ProgressStream:
9
+ """
10
+ A file-wrapper that updates a progress bar while the
11
+ network library reads data from it.
12
+ """
13
+ def __init__(self, file_path: str, callback: Optional[Callable]):
14
+ self.file = open(file_path, "rb")
15
+ self.total_size = os.path.getsize(file_path)
16
+ self.callback = callback
17
+ self.bytes_read = 0
18
+
19
+ def __len__(self):
20
+ return self.total_size
21
+
22
+ def read(self, size=-1):
23
+ chunk = self.file.read(size)
24
+ if chunk:
25
+ self.bytes_read += len(chunk)
26
+ if self.callback:
27
+ self.callback(self.bytes_read, self.total_size)
28
+ return chunk
29
+
30
+ def close(self):
31
+ self.file.close()
32
+
33
+ class VaultUploader:
34
+ def __init__(self, client):
35
+ self.client = client
36
+
37
+ def upload(
38
+ self,
39
+ file_path: Optional[str] = None,
40
+ content: Optional[str] = None,
41
+ filename: Optional[str] = None,
42
+ burn: bool = False,
43
+ expiry: int = 0,
44
+ password: Optional[str] = None,
45
+ progress_callback: Optional[Callable[[int, int], None]] = None
46
+ ) -> UploadResult:
47
+
48
+ # 1. Prepare Headers (V3 Spec)
49
+ headers = {
50
+ "x-vault-burn": "1" if burn else "0",
51
+ "x-vault-expiry": str(expiry),
52
+ }
53
+ if password:
54
+ headers["x-vault-password"] = password
55
+
56
+ url = f"{self.client.base_url}/api/upload"
57
+
58
+ try:
59
+ # --- CASE A: TEXT SNIPPET (JSON) ---
60
+ if content is not None:
61
+ payload = {
62
+ "content": content,
63
+ "filename": filename or "snippet.txt"
64
+ }
65
+ resp = httpx.post(
66
+ url,
67
+ json=payload,
68
+ headers=self.client._get_headers(headers),
69
+ timeout=None
70
+ )
71
+
72
+ # --- CASE B: FILE UPLOAD (Multipart) ---
73
+ else:
74
+ if not file_path or not os.path.exists(file_path):
75
+ raise UploadFailed(f"File not found: {file_path}")
76
+
77
+ # 🌟 THE FIX: Use the ProgressStream wrapper instead of a generator
78
+ stream = ProgressStream(file_path, progress_callback)
79
+
80
+ try:
81
+ files = {
82
+ "file": (os.path.basename(file_path), stream, "application/octet-stream")
83
+ }
84
+
85
+ resp = httpx.post(
86
+ url,
87
+ files=files,
88
+ headers=self.client._get_headers(headers),
89
+ timeout=None
90
+ )
91
+ finally:
92
+ stream.close()
93
+
94
+ # 2. Handle Response
95
+ if resp.status_code == 429:
96
+ raise QuotaExceeded("Upload quota reached.")
97
+
98
+ resp.raise_for_status()
99
+ d = resp.json()
100
+
101
+ return UploadResult(
102
+ status=d['status'],
103
+ file_code=d['file_code'],
104
+ embed_url=d['links']['embed'],
105
+ direct_url=d['links']['direct'],
106
+ input_type=d.get('input_type', 'file'),
107
+ dna=d.get('dna', {})
108
+ )
109
+
110
+ except httpx.HTTPStatusError as e:
111
+ raise UploadFailed(f"Server Error ({e.response.status_code}): {e.response.text}")
112
+ except Exception as e:
113
+ raise UploadFailed(f"Connection Error: {e}")
@@ -0,0 +1,38 @@
1
+ import uuid
2
+ import hashlib
3
+ import platform
4
+
5
+ def get_device_id() -> str:
6
+ """Generates a unique hardware fingerprint for encrypted config binding."""
7
+ # Combine node (MAC address) and system info for a stable ID
8
+ raw_id = f"{uuid.getnode()}-{platform.node()}-{platform.processor()}"
9
+ return hashlib.sha256(raw_id.encode()).hexdigest()
10
+
11
+ def get_file_emoji(filename: str) -> str:
12
+ """Returns a specific emoji based on the file extension for the CLI UI."""
13
+ if not filename:
14
+ return "📄"
15
+
16
+ fn = filename.lower()
17
+
18
+ # Videos
19
+ if fn.endswith(('.mp4', '.mkv', '.mov', '.webm', '.avi', '.m4v')):
20
+ return "🎬"
21
+ # Images
22
+ if fn.endswith(('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.svg', '.ico')):
23
+ return "🖼️"
24
+ # Audio
25
+ if fn.endswith(('.mp3', '.ogg', '.wav', '.flac', '.m4a', '.opus', '.aac')):
26
+ return "🎵"
27
+ # Code / Developer Files
28
+ if fn.endswith(('.py', '.js', '.ts', '.sh', '.bash', '.html', '.css', '.json', '.yaml', '.yml', '.c', '.cpp', '.rs', '.go', '.php', '.sql')):
29
+ return "💻"
30
+ # Documents
31
+ if fn.endswith(('.pdf', '.docx', '.doc', '.xlsx', '.pptx', '.txt', '.rtf')):
32
+ return "📑"
33
+ # Compressed Archives
34
+ if fn.endswith(('.zip', '.rar', '.7z', '.tar', '.gz')):
35
+ return "📦"
36
+
37
+ # Default
38
+ return "📄"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: Enterprise Developer SDK and CLI for Akash Vault
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -8,6 +8,7 @@ Requires-Dist: httpx
8
8
  Requires-Dist: typer[all]
9
9
  Requires-Dist: rich
10
10
  Requires-Dist: cryptography
11
+ Requires-Dist: questionary
11
12
 
12
13
  To get started with **AkashCLI**, your users should follow these steps. You can provide this guide as a `README.md` or a "Getting Started" message in your bot.
13
14
 
@@ -6,6 +6,7 @@ akashcli/cli.py
6
6
  akashcli/client.py
7
7
  akashcli/config.py
8
8
  akashcli/crypto.py
9
+ akashcli/downloader.py
9
10
  akashcli/exceptions.py
10
11
  akashcli/models.py
11
12
  akashcli/uploader.py
@@ -2,3 +2,4 @@ httpx
2
2
  typer[all]
3
3
  rich
4
4
  cryptography
5
+ questionary
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akashcli"
3
- version = "3.0.0"
3
+ version = "3.2.0"
4
4
  description = "Enterprise Developer SDK and CLI for Akash Vault"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -9,6 +9,7 @@ dependencies = [
9
9
  "typer[all]",
10
10
  "rich",
11
11
  "cryptography",
12
+ "questionary",
12
13
  ]
13
14
 
14
15
  [project.scripts]
@@ -1,184 +0,0 @@
1
- import typer
2
- import json
3
- import os
4
- import subprocess
5
- import webbrowser
6
- import httpx
7
- from typing import Optional
8
- from pathlib import Path
9
- from rich.console import Console
10
- from rich.table import Table
11
- from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, DownloadColumn, TransferSpeedColumn
12
- from rich.panel import Panel
13
-
14
- # Internal Imports
15
- from .client import AkashClient
16
- from .auth import AuthManager
17
- from .config import ConfigManager
18
- from .exceptions import AkashError
19
- from .uploader import VaultUploader
20
-
21
- # 🌟 Removed the 'gh' overrides and panels
22
- app = typer.Typer(help="🛡️ Akash Vault Enterprise CLI", add_completion=True)
23
- console = Console()
24
-
25
- @app.command()
26
- def login(
27
- url: str = typer.Option("https://jstore.2bd.net", "--url", help="Custom Vault URL"),
28
- key: str = typer.Option(..., prompt="🔑 Enter API Key", hide_input=True)
29
- ):
30
- """🔑 Link your device to the vault."""
31
- try:
32
- with console.status("[bold blue]Verifying identity..."):
33
- user = AuthManager.perform_login(key, url)
34
-
35
- console.print(f"[green]✓ Login Successful![/green] Owner: {user.owner}")
36
- except Exception as e:
37
- console.print(f"[bold red]✗ Authentication Failed:[/bold red] {e}")
38
-
39
- @app.command()
40
- def whoami():
41
- """👤 Check current login and quota status."""
42
- try:
43
- client = AkashClient()
44
- user = client.whoami()
45
- console.print(f"Logged in as: [bold cyan]{user.owner}[/bold cyan]")
46
- console.print(f"Storage Quota: [bold yellow]{user.quota_used}/{user.quota_limit}[/bold yellow]")
47
- except Exception as e:
48
- console.print(f"[red]Error:[/red] {e}")
49
-
50
- @app.command()
51
- def upload(
52
- path: Optional[Path] = typer.Argument(None, help="Path to file"),
53
- text: Optional[str] = typer.Option(None, "--text", "-t", help="Upload raw text content"),
54
- name: Optional[str] = typer.Option(None, "--name", "-n", help="Custom filename"),
55
- burn: bool = typer.Option(False, "--burn", help="Self-destruct after one view"),
56
- expiry: int = typer.Option(0, "--expiry", "-e", help="Expiry in days (0 for forever)"),
57
- password: Optional[str] = typer.Option(None, "--pass", "-p", help="Password protect file")
58
- ):
59
- """📤 Vault a single file or text snippet."""
60
- try:
61
- client = AkashClient()
62
-
63
- with Progress(
64
- SpinnerColumn(),
65
- TextColumn("[progress.description]{task.description}"),
66
- BarColumn(),
67
- console=console,
68
- transient=True
69
- ) as progress:
70
- task = progress.add_task(description="[cyan]Processing...", total=100)
71
- def update_bar(current, total):
72
- progress.update(task, completed=(current/total)*100)
73
-
74
- if text:
75
- res = client.upload_text(text, filename=name, burn=burn, expiry=expiry, password=password)
76
- else:
77
- if not path:
78
- console.print("[red]Error:[/red] Provide a file path or use --text")
79
- return
80
- res = client.upload_file(str(path), burn=burn, expiry=expiry, password=password, progress_callback=update_bar)
81
-
82
- console.print(f"[bold green]✓ Vaulted![/bold green] Code: [yellow]{res.file_code}[/yellow]")
83
- except Exception as e:
84
- console.print(f"[bold red]✗ Upload Failed:[/bold red] {e}")
85
-
86
- @app.command()
87
- def files(json_output: bool = typer.Option(False, "--json", help="Output in raw JSON")):
88
- """📦 List and search files in your vault."""
89
- try:
90
- client = AkashClient()
91
- file_list = client.list_files()
92
-
93
- if json_output:
94
- console.print_json(data=[f.__dict__ for f in file_list])
95
- return
96
-
97
- table = Table(title="📦 Encrypted Vault")
98
- table.add_column("Filename", style="cyan")
99
- table.add_column("Code", style="yellow")
100
- table.add_column("Size", justify="right")
101
-
102
- for f in file_list:
103
- size_mb = f"{f.size / (1024*1024):.2f} MB"
104
- table.add_row(f.name, f.code, size_mb)
105
-
106
- console.print(table)
107
- except Exception as e:
108
- console.print(f"[red]Error:[/red] {e}")
109
-
110
- @app.command()
111
- def stream(code: str, vlc: bool = typer.Option(False, "--vlc", help="Open directly in VLC")):
112
- """🎬 Get a secure streaming link."""
113
- client = AkashClient()
114
- try:
115
- with httpx.Client(base_url=client.base_url) as c:
116
- resp = c.get(f"/api/stream/generate/{code}", headers=client._get_headers())
117
- resp.raise_for_status()
118
- url = resp.json()['stream_url']
119
-
120
- if vlc:
121
- console.print(f"🎬 Launching VLC for: {code}")
122
- subprocess.Popen(['vlc', url], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
123
- else:
124
- console.print(f"🎬 Secure URL: {url}")
125
- except Exception as e:
126
- console.print(f"[red]Error:[/red] {e}")
127
-
128
- @app.command()
129
- def delete(code: str):
130
- """🗑️ Permanently remove a file."""
131
- if typer.confirm(f"Are you sure you want to delete {code}?"):
132
- try:
133
- client = AkashClient()
134
- client.delete_file(code)
135
- console.print(f"[green]✓ Deleted {code}[/green]")
136
- except Exception as e:
137
- console.print(f"[red]Error:[/red] {e}")
138
-
139
- @app.command()
140
- def manage(
141
- code: str,
142
- name: Optional[str] = typer.Option(None, "--name"),
143
- burn: Optional[bool] = typer.Option(None, "--burn/--no-burn"),
144
- expiry: Optional[int] = typer.Option(None, "--expiry")
145
- ):
146
- """⚙️ Modify settings for an existing file."""
147
- try:
148
- client = AkashClient()
149
- updates = {}
150
- if name: updates['filename'] = name
151
- if burn is not None: updates['burn_on_read'] = 1 if burn else 0
152
- if expiry is not None: updates['expiry_days'] = expiry
153
- client.update_file(code, **updates)
154
- console.print(f"[green]✓ Updated {code}[/green]")
155
- except Exception as e:
156
- console.print(f"[red]Error:[/red] {e}")
157
-
158
- @app.command()
159
- def bulk(path: Path):
160
- """🚀 Recursive folder vaulting."""
161
- try:
162
- client = AkashClient()
163
- uploader = VaultUploader(client)
164
- files = [p for p in path.glob("**/*") if p.is_file() and not p.name.startswith('.')]
165
- for file in files:
166
- console.print(f"Vaulting: {file.name}")
167
- uploader.upload(file_path=str(file))
168
- console.print("[green]✓ Batch complete.[/green]")
169
- except Exception as e:
170
- console.print(f"[red]Error:[/red] {e}")
171
-
172
- @app.command()
173
- def docs():
174
- """📖 View the integration guide."""
175
- webbrowser.open("https://github.com/juniorsir/akashcli")
176
-
177
- @app.command()
178
- def logout():
179
- """🚪 Clear local credentials."""
180
- ConfigManager.save(b"")
181
- console.print("[yellow]Logged out.[/yellow]")
182
-
183
- if __name__ == "__main__":
184
- app()
@@ -1,74 +0,0 @@
1
- import httpx
2
- import time
3
- from typing import List, Optional
4
- from .models import UploadResult, FileInfo, UserInfo
5
- from .exceptions import *
6
- from .config import ConfigManager
7
-
8
- class AkashClient:
9
- def __init__(self, api_key: Optional[str] = None, api_url: Optional[str] = None):
10
- config = ConfigManager.load()
11
- self.api_key = api_key or config.get("api_key")
12
- self.base_url = (api_url or config.get("api_url", "https://jstore.2bd.net")).rstrip("/")
13
-
14
- if not self.api_key:
15
- raise AuthenticationError("No API Key found. Run 'akash login'")
16
-
17
- def _get_headers(self, custom: dict = None):
18
- h = {"x-api-key": self.api_key, "Origin": "https://akash.cli"}
19
- if custom: h.update(custom)
20
- return h
21
-
22
- def whoami(self) -> UserInfo:
23
- with httpx.Client(base_url=self.base_url) as client:
24
- resp = client.get("/api/me", headers=self._get_headers())
25
- if resp.status_code != 200: raise AuthenticationError("Invalid Key")
26
- d = resp.json()
27
- return UserInfo(d['owner'], d['quota']['used'], d['quota']['limit'], d['allowed_domains'])
28
-
29
- def upload_file(self, path: str, burn: bool = False, expiry: int = 0) -> UploadResult:
30
- files = {"file": open(path, "rb")}
31
- headers = {
32
- "x-vault-burn": "1" if burn else "0",
33
- "x-vault-expiry": str(expiry)
34
- }
35
- with httpx.Client(base_url=self.base_url, timeout=None) as client:
36
- resp = client.post("/api/upload", headers=self._get_headers(headers), files=files)
37
- resp.raise_for_status()
38
- d = resp.json()
39
- return UploadResult(
40
- d['status'], d['file_code'], d['links']['embed'],
41
- d['links']['direct'], d['input_type'], d.get('dna', {})
42
- )
43
-
44
- def list_files(self) -> List[FileInfo]:
45
- with httpx.Client(base_url=self.base_url) as client:
46
- resp = client.get("/api/files", headers=self._get_headers())
47
- return [FileInfo(f['name'], f['code'], f['size'], f['uploaded_at']) for f in resp.json()]
48
-
49
- def delete_file(self, code: str):
50
- with httpx.Client(base_url=self.base_url) as client:
51
- client.delete(f"/api/files/{code}", headers=self._get_headers())
52
-
53
- def upload_text(self, content: str, filename: str = None, burn: bool = False, expiry: int = 0, password: str = None):
54
- from .uploader import VaultUploader
55
- uploader = VaultUploader(self)
56
- return uploader.upload(content=content, filename=filename, burn=burn, expiry=expiry, password=password)
57
-
58
- def update_file(self, code: str, **kwargs) -> dict:
59
- """Update file settings (filename, burn_on_read, expiry_days)."""
60
- with httpx.Client(base_url=self.base_url) as client:
61
- resp = client.patch(
62
- f"/api/files/{code}",
63
- headers=self._get_headers(),
64
- json=kwargs
65
- )
66
- resp.raise_for_status()
67
- return resp.json()
68
-
69
- def get_stats(self) -> dict:
70
- """Admin Only: Fetch global vault statistics."""
71
- with httpx.Client(base_url=self.base_url) as client:
72
- # Reuses the logic from your bot's show_stats
73
- resp = client.get("/api/me", headers=self._get_headers())
74
- return resp.json()
@@ -1,123 +0,0 @@
1
- import os
2
- import httpx, asyncio
3
- from pathlib import Path
4
- from typing import Optional, Callable, Dict, Any, List
5
- from concurrent.futures import ThreadPoolExecutor
6
- from .models import UploadResult
7
- from .exceptions import UploadFailed, QuotaExceeded
8
-
9
- class VaultUploader:
10
- def __init__(self, client):
11
- self.client = client # Reference to AkashClient for headers/url
12
-
13
- def upload(
14
- self,
15
- file_path: Optional[str] = None,
16
- content: Optional[str] = None,
17
- filename: Optional[str] = None,
18
- burn: bool = False,
19
- expiry: int = 0,
20
- password: Optional[str] = None,
21
- progress_callback: Optional[Callable[[int, int], None]] = None
22
- ) -> UploadResult:
23
- """
24
- Standardizes both File and Text uploads into a single stream.
25
- progress_callback signature: (bytes_read, total_bytes)
26
- """
27
-
28
- # 1. Prepare Headers based on V3 Power-Control Specs
29
- headers = {
30
- "x-vault-burn": "1" if burn else "0",
31
- "x-vault-expiry": str(expiry),
32
- }
33
- if password:
34
- headers["x-vault-password"] = password
35
-
36
- url = f"{self.client.base_url}/api/upload"
37
-
38
- try:
39
- if content is not None:
40
- # --- TEXT SNIPPET MODE (JSON) ---
41
- payload = {
42
- "content": content,
43
- "filename": filename or "snippet.txt"
44
- }
45
- resp = httpx.post(
46
- url,
47
- json=payload,
48
- headers=self.client._get_headers(headers),
49
- timeout=None
50
- )
51
- else:
52
- # --- FILE MODE (Multipart) ---
53
- if not file_path or not os.path.exists(file_path):
54
- raise UploadFailed(f"File not found: {file_path}")
55
-
56
- file_size = os.path.getsize(file_path)
57
-
58
- # Setup custom stream to track progress
59
- def file_generator():
60
- with open(file_path, "rb") as f:
61
- chunk_size = 1024 * 1024 # 1MB chunks
62
- bytes_read = 0
63
- while chunk := f.read(chunk_size):
64
- bytes_read += len(chunk)
65
- if progress_callback:
66
- progress_callback(bytes_read, file_size)
67
- yield chunk
68
-
69
- files = {"file": (os.path.basename(file_path), file_generator())}
70
-
71
- resp = httpx.post(
72
- url,
73
- files=files,
74
- headers=self.client._get_headers(headers),
75
- timeout=None
76
- )
77
-
78
- if resp.status_code == 429:
79
- raise QuotaExceeded("Monthly upload quota reached.")
80
-
81
- resp.raise_for_status()
82
- d = resp.json()
83
-
84
- return UploadResult(
85
- status=d['status'],
86
- file_code=d['file_code'],
87
- embed_url=d['links']['embed'],
88
- direct_url=d['links']['direct'],
89
- input_type=d['input_type'],
90
- dna=d.get('dna', {})
91
- )
92
-
93
- except httpx.HTTPStatusError as e:
94
- raise UploadFailed(f"Server rejected upload ({e.response.status_code}): {e.response.text}")
95
- except Exception as e:
96
- raise UploadFailed(f"Unexpected error during upload: {e}")
97
-
98
- def upload_directory(self, folder_path: str, max_workers: int = 3) -> List[UploadResult]:
99
- """Recursively uploads all files in a folder using a thread pool."""
100
- # Convert string path to Path object
101
- root = Path(folder_path)
102
- if not root.is_dir():
103
- raise UploadFailed(f"Source is not a directory: {folder_path}")
104
-
105
- # Find all files recursively (excluding hidden files/folders)
106
- paths = [p for p in root.glob("**/*") if p.is_file() and not p.name.startswith('.')]
107
- results = []
108
-
109
- # Using ThreadPoolExecutor for parallel I/O bound tasks
110
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
111
- # We wrap the self.upload calls into the executor
112
- # Note: We don't pass a progress_callback here to avoid UI overlap
113
- # (The CLI 'bulk' command will handle the UI instead)
114
- futures = [executor.submit(self.upload, file_path=str(p)) for p in paths]
115
-
116
- for f in futures:
117
- try:
118
- results.append(f.result())
119
- except Exception as e:
120
- # Log error but continue with other files
121
- print(f"Error uploading a file in batch: {e}")
122
-
123
- return results
@@ -1,9 +0,0 @@
1
- import uuid
2
- import hashlib
3
- import platform
4
-
5
- def get_device_id() -> str:
6
- """Generates a unique hardware fingerprint."""
7
- # Combine node (MAC address) and system info for a stable ID
8
- raw_id = f"{uuid.getnode()}-{platform.node()}-{platform.processor()}"
9
- return hashlib.sha256(raw_id.encode()).hexdigest()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes