akashcli 4.7.0__tar.gz → 4.7.2__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.
Files changed (33) hide show
  1. {akashcli-4.7.0 → akashcli-4.7.2}/PKG-INFO +1 -1
  2. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/__init__.py +1 -1
  3. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/cli.py +106 -228
  4. akashcli-4.7.2/akashcli/updater.py +225 -0
  5. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli.egg-info/PKG-INFO +1 -1
  6. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli.egg-info/SOURCES.txt +1 -0
  7. {akashcli-4.7.0 → akashcli-4.7.2}/pyproject.toml +1 -1
  8. {akashcli-4.7.0 → akashcli-4.7.2}/README.md +0 -0
  9. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/_net.py +0 -0
  10. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/auth.py +0 -0
  11. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/client.py +0 -0
  12. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/config.py +0 -0
  13. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/crypto.py +0 -0
  14. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/env_manager.py +0 -0
  15. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/exceptions.py +0 -0
  16. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/exec.py +0 -0
  17. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/integrity.py +0 -0
  18. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/logger.py +0 -0
  19. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/models.py +0 -0
  20. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/p2p.py +0 -0
  21. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/packages.py +0 -0
  22. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/relay.py +0 -0
  23. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/restore.py +0 -0
  24. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/snapshot_crypto.py +0 -0
  25. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/storage.py +0 -0
  26. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/terminal.py +0 -0
  27. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/uploader.py +0 -0
  28. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli/utils.py +0 -0
  29. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli.egg-info/dependency_links.txt +0 -0
  30. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli.egg-info/entry_points.txt +0 -0
  31. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli.egg-info/requires.txt +0 -0
  32. {akashcli-4.7.0 → akashcli-4.7.2}/akashcli.egg-info/top_level.txt +0 -0
  33. {akashcli-4.7.0 → akashcli-4.7.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.7.0
3
+ Version: 4.7.2
4
4
  Summary: Akash CLI — secure remote shell relay, file vault, and device management
5
5
  License-Expression: MIT
6
6
  Keywords: akash,cli,relay,remote-shell,vault
@@ -2,7 +2,7 @@ try:
2
2
  from importlib.metadata import version
3
3
  __version__ = version("akashcli")
4
4
  except Exception:
5
- __version__ = "4.7.0"
5
+ __version__ = "4.7.2"
6
6
 
7
7
  AKASH_VERSION = __version__
8
8
 
@@ -6,6 +6,7 @@ import subprocess
6
6
  import re
7
7
  import webbrowser
8
8
  import tempfile
9
+ import atexit
9
10
  from typing import Optional, List
10
11
  from pathlib import Path
11
12
 
@@ -18,6 +19,9 @@ from .config import ConfigManager, DEFAULT_STORAGE_URL
18
19
  from .exceptions import AkashError
19
20
  from .exec import _device_fingerprint
20
21
  from .uploader import VaultUploader
22
+ from .updater import show_update_warning_if_available, check_for_updates, format_update_box, mark_update_printed
23
+
24
+ atexit.register(show_update_warning_if_available)
21
25
 
22
26
  # ── ANSI palette (matches exec.py) ───────────────────────────────────────────
23
27
  _C = "\033[1;36m" # cyan bold
@@ -184,6 +188,13 @@ def _version_callback(value: bool):
184
188
  if value:
185
189
  from . import __version__
186
190
  print(f"akashcli v{__version__}")
191
+ try:
192
+ available, latest = check_for_updates(__version__, force_sync=False)
193
+ if available and latest:
194
+ print(format_update_box(__version__, latest))
195
+ mark_update_printed()
196
+ except Exception:
197
+ pass
187
198
  raise typer.Exit(0)
188
199
 
189
200
  def raise_exit():
@@ -204,40 +215,46 @@ def _print_help():
204
215
  C, G, Y, DM, RS = _C, _G, _Y, _DM, _RS
205
216
  lines = [
206
217
  "",
207
- f" {C}akash{RS} {DM}- encrypted cloud backup · remote terminal · AI tool vault{RS}",
218
+ f" {C}akash{RS} {DM}— encrypted cloud backup · remote shell · AI agent vault{RS}",
208
219
  "",
209
- f" {DM}AI & AGENTS{RS}",
210
- f" {C}mcp{RS} {DM}<serve|share|status>{RS} serve local tools to Codex, Claude & Cursor",
211
- f" {C}agent{RS} {DM}<create|list|info…>{RS} manage AI client credentials & scoped access",
220
+ f" {DM}ACCOUNT & SERVER{RS}",
221
+ f" {C}login{RS} {DM}[--label NAME] [--url URL]{RS} save your API key + name this device",
222
+ f" {C}whoami{RS} account quota, storage URL & device info",
223
+ f" {C}url{RS} {DM}[NEW_URL|--reset]{RS} view or switch configured storage server URL",
224
+ f" {C}logout{RS} clear saved credentials from this device",
212
225
  "",
213
226
  f" {DM}ENVIRONMENT BACKUP{RS}",
214
- f" {C}backup{RS} {DM}[--password ...]{RS} save terminal environment & dotfiles to cloud or local",
215
- f" {C}restore{RS} {DM}<CODE|PATH>{RS} restore terminal from cloud save or local archive",
216
- f" {C}pull{RS} {DM}<CODE>{RS} one-shot restore on fresh machine (no login needed)",
217
- f" {C}saves{RS} {DM}[--json]{RS} list your cloud-saved & local environments",
227
+ f" {C}backup{RS} {DM}[--label NAME] [--password PASS]{RS} save environment & packages to cloud or disk",
228
+ f" {C}restore{RS} {DM}<CODE|PATH> [--dry-run]{RS} restore terminal environment from cloud or archive",
229
+ f" {C}saves{RS} {DM}[--json]{RS} list cloud-saved and local environments",
230
+ f" {C}pull{RS} {DM}<CODE> [--target DIR]{RS} one-shot restore on fresh machine (no login needed)",
231
+ "",
232
+ f" {DM}REMOTE TERMINAL & SHELL{RS}",
233
+ f" {C}shell{RS} {DM}[DEVICE|--code CODE]{RS} open interactive shell (local, device, or code)",
234
+ f" {C}run{RS} {DM}<CMD> [--device NAME]{RS} run command on server or enrolled device",
235
+ f" {C}serve{RS} {DM}[--share] [--label NAME]{RS} make this device reachable (or get one-time code)",
236
+ f" {C}devices{RS} {DM}[--revoke NAME]{RS} list or manage enrolled devices",
218
237
  "",
219
- f" {DM}REMOTE TERMINAL{RS}",
220
- f" {C}shell{RS} {DM}[NAME|--code CODE]{RS} open interactive shell (local, remote device, or code)",
221
- f" {C}run{RS} {DM}<CMD> [--device NAME]{RS} run command on server or enrolled device",
222
- f" {C}serve{RS} {DM}[--share]{RS} make this device reachable (or get one-time code)",
223
- f" {C}devices{RS} {DM}[--revoke NAME]{RS} list or manage enrolled devices",
238
+ f" {DM}AI AGENTS & MCP{RS}",
239
+ f" {C}mcp{RS} {DM}<serve|share|create|list…>{RS} AI agent & MCP tool bridge (Codex, Claude, Cursor)",
224
240
  "",
225
- f" {DM}FILES & TRANSFERS{RS}",
226
- f" {C}cloud{RS} {DM}<upload|files|pipe|url…>{RS} cloud file vault, streaming, pipe & storage url",
227
- f" {C}send{RS} {DM}<USER> <FILE>{RS} end-to-end encrypted P2P file transfer",
228
- f" {C}inbox{RS} {DM}[CODE]{RS} receive & decrypt files sent to your identity",
229
- f" {C}id{RS} {DM}[--renew]{RS} view or renew your P2P identity fingerprint",
241
+ f" {DM}FILES & CLOUD VAULT{RS}",
242
+ f" {C}cloud{RS} {DM}<upload|files|stream|delete…>{RS} cloud file vault & media streaming engine",
243
+ f" {C}pipe{RS} {DM}[--name NAME] [--burn]{RS} pipe stdin or command output directly to cloud vault",
230
244
  "",
231
- f" {DM}SYSTEM & SECURITY{RS}",
232
- f" {C}login{RS} {DM}[--label NAME]{RS} authenticate & name this device",
233
- f" {C}logout{RS} disconnect and clear credentials from this device",
234
- f" {C}whoami{RS} account quota, storage URL & device info",
235
- f" {C}url{RS} {DM}[NEW_URL|--reset]{RS} view or switch configured storage server URL",
236
- f" {C}version{RS} check CLI and server status & versions",
237
- f" {C}env{RS} {DM}<set|get|list…>{RS} encrypted local .env store",
238
- f" {C}check{RS} {DM}[--seal]{RS} verify source integrity & tamper detection",
245
+ f" {DM}SECURE P2P TRANSFERS{RS}",
246
+ f" {C}send{RS} {DM}<USER> <FILE>{RS} end-to-end encrypted P2P file transfer",
247
+ f" {C}inbox{RS} {DM}[CODE]{RS} receive & decrypt files sent to your identity",
248
+ f" {C}id{RS} {DM}[--export|--add]{RS} show P2P fingerprint, export key, or add contacts",
239
249
  "",
240
- f" {DM}Run {RS}{C}akash <command> --help{RS}{DM} for options on any command.{RS}",
250
+ f" {DM}SECRETS & SYSTEM{RS}",
251
+ f" {C}env{RS} {DM}<set|get|list|export|push…>{RS} encrypted local .env store & cloud sync",
252
+ f" {C}log{RS} {DM}[--stats]{RS} view operation history & bandwidth summary",
253
+ f" {C}check{RS} {DM}[--seal]{RS} verify source integrity & tamper detection",
254
+ f" {C}version{RS} check CLI and server status & versions",
255
+ f" {C}update{RS} check and install latest akashcli release",
256
+ "",
257
+ f" {DM}Run {RS}{C}akash <command> --help{RS}{DM} for full options on any command.{RS}",
241
258
  "",
242
259
  ]
243
260
  nl = chr(10)
@@ -312,7 +329,50 @@ def version(url: Optional[str] = typer.Option(None, "--url", help="Server URL to
312
329
  print(f" {_DM}server {_RS}{_cy(base)} {_dim(f'(HTTP {resp.status_code})')}")
313
330
  except Exception:
314
331
  print(f" {_DM}server {_RS}{_cy(base)} {_dim('(unreachable)')}")
315
- print()
332
+
333
+ try:
334
+ available, latest = check_for_updates(__version__, force_sync=True)
335
+ if available and latest:
336
+ print(format_update_box(__version__, latest))
337
+ mark_update_printed()
338
+ else:
339
+ print(f" {_G}✓{_RS} {_DM}akashcli is up to date{_RS}\n")
340
+ except Exception:
341
+ print()
342
+
343
+ @app.command()
344
+ def update():
345
+ """Check for updates and upgrade akashcli to the latest version."""
346
+ from . import __version__
347
+ _p()
348
+ print(f" {_C}◈{_RS} {_W}Checking for akashcli updates…{_RS}")
349
+ available, latest = check_for_updates(__version__, force_sync=True)
350
+ if not available or not latest:
351
+ _ok(f"akashcli is already at the latest version ({_cy('v' + __version__)})")
352
+ _p()
353
+ return
354
+
355
+ print(f" {_Y}⚡{_RS} New version available: {_DM}{__version__}{_RS} {_C}→{_RS} {_G}{latest}{_RS}")
356
+ with _Spin(f"updating akashcli to v{latest}…"):
357
+ cmd = [sys.executable, "-m", "pip", "install", "-U", "akashcli", "--break-system-packages"]
358
+ res = subprocess.run(cmd, capture_output=True, text=True)
359
+ if res.returncode != 0:
360
+ cmd = [sys.executable, "-m", "pip", "install", "-U", "akashcli"]
361
+ res = subprocess.run(cmd, capture_output=True, text=True)
362
+
363
+ if res.returncode == 0:
364
+ _ok(f"Successfully updated akashcli to {_G}v{latest}{_RS}!")
365
+ try:
366
+ from .integrity import seal
367
+ seal()
368
+ _ok("Integrity manifest re-sealed.")
369
+ except Exception:
370
+ pass
371
+ else:
372
+ _err(f"Update failed: {res.stderr or res.stdout}")
373
+ _info(f"Please run manually: {_cy('pip install -U akashcli')}")
374
+ mark_update_printed()
375
+ _p()
316
376
 
317
377
  @app.command()
318
378
  def whoami():
@@ -2670,8 +2730,10 @@ def env_pull(
2670
2730
  # AKASH AGENT & DEVICE BINDING COMMANDS
2671
2731
  # =============================================================================
2672
2732
 
2673
- agent_app = typer.Typer(help="Manage AI agents, client credentials & device approvals.")
2674
- app.add_typer(agent_app, name="agent", help="agent credentials · device binding · approvals")
2733
+ mcp_app = typer.Typer(help="Model Context Protocol (MCP) server & AI agent tool bridge.")
2734
+ agent_app = mcp_app
2735
+ app.add_typer(mcp_app, name="mcp", help="MCP server · AI agent tools · Claude/Cursor/Codex bridge")
2736
+ app.add_typer(mcp_app, name="agent", hidden=True, help="Alias for akash mcp")
2675
2737
 
2676
2738
  def _render_inspection_card(r: dict) -> None:
2677
2739
  print(f"\n {_C}╭─ ⚡ INCOMING AGENT DEVICE REGISTRATION ────────────────────────────{_RS}")
@@ -2861,6 +2923,8 @@ def agent_update(
2861
2923
  _err("Provide a Profile ID (e.g. 'akash agent update fbf6 --scopes +exec:package_install')")
2862
2924
  raise typer.Exit(1)
2863
2925
 
2926
+ if role and not role.startswith("@") and role in ("viewer", "coder", "tester", "full"):
2927
+ role = "@" + role
2864
2928
  effective_scopes = scopes if scopes else role
2865
2929
  if not effective_scopes:
2866
2930
  _err("Specify new scopes with --scopes or --role (e.g. --scopes +exec:package_install or --role @coder)")
@@ -2932,6 +2996,13 @@ def agent_create(
2932
2996
  url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
2933
2997
  ):
2934
2998
  """Generate new Client ID and Client Secret with hardware binding."""
2999
+ if role:
3000
+ if not role.startswith("@") and role in ("viewer", "coder", "tester", "full"):
3001
+ role = "@" + role
3002
+ effective_scopes = role
3003
+ else:
3004
+ effective_scopes = scopes
3005
+
2935
3006
  api_key, base = _resolve_client(url, require_key=False)
2936
3007
  hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2937
3008
  if api_key: hdrs["x-api-key"] = api_key
@@ -2983,30 +3054,6 @@ def agent_create(
2983
3054
  print(f" {_G}akash mcp serve --id {profile_id}{_RS}")
2984
3055
  _p()
2985
3056
 
2986
- @agent_app.command("update")
2987
- def agent_update(
2988
- client_id: str = typer.Argument(..., help="Client ID of the agent to update"),
2989
- scopes: str = typer.Option(..., "--scopes", "-s", help="New scopes or role preset (e.g., @coder, +exec:package_install)"),
2990
- url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
2991
- ):
2992
- """Update permissions/scopes for an existing AI Agent application."""
2993
- api_key, base = _resolve_client(url, require_key=False)
2994
- hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2995
- if api_key: hdrs["x-api-key"] = api_key
2996
-
2997
- with _Spin(f"updating permissions for {client_id}…"):
2998
- res = httpx.patch(
2999
- f"{base}/api/agent/app/{client_id}",
3000
- headers=hdrs,
3001
- json={"scopes": scopes},
3002
- timeout=10.0
3003
- )
3004
- if res.status_code == 200:
3005
- d = res.json()
3006
- _ok(f"Agent {_ye(client_id)} permissions updated to: {_cy(d.get('scopes', ''))}")
3007
- else:
3008
- _err(f"Failed to update scopes: {res.text}")
3009
-
3010
3057
  @agent_app.command("requests")
3011
3058
  def agent_requests(url: Optional[str] = typer.Option(None, "--url", help="Override server URL")):
3012
3059
  """List pending device registration requests."""
@@ -3094,64 +3141,11 @@ def agent_reject(request_id: str, url: Optional[str] = typer.Option(None, "--url
3094
3141
  else:
3095
3142
  _err(f"Rejection failed: {res.text}")
3096
3143
 
3097
- @app.command("guard")
3098
- def guard_console(url: Optional[str] = typer.Option(None, "--url", help="Override server URL")):
3099
- import time
3100
- """Interactive real-time terminal console that listens for agent access requests."""
3101
- api_key, base = _resolve_client(url, require_key=False)
3102
- hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
3103
- if api_key: hdrs["x-api-key"] = api_key
3104
- print(f"\n {_G}◉{_RS} {_W}Akash Guard Console Online{_RS}")
3105
- print(f" {_DM}Listening for incoming agent device registrations (Ctrl-C to stop)…{_RS}\n")
3106
-
3107
- handled_requests = set()
3108
- while True:
3109
- try:
3110
- res = httpx.get(
3111
- f"{base}/api/agent/requests",
3112
- headers=hdrs,
3113
- timeout=5.0
3114
- )
3115
- if res.status_code == 200:
3116
- for r in res.json().get("requests", []):
3117
- rid = r["request_id"]
3118
- if r["status"] == "pending" and rid not in handled_requests:
3119
- _render_inspection_card(r)
3120
- try:
3121
- sys.stdout.write(f" Approve access for this device? [y/N]: ")
3122
- sys.stdout.flush()
3123
- choice = sys.stdin.readline().strip().lower()
3124
- except (KeyboardInterrupt, EOFError):
3125
- choice = "n"
3126
-
3127
- if choice == "y":
3128
- httpx.post(f"{base}/api/agent/requests/{rid}/approve", headers=hdrs, timeout=5)
3129
- _ok(f"Approved {rid} - Access token released to agent!\n")
3130
- else:
3131
- httpx.post(f"{base}/api/agent/requests/{rid}/reject", headers=hdrs, timeout=5)
3132
- _warn(f"Rejected {rid}.\n")
3133
- handled_requests.add(rid)
3134
- time.sleep(2.0)
3135
- except KeyboardInterrupt:
3136
- print(f"\n {_DM}Guard console stopped.{_RS}\n")
3137
- break
3138
- except Exception:
3139
- time.sleep(3.0)
3140
-
3141
3144
 
3142
3145
  # =============================================================================
3143
- # MODEL CONTEXT PROTOCOL (MCP) V6 POWER-SUITE
3146
+ # MODEL CONTEXT PROTOCOL (MCP) & AI AGENT ENGINE (MERGED)
3144
3147
  # =============================================================================
3145
3148
 
3146
- mcp_app = typer.Typer(help="Model Context Protocol (MCP) server for AI agents.")
3147
- app.add_typer(mcp_app, name="mcp", help="Model Context Protocol (MCP) bridge for AI agents")
3148
- mcp_app.command("guard", help="Terminal approval console for incoming agent requests")(guard_console)
3149
-
3150
- @mcp_app.command("list")
3151
- def mcp_list(raw: bool = typer.Option(False, "--json", help="Raw JSON output")):
3152
- """List all available Agent Profile IDs for MCP."""
3153
- agent_list(raw=raw)
3154
-
3155
3149
  _ASYNC_JOBS = {}
3156
3150
  _LAST_UNDO_DIR = Path.home() / ".akash" / "undo_vault"
3157
3151
 
@@ -3268,7 +3262,7 @@ def _parse_temp_duration(val: str) -> int:
3268
3262
  elif unit in ('d', 'day', 'days'): return num * 1440
3269
3263
  return num
3270
3264
 
3271
- @mcp_app.command("serve")
3265
+ @agent_app.command("serve")
3272
3266
  def mcp_serve(
3273
3267
  url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
3274
3268
  client_id: Optional[str] = typer.Option(None, "--client-id", help="Agent Client ID"),
@@ -3449,8 +3443,8 @@ def mcp_serve(
3449
3443
  print(f" {_GR}{now_t}{_RS} {_Y}⚠{_RS} {_dim(f'Worker disconnected ({e}). Reconnecting in {backoff:.1f}s (attempt {reconnect_attempts})…')}")
3450
3444
  time.sleep(backoff)
3451
3445
 
3452
- @mcp_app.command("share")
3453
- @mcp_app.command("url", hidden=True)
3446
+ @agent_app.command("share")
3447
+ @agent_app.command("url", hidden=True)
3454
3448
  def mcp_share(
3455
3449
  target: Optional[str] = typer.Argument(None, help="Profile ID (optional if only 1 profile exists)"),
3456
3450
  id: Optional[str] = typer.Option(None, "--id", "-i", help="Profile ID"),
@@ -3642,123 +3636,7 @@ def mcp_share(
3642
3636
  _p()
3643
3637
 
3644
3638
 
3645
- @mcp_app.command("url", hidden=True)
3646
- def mcp_share(
3647
- target: Optional[str] = typer.Argument(None, help="Profile ID (optional if only 1 profile exists)"),
3648
- id: Optional[str] = typer.Option(None, "--id", "-i", help="Profile ID"),
3649
- temp: Optional[str] = typer.Option(None, "--temp", "-t", help="Temporary short URL duration (e.g. '30m', '30 min', '1h')"),
3650
- codex: bool = typer.Option(False, "--codex", "-c", help="Show snippet only for Codex CLI/Desktop"),
3651
- claude: bool = typer.Option(False, "--claude", help="Show snippet only for Claude Desktop / Cursor"),
3652
- keys: bool = typer.Option(False, "--keys", "-k", help="Show raw Client ID and Secret"),
3653
- url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
3654
- ):
3655
- """Generate ready-to-copy MCP configuration for Codex, Claude, or Cursor on ANY device."""
3656
- import json, urllib.parse
3657
- from pathlib import Path
3658
-
3659
- agents_file = Path.home() / ".akash" / "agents.json"
3660
- if not agents_file.exists():
3661
- _err("No saved agent profiles found. Create one with: akash agent create")
3662
- raise typer.Exit(1)
3663
-
3664
- try:
3665
- agents = json.loads(agents_file.read_text(encoding="utf-8"))
3666
- except Exception: agents = {}
3667
-
3668
- target_id = id or target
3669
- if not target_id:
3670
- if len(agents) == 1:
3671
- target_id = next(iter(agents.keys()))
3672
- elif "af4f" in agents:
3673
- target_id = "af4f"
3674
- else:
3675
- _err("Multiple profiles found. Specify which one: akash mcp share <ID>")
3676
- raise typer.Exit(1)
3677
-
3678
- pid, cid, name = _resolve_agent_id(target_id)
3679
- profile = agents.get(pid, {})
3680
- csec = profile.get("client_secret", "")
3681
-
3682
- if not csec:
3683
- _err(f"No client_secret found for profile {pid}.")
3684
- raise typer.Exit(1)
3685
-
3686
- _, base = _resolve_client(url or profile.get("url"), require_key=False, silent=True)
3687
- dev_fp = _device_fingerprint()
3688
-
3689
- lan_ip = _get_lan_ip()
3690
- port = base.split(":")[-1] if ":" in base.replace("http://", "").replace("https://", "") else "8000"
3691
- wifi_base = f"http://{lan_ip}:{port}"
3692
-
3693
- if temp:
3694
- try:
3695
- ttl_min = _parse_temp_duration(temp)
3696
- except ValueError as ve:
3697
- _err(str(ve))
3698
- raise typer.Exit(1)
3699
-
3700
- with _Spin(f"generating {ttl_min}m short link…"):
3701
- try:
3702
- res = httpx.post(
3703
- f"{base}/api/agent/mcp/ticket",
3704
- json={"client_id": cid, "client_secret": csec, "device_fp": dev_fp, "ttl_minutes": ttl_min},
3705
- timeout=10.0
3706
- )
3707
- res.raise_for_status()
3708
- d = res.json()
3709
- tcode = d["ticket_code"]
3710
- local_url = f"{base}/mcp/t/{tcode}"
3711
- wifi_url = f"{wifi_base}/mcp/t/{tcode}"
3712
- mode_label = f"Temporary ({ttl_min} mins · Ephemeral)"
3713
- except Exception as e:
3714
- _err(f"Failed to issue temporary ticket: {e}")
3715
- raise typer.Exit(1)
3716
- else:
3717
- params = urllib.parse.urlencode({"cid": cid, "csec": csec, "dfp": dev_fp})
3718
- local_url = f"{base}/mcp?{params}"
3719
- wifi_url = f"{wifi_base}/mcp?{params}"
3720
- mode_label = "Permanent Small & Secure"
3721
-
3722
- _p()
3723
- print(" " + _C + "◈" + _RS + " " + _W + "Akash MCP · Quick Connect" + _RS)
3724
- print(" " + _GR + "──────────────────────────────────────────────────" + _RS)
3725
- print(" " + _DM + "Agent : " + _RS + _ye(name) + " " + _dim("(" + pid + ")"))
3726
- print(" " + _DM + "Mode : " + _RS + _C + mode_label + _RS)
3727
- if keys:
3728
- print(" " + _DM + "Client ID: " + _RS + _dim(cid))
3729
- print(" " + _DM + "Secret : " + _RS + _dim(csec))
3730
- print(" " + _DM + "Device FP: " + _RS + _dim(dev_fp))
3731
- print(" " + _GR + "──────────────────────────────────────────────────" + _RS)
3732
- _p()
3733
-
3734
- show_all = not codex and not claude
3735
-
3736
- if show_all or codex:
3737
- print(" " + _C + "For Codex CLI / Desktop" + _RS + " " + _dim("(~/.codex/config.toml)") + ":")
3738
- print(" " + _DM + "Option A: Same Device (Termux / Localhost)" + _RS)
3739
- print(" [mcp_servers.akash]")
3740
- print(' url = "' + local_url + '"')
3741
- if lan_ip != "127.0.0.1":
3742
- print()
3743
- print(" " + _DM + "Option B: Other Device (Over Wi-Fi)" + _RS)
3744
- print(" [mcp_servers.akash]")
3745
- print(' url = "' + wifi_url + '"')
3746
- print(" " + _GR + "──────────────────────────────────────────────────" + _RS)
3747
- _p()
3748
-
3749
- if show_all or claude:
3750
- print(" " + _C + "For Claude Desktop / Cursor" + _RS + " " + _dim("(config.json)") + ":")
3751
- claude_cfg = json.dumps({"mcpServers": {"akash": {"url": local_url}}}, indent=2)
3752
- for c_line in claude_cfg.splitlines():
3753
- print(" " + c_line)
3754
- print(" " + _GR + "──────────────────────────────────────────────────" + _RS)
3755
- _p()
3756
-
3757
- _info("Paste snippet into AI client, then keep running:")
3758
- _info(f" {_cy('akash mcp serve')}")
3759
- _p()
3760
-
3761
- @mcp_app.command("status")
3639
+ @agent_app.command("status")
3762
3640
  def mcp_status(
3763
3641
  url: Optional[str] = typer.Option(None, "--url", help="Override server URL")
3764
3642
  ):
@@ -0,0 +1,225 @@
1
+ """
2
+ updater.py — Asynchronous, cached PyPI update checker for akashcli.
3
+
4
+ - Queries PyPI (https://pypi.org/pypi/akashcli/json) for the latest release.
5
+ - Caches response in ~/.akash/update_check.json with a 6-hour TTL.
6
+ - Background refresh runs detached so CLI commands have 0ms network latency.
7
+ - Renders an aligned, non-intrusive warning box when an update is available.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import json
13
+ import time
14
+ import subprocess
15
+ import unicodedata
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Optional, Dict, Tuple
19
+
20
+ CACHE_FILE = Path.home() / ".akash" / "update_check.json"
21
+ CHECK_INTERVAL_SECONDS = 6 * 3600 # 6 hours
22
+ PYPI_URL = "https://pypi.org/pypi/akashcli/json"
23
+
24
+ _already_printed = False
25
+
26
+ # ANSI Color Palette
27
+ _C = "\033[1;36m" # cyan bold
28
+ _W = "\033[1;37m" # white bold
29
+ _G = "\033[1;32m" # green bold
30
+ _Y = "\033[1;33m" # yellow bold
31
+ _DM = "\033[2m" # dim
32
+ _GR = "\033[90m" # gray
33
+ _RS = "\033[0m" # reset
34
+
35
+
36
+ def _cell_width(text: str) -> int:
37
+ """Calculate terminal display width accounting for wide characters and ANSI codes."""
38
+ clean = re.sub(r'\x1b\[[0-9;]*m', '', text)
39
+ width = 0
40
+ for ch in clean:
41
+ if unicodedata.east_asian_width(ch) in ('W', 'F'):
42
+ width += 2
43
+ else:
44
+ width += 1
45
+ return width
46
+
47
+
48
+ def is_newer_version(latest: str, current: str) -> bool:
49
+ """Compare two semantic version strings safely."""
50
+ if not latest or not current:
51
+ return False
52
+ try:
53
+ from packaging.version import parse
54
+ return parse(latest) > parse(current)
55
+ except Exception:
56
+ try:
57
+ def to_tuple(v: str) -> Tuple[int, ...]:
58
+ nums = [int(x) for x in re.findall(r'\d+', v.split("+")[0])]
59
+ return tuple(nums)
60
+ return to_tuple(latest) > to_tuple(current)
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ def fetch_latest_version(timeout: float = 2.0) -> Optional[str]:
66
+ """Synchronously fetch the latest version of akashcli from PyPI."""
67
+ try:
68
+ import httpx
69
+ with httpx.Client(timeout=timeout) as client:
70
+ resp = client.get(PYPI_URL)
71
+ if resp.status_code == 200:
72
+ data = resp.json()
73
+ return data.get("info", {}).get("version")
74
+ except Exception:
75
+ pass
76
+ return None
77
+
78
+
79
+ def read_cache() -> Optional[Dict]:
80
+ """Read the cached update check result."""
81
+ try:
82
+ if CACHE_FILE.exists():
83
+ return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
84
+ except Exception:
85
+ pass
86
+ return None
87
+
88
+
89
+ def write_cache(latest_version: str):
90
+ """Write the latest version to cache."""
91
+ try:
92
+ CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
93
+ data = {
94
+ "last_checked": time.time(),
95
+ "latest_version": latest_version
96
+ }
97
+ CACHE_FILE.write_text(json.dumps(data), encoding="utf-8")
98
+ except Exception:
99
+ pass
100
+
101
+
102
+ def trigger_background_check():
103
+ """Trigger a detached background process to query PyPI and update the cache."""
104
+ try:
105
+ subprocess.Popen(
106
+ [sys.executable, "-m", "akashcli.updater"],
107
+ stdout=subprocess.DEVNULL,
108
+ stderr=subprocess.DEVNULL,
109
+ stdin=subprocess.DEVNULL,
110
+ start_new_session=True,
111
+ close_fds=True,
112
+ )
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def check_for_updates(current_version: str, force_sync: bool = False) -> Tuple[bool, Optional[str]]:
118
+ """
119
+ Check if an update is available.
120
+ Returns (is_available, latest_version).
121
+ """
122
+ if os.environ.get("AKASH_NO_UPDATE_CHECK", "").lower() in ("1", "true", "yes"):
123
+ return False, None
124
+ if os.environ.get("CI", "").lower() in ("1", "true", "yes"):
125
+ return False, None
126
+
127
+ now = time.time()
128
+ cache = read_cache()
129
+
130
+ if force_sync:
131
+ latest = fetch_latest_version(timeout=2.0)
132
+ if latest:
133
+ write_cache(latest)
134
+ return is_newer_version(latest, current_version), latest
135
+ # If fetch fails, fall back to cache
136
+ if cache and "latest_version" in cache:
137
+ latest = cache["latest_version"]
138
+ return is_newer_version(latest, current_version), latest
139
+ return False, None
140
+
141
+ # Check cache TTL
142
+ needs_refresh = True
143
+ if cache and isinstance(cache, dict):
144
+ last_checked = cache.get("last_checked", 0)
145
+ if (now - last_checked) < CHECK_INTERVAL_SECONDS:
146
+ needs_refresh = False
147
+
148
+ if needs_refresh:
149
+ trigger_background_check()
150
+
151
+ if cache and "latest_version" in cache:
152
+ latest = cache["latest_version"]
153
+ if is_newer_version(latest, current_version):
154
+ return True, latest
155
+
156
+ return False, None
157
+
158
+
159
+ def format_update_box(current: str, latest: str) -> str:
160
+ """Format a styled, pixel-aligned terminal banner for the update notification."""
161
+ line1 = f" {_Y}⚡{_RS} {_W}Update available:{_RS} {_DM}{current}{_RS} {_C}→{_RS} {_G}{latest}{_RS}"
162
+ cmd_str = "pip install -U akashcli"
163
+ line2 = f" {_DM}Run:{_RS} {_C}{cmd_str}{_RS} {_DM}to update{_RS}"
164
+
165
+ w1 = _cell_width(line1)
166
+ w2 = _cell_width(line2)
167
+
168
+ width = max(w1, w2) + 4
169
+ pad1 = width - w1
170
+ pad2 = width - w2
171
+
172
+ top = f" {_GR}╭{'─' * width}╮{_RS}"
173
+ mid1 = f" {_GR}│{_RS}{line1}{' ' * pad1}{_GR}│{_RS}"
174
+ mid2 = f" {_GR}│{_RS}{line2}{' ' * pad2}{_GR}│{_RS}"
175
+ bot = f" {_GR}╰{'─' * width}╯{_RS}"
176
+
177
+ return f"\n{top}\n{mid1}\n{mid2}\n{bot}\n"
178
+
179
+
180
+ def should_suppress_notice() -> bool:
181
+ """Check if the notice should be suppressed (e.g. non-tty or json/pipe output)."""
182
+ if os.environ.get("AKASH_NO_UPDATE_CHECK", "").lower() in ("1", "true", "yes"):
183
+ return True
184
+ if not sys.stdout.isatty():
185
+ return True
186
+ # Suppress in quiet or raw data commands
187
+ for arg in sys.argv[1:]:
188
+ if arg in ("--json", "--raw", "--quiet", "-q", "pipe", "stream"):
189
+ return True
190
+ return False
191
+
192
+
193
+ def show_update_warning_if_available():
194
+ """Atexit / teardown hook: show update warning if available and not yet printed."""
195
+ global _already_printed
196
+ if _already_printed:
197
+ return
198
+ if should_suppress_notice():
199
+ return
200
+
201
+ try:
202
+ from . import __version__
203
+ available, latest = check_for_updates(__version__, force_sync=False)
204
+ if available and latest:
205
+ _already_printed = True
206
+ print(format_update_box(__version__, latest))
207
+ except Exception:
208
+ pass
209
+
210
+
211
+ def mark_update_printed():
212
+ """Mark update warning as printed so it won't duplicate on exit."""
213
+ global _already_printed
214
+ _already_printed = True
215
+
216
+
217
+ def main():
218
+ """Background worker entry point: python3 -m akashcli.updater"""
219
+ latest = fetch_latest_version(timeout=4.0)
220
+ if latest:
221
+ write_cache(latest)
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.7.0
3
+ Version: 4.7.2
4
4
  Summary: Akash CLI — secure remote shell relay, file vault, and device management
5
5
  License-Expression: MIT
6
6
  Keywords: akash,cli,relay,remote-shell,vault
@@ -20,6 +20,7 @@ akashcli/restore.py
20
20
  akashcli/snapshot_crypto.py
21
21
  akashcli/storage.py
22
22
  akashcli/terminal.py
23
+ akashcli/updater.py
23
24
  akashcli/uploader.py
24
25
  akashcli/utils.py
25
26
  akashcli.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akashcli"
3
- version = "4.7.0"
3
+ version = "4.7.2"
4
4
  description = "Akash CLI — secure remote shell relay, file vault, and device management"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes