akashcli 4.6.7__tar.gz → 4.6.9__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 (32) hide show
  1. {akashcli-4.6.7 → akashcli-4.6.9}/PKG-INFO +1 -1
  2. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/__init__.py +1 -1
  3. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/cli.py +555 -47
  4. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/relay.py +9 -3
  5. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/uploader.py +40 -25
  6. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/PKG-INFO +1 -1
  7. {akashcli-4.6.7 → akashcli-4.6.9}/pyproject.toml +1 -1
  8. {akashcli-4.6.7 → akashcli-4.6.9}/README.md +0 -0
  9. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/_net.py +0 -0
  10. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/auth.py +0 -0
  11. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/client.py +0 -0
  12. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/config.py +0 -0
  13. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/crypto.py +0 -0
  14. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/env_manager.py +0 -0
  15. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/exceptions.py +0 -0
  16. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/exec.py +0 -0
  17. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/integrity.py +0 -0
  18. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/logger.py +0 -0
  19. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/models.py +0 -0
  20. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/p2p.py +0 -0
  21. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/packages.py +0 -0
  22. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/restore.py +0 -0
  23. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/snapshot_crypto.py +0 -0
  24. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/storage.py +0 -0
  25. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/terminal.py +0 -0
  26. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli/utils.py +0 -0
  27. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/SOURCES.txt +0 -0
  28. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/dependency_links.txt +0 -0
  29. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/entry_points.txt +0 -0
  30. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/requires.txt +0 -0
  31. {akashcli-4.6.7 → akashcli-4.6.9}/akashcli.egg-info/top_level.txt +0 -0
  32. {akashcli-4.6.7 → akashcli-4.6.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.6.7
3
+ Version: 4.6.9
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,6 +2,6 @@ try:
2
2
  from importlib.metadata import version
3
3
  __version__ = version("akashcli")
4
4
  except Exception:
5
- __version__ = "4.6.7"
5
+ __version__ = "4.6.9"
6
6
 
7
7
  AKASH_VERSION = __version__
@@ -1,3 +1,4 @@
1
+ import time
1
2
  import os
2
3
  import sys
3
4
  import json
@@ -14,6 +15,7 @@ from .client import AkashClient
14
15
  from .auth import AuthManager
15
16
  from .config import ConfigManager
16
17
  from .exceptions import AkashError
18
+ from .exec import _device_fingerprint
17
19
  from .uploader import VaultUploader
18
20
 
19
21
  # ── ANSI palette (matches exec.py) ───────────────────────────────────────────
@@ -40,25 +42,37 @@ def _gr(s: str) -> str: return f"{_GR}{s}{_RS}"
40
42
 
41
43
  # ── Spinner (single-line, no flicker) ────────────────────────────────────────
42
44
  class _Spin:
43
- """Minimal inline spinner - uses \r, no alternate screen, no deps."""
45
+ """Minimal inline spinner with instant frame-0 rendering, smooth 350ms visibility, and clean cleanup."""
44
46
  _FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
45
47
 
46
- def __init__(self, label: str):
48
+ def __init__(self, label: str, min_seconds: float = 0.35, disabled: bool = False):
47
49
  self._label = label
50
+ self._disabled = disabled or (not sys.stdout.isatty())
51
+ self._min_seconds = min_seconds
48
52
  self._i = 0
49
53
  self._active = False
50
54
  self._thread = None
55
+ self._t0 = 0.0
51
56
 
52
57
  def __enter__(self):
58
+ if self._disabled:
59
+ return self
53
60
  import threading, time
54
61
  self._active = True
62
+ self._t0 = time.monotonic()
63
+ cr = chr(13)
64
+ sys.stdout.write(cr + " " + _C + self._FRAMES[0] + _RS + " " + _DM + self._label + _RS + " ")
65
+ sys.stdout.flush()
66
+
55
67
  def _spin():
68
+ time.sleep(0.04)
56
69
  while self._active:
57
70
  f = self._FRAMES[self._i % len(self._FRAMES)]
58
- sys.stdout.write(f"\r {_C}{f}{_RS} {_DM}{self._label}{_RS} ")
71
+ sys.stdout.write(chr(13) + " " + _C + f + _RS + " " + _DM + self._label + _RS + " ")
59
72
  sys.stdout.flush()
60
73
  self._i += 1
61
- time.sleep(0.08)
74
+ time.sleep(0.07)
75
+
62
76
  self._thread = threading.Thread(target=_spin, daemon=True)
63
77
  self._thread.start()
64
78
  return self
@@ -67,22 +81,28 @@ class _Spin:
67
81
  self._label = label
68
82
 
69
83
  def __exit__(self, *_):
84
+ if self._disabled:
85
+ return
86
+ import time
87
+ elapsed = time.monotonic() - self._t0
88
+ if elapsed < self._min_seconds:
89
+ time.sleep(self._min_seconds - elapsed)
70
90
  self._active = False
71
91
  if self._thread:
72
- self._thread.join(timeout=0.3)
73
- sys.stdout.write("\r\033[2K") # clear line
92
+ self._thread.join(timeout=0.2)
93
+ sys.stdout.write(chr(13) + chr(27) + "[2K")
74
94
  sys.stdout.flush()
75
95
 
76
-
77
- def _resolve_client(url_override: str = None, require_key: bool = False):
96
+ def _resolve_client(url_override: str = None, require_key: bool = False, silent: bool = False):
78
97
  """
79
98
  Return (api_key_or_None, base_url).
99
+
80
100
  require_key=True - hard-fail with a clear message if no key found
101
+ (used by serve/devices which cannot work without server auth).
81
102
  require_key=False - return None for api_key if missing; caller shows warning
103
+ and can offer a local/offline fallback.
82
104
  """
83
105
  import os as _os
84
- from urllib.parse import urlparse as _up
85
- from ._net import patch_dns_globally as _patch_dns
86
106
  config = ConfigManager.load()
87
107
  base = (
88
108
  url_override
@@ -90,19 +110,19 @@ def _resolve_client(url_override: str = None, require_key: bool = False):
90
110
  or config.get("api_url", "https://jstore.2bd.net")
91
111
  ).rstrip("/")
92
112
 
93
- host = _up(base).hostname or ""
94
- if host:
95
- try: _patch_dns(host)
96
- except Exception: pass
97
-
98
113
  api_key = _os.getenv("AKASH_API_KEY") or config.get("api_key") or None
99
114
 
100
115
  if not api_key and require_key:
101
- print(f" {_Y}!{_RS} No API key found.")
102
- print(f" {_DM}Run {_C}akash login{_RS}{_DM} to save your key, or set {_C}AKASH_API_KEY{_RS}{_DM}.{_RS}")
116
+ if not silent:
117
+ print(f" {_Y}!{_RS} No API key found.")
118
+ print(f" {_DM}Run {_C}akash login{_RS}{_DM} to save your key, or set {_C}AKASH_API_KEY{_RS}{_DM}.{_RS}")
119
+ import sys
120
+ if silent: sys.exit(1)
103
121
  raise typer.Exit(1)
104
122
 
105
123
  return api_key, base
124
+
125
+
106
126
  _GUEST_WARNING = (
107
127
  f" {_Y}⚠{_RS} {_Y}Running as guest{_RS} - not logged in.\n"
108
128
  f" {_DM}Features are limited and your data won't be saved to the cloud.\n"
@@ -143,13 +163,6 @@ def _upload_with_progress(client: AkashClient, tmp_path: str, password=None) ->
143
163
  # `app` - primary commands (backup / restore / pull / run / shell …)
144
164
  # `cloud_app` - file management (akash cloud upload/files/stream/delete/manage/bulk)
145
165
 
146
-
147
- def _version_callback(value: bool):
148
- if value:
149
- from . import __version__
150
- print("akashcli v" + __version__)
151
- raise typer.Exit(0)
152
-
153
166
  app = typer.Typer(
154
167
  invoke_without_command=True,
155
168
  rich_markup_mode=None,
@@ -166,11 +179,18 @@ def _print_version(value: bool):
166
179
  print(f"akashcli v{__version__}")
167
180
  raise typer.Exit(0)
168
181
 
182
+ def _version_callback(value: bool):
183
+ if value:
184
+ from . import __version__
185
+ print(f"akashcli v{__version__}")
186
+ raise typer.Exit(0)
187
+
188
+ def raise_exit():
189
+ raise typer.Exit(0)
190
+
169
191
  @app.callback(invoke_without_command=True)
170
192
  def _main(ctx: typer.Context,
171
- version: bool = typer.Option(False, "--version", "-v", is_eager=True, expose_value=False,
172
- callback=_print_version,
173
- help="Show version and exit."),
193
+ version: Optional[bool] = typer.Option(None, "--version", "-v", callback=_version_callback, is_eager=True, help="Show version and exit."),
174
194
  help: bool = typer.Option(False, "--help", "-h", is_eager=True, expose_value=False,
175
195
  callback=lambda v: (_print_help(), raise_exit()) if v else None,
176
196
  help="Show this message and exit.")):
@@ -179,8 +199,6 @@ def _main(ctx: typer.Context,
179
199
  _print_help()
180
200
  raise typer.Exit(0)
181
201
 
182
-
183
-
184
202
  def _print_help():
185
203
  C, G, Y, DM, RS = _C, _G, _Y, _DM, _RS
186
204
  lines = [
@@ -190,6 +208,7 @@ def _print_help():
190
208
  f" {DM}ACCOUNT{RS}",
191
209
  f" {C}login{RS} {DM}[--label NAME]{RS} save your API key + name this device",
192
210
  f" {C}whoami{RS} show account info and storage quota",
211
+ f" {C}version{RS} show CLI and server version",
193
212
  f" {C}logout{RS} clear saved credentials",
194
213
  f"",
195
214
  f" {DM}TERMINAL BACKUP{RS}",
@@ -276,22 +295,24 @@ def login(
276
295
 
277
296
  # ── akash whoami ─────────────────────────────────────────────────────────────
278
297
 
279
-
280
298
  @app.command()
281
299
  def version(url: Optional[str] = typer.Option(None, "--url", help="Server URL to check")):
282
- """Show akash CLI version and connected server version."""
300
+ """Show akash CLI and connected server version with animated status."""
283
301
  from . import __version__
284
302
  ver_tag = "v" + __version__
285
303
  print(f"\n {_C}◈{_RS} {_W}akashcli{_RS} {_cy(ver_tag)}")
286
304
  try:
287
- _, base = _resolve_client(url, require_key=False)
288
- resp = httpx.get(f"{base}/health", timeout=3.0)
305
+ with _Spin("querying server status…"):
306
+ _, base = _resolve_client(url, require_key=False, silent=True)
307
+ resp = httpx.get(f"{base}/health", timeout=8.0)
289
308
  if resp.status_code == 200:
290
309
  s_ver = resp.json().get("version", "unknown")
291
- srv_tag = "(" + s_ver + ")"
292
- print(f" {_DM}server {_RS}{_cy(base)} {_dim(srv_tag)}")
310
+ srv_str = "(" + s_ver + ")"
311
+ print(f" {_DM}server {_RS}{_cy(base)} {_dim(srv_str)}")
312
+ else:
313
+ print(f" {_DM}server {_RS}{_cy(base)} {_dim(f'(HTTP {resp.status_code})')}")
293
314
  except Exception:
294
- pass
315
+ print(f" {_DM}server {_RS}{_cy(base)} {_dim('(unreachable)')}")
295
316
  print()
296
317
 
297
318
  @app.command()
@@ -352,7 +373,8 @@ def cloud_files(raw: bool = typer.Option(False, "--json", help="Raw JSON output"
352
373
  """List all files stored in the cloud."""
353
374
  try:
354
375
  client = AkashClient()
355
- file_list = client.list_files()
376
+ with _Spin("fetching cloud vault files…", disabled=raw):
377
+ file_list = client.list_files()
356
378
 
357
379
  if raw:
358
380
  print(json.dumps([f.__dict__ for f in file_list], indent=2))
@@ -724,7 +746,8 @@ def saves(raw: bool = typer.Option(False, "--json", help="Raw JSON output")):
724
746
 
725
747
  try:
726
748
  client = AkashClient()
727
- snaps = list_snapshots(client)
749
+ with _Spin("querying cloud saves…", disabled=raw):
750
+ snaps = list_snapshots(client)
728
751
 
729
752
  if not snaps:
730
753
  _info("no saves found - run `akash backup` to create one")
@@ -790,7 +813,8 @@ def pull(
790
813
  params["password"] = password
791
814
 
792
815
  try:
793
- resp = httpx.get(f"{base}/api/recover/{code}", params=params, timeout=15)
816
+ with _Spin(f"resolving snapshot {code}…"):
817
+ resp = httpx.get(f"{base}/api/recover/{code}", params=params, timeout=15)
794
818
  except Exception as e:
795
819
  _err(f"could not reach server: {e}")
796
820
  raise typer.Exit(1)
@@ -935,14 +959,15 @@ def check(
935
959
  """
936
960
  if seal:
937
961
  from .integrity import seal as _seal, _manifest_path
938
- _info("sealing source integrity…")
939
- hashes = _seal()
962
+ with _Spin("sealing package source files…"):
963
+ hashes = _seal()
940
964
  _ok(f"hashed {len(hashes)} source files")
941
965
  _ok(f"manifest → {_dim(str(_manifest_path()))}")
942
966
  _info("tamper detection active for backup, restore, and pull")
943
967
  else:
944
968
  from .integrity import status as _status
945
- s = _status()
969
+ with _Spin("verifying source code integrity…"):
970
+ s = _status()
946
971
  if not s["sealed"]:
947
972
  _warn(f"not sealed yet - run {_cy('akash check --seal')} to enable tamper detection")
948
973
  return
@@ -1010,9 +1035,14 @@ def run(
1010
1035
  from .relay import connect_run
1011
1036
  _info(f"running on device {_cy(device or 'auto')}: {command}")
1012
1037
  _p()
1013
- with _Spin("connecting to relay…"):
1014
- pass
1015
- exit_code = connect_run(api_key, base, command, target=device or None)
1038
+ spin = _Spin(f"connecting to relay ({device or 'auto'})…")
1039
+ spin.__enter__()
1040
+ try:
1041
+ def _on_connected(dev):
1042
+ spin.__exit__(None, None, None)
1043
+ exit_code = connect_run(api_key, base, command, target=device or None, on_connected=_on_connected)
1044
+ finally:
1045
+ spin.__exit__(None, None, None)
1016
1046
  _p()
1017
1047
  if exit_code == 0:
1018
1048
  _info("exited 0")
@@ -1202,13 +1232,20 @@ def shell(
1202
1232
  label = device.strip() if device.strip() else None
1203
1233
  _p()
1204
1234
  _info(f"connecting to {_cy(label or 'auto')}…")
1235
+ spin = _Spin(f"connecting to relay ({label or 'auto'})…")
1236
+ spin.__enter__()
1205
1237
  try:
1206
- exit_code = connect_shell(api_key, base, target=label)
1238
+ def _on_connected(dev):
1239
+ spin.__exit__(None, None, None)
1240
+ exit_code = connect_shell(api_key, base, target=label, on_connected=_on_connected)
1207
1241
  except RuntimeError as e:
1242
+ spin.__exit__(None, None, None)
1208
1243
  _err(str(e))
1209
1244
  _p()
1210
1245
  _info(f"Make sure the device is running: {_cy('akash serve')}")
1211
1246
  raise typer.Exit(1)
1247
+ finally:
1248
+ spin.__exit__(None, None, None)
1212
1249
  _p()
1213
1250
  _info("session closed")
1214
1251
  if exit_code != 0:
@@ -1272,7 +1309,7 @@ def devices(
1272
1309
  akash serve register it as reachable
1273
1310
  """
1274
1311
  try:
1275
- api_key, base = _resolve_client(require_key=False)
1312
+ api_key, base = _resolve_client(url, require_key=False)
1276
1313
  except typer.Exit:
1277
1314
  raise
1278
1315
  except Exception as e:
@@ -2320,3 +2357,474 @@ def env_pull(
2320
2357
 
2321
2358
  if __name__ == "__main__":
2322
2359
  app()
2360
+
2361
+
2362
+ # =============================================================================
2363
+ # AKASH AGENT & DEVICE BINDING COMMANDS
2364
+ # =============================================================================
2365
+
2366
+ agent_app = typer.Typer(help="Manage AI agents, client credentials & device approvals.")
2367
+ app.add_typer(agent_app, name="agent", help="agent credentials · device binding · approvals")
2368
+
2369
+ def _render_inspection_card(r: dict) -> None:
2370
+ print(f"\n {_C}╭─ ⚡ INCOMING AGENT DEVICE REGISTRATION ────────────────────────────{_RS}")
2371
+ print(f" {_GR}│{_RS} {_DM}Request ID :{_RS} {_ye(r['request_id'])}")
2372
+ print(f" {_GR}│{_RS} {_DM}Client Name :{_RS} {_W}{r['client_name']}{_RS}")
2373
+ print(f" {_GR}│{_RS} {_DM}Client ID :{_RS} {_dim(r['client_id'])}")
2374
+ print(f" {_GR}│{_RS} {_DM}Device Hostname:{_RS} {_W}{r['device_hostname']}{_RS}")
2375
+ print(f" {_GR}│{_RS} {_DM}Hardware ID :{_RS} {_C}{r['device_fingerprint'][:24]}…{_RS}")
2376
+ print(f" {_GR}│{_RS} {_DM}IP Address :{_RS} {_W}{r['ip_address']}{_RS}")
2377
+ print(f" {_GR}│{_RS} {_DM}Location :{_RS} {_W}{r['city']}, {r['country']}{_RS} {_dim('(' + r['isp'] + ')')}")
2378
+ print(f" {_GR}│{_RS} {_DM}OS / Platform :{_RS} {r['device_platform']}")
2379
+ print(f" {_GR}│{_RS} {_DM}Target Device :{_RS} {_ye(r['target_device'])}")
2380
+ print(f" {_GR}│{_RS} {_DM}Scopes :{_RS} {r['requested_scopes']}")
2381
+ print(f" {_GR}│{_RS} {_DM}Timestamp :{_RS} {_dim(r['created_at'])}")
2382
+ print(f" {_C}╰────────────────────────────────────────────────────────────────────{_RS}")
2383
+
2384
+ @agent_app.command("create")
2385
+ def agent_create(
2386
+ name: str = typer.Option("Cursor IDE", "--name", "-n", help="Name of the AI Agent / Application"),
2387
+ ttl: int = typer.Option(7, "--ttl", "-t", help="Validity in days (e.g. 7)"),
2388
+ scopes: str = typer.Option("workspace:read,relay:exec", "--scopes", "-s", help="Comma-separated scopes"),
2389
+ devices: str = typer.Option("*", "--devices", "-d", help="Allowed device labels (e.g. 'termux,laptop' or '*')"),
2390
+ url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
2391
+ ):
2392
+ """Generate new Client ID and Client Secret with hardware binding."""
2393
+ api_key, base = _resolve_client(url, require_key=False)
2394
+ hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2395
+ if api_key: hdrs["x-api-key"] = api_key
2396
+ with _Spin("generating agent credentials…"):
2397
+ res = httpx.post(
2398
+ f"{base}/api/agent/app",
2399
+ headers=hdrs,
2400
+ json={"name": name, "ttl_days": ttl, "scopes": scopes, "allowed_devices": devices},
2401
+ timeout=10.0
2402
+ )
2403
+ if res.status_code != 200:
2404
+ _err(f"Failed to create agent credentials: {res.text}")
2405
+ raise typer.Exit(1)
2406
+
2407
+ d = res.json()
2408
+
2409
+ import json, os, secrets
2410
+ from pathlib import Path
2411
+ agents_file = Path.home() / ".akash" / "agents.json"
2412
+ agents_file.parent.mkdir(parents=True, exist_ok=True)
2413
+ try:
2414
+ agents = json.loads(agents_file.read_text())
2415
+ except Exception:
2416
+ agents = {}
2417
+
2418
+ profile_id = secrets.token_hex(2)
2419
+ agents[profile_id] = {
2420
+ "client_id": d["client_id"],
2421
+ "client_secret": d["client_secret"],
2422
+ "name": name
2423
+ }
2424
+ agents_file.write_text(json.dumps(agents, indent=2))
2425
+ try: agents_file.chmod(0o600)
2426
+ except OSError: pass
2427
+
2428
+ _p()
2429
+ _ok(f"Agent Credentials Issued & Saved Locally: {_cy(name)}")
2430
+ print(f" {_DM}Profile ID :{_RS} {_C}{profile_id}{_RS}")
2431
+ print(f" {_DM}Client ID :{_RS} {_ye(d['client_id'])}")
2432
+ print(f" {_DM}Client Secret :{_RS} {_W}{d['client_secret']}{_RS}")
2433
+ print(f" {_DM}Expires At :{_RS} {_dim(d['expires_at'][:19].replace('T', ' '))} UTC ({ttl} days)")
2434
+ _p()
2435
+ _info("To use in Cursor or Claude Desktop, add a Command MCP Server with:")
2436
+ if url:
2437
+ print(f" {_G}akash mcp serve --id {profile_id} --url {url}{_RS}")
2438
+ else:
2439
+ print(f" {_G}akash mcp serve --id {profile_id}{_RS}")
2440
+ _p()
2441
+
2442
+ @agent_app.command("requests")
2443
+ def agent_requests():
2444
+ """List pending device registration requests."""
2445
+ api_key, base = _resolve_client(url, require_key=False)
2446
+ hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2447
+ if api_key: hdrs["x-api-key"] = api_key
2448
+ with _Spin("checking pending device requests…"):
2449
+ res = httpx.get(
2450
+ f"{base}/api/agent/requests",
2451
+ headers=hdrs,
2452
+ timeout=10.0
2453
+ )
2454
+ if res.status_code != 200:
2455
+ _err(f"Failed: {res.text}")
2456
+ raise typer.Exit(1)
2457
+
2458
+ reqs = res.json().get("requests", [])
2459
+ if not reqs:
2460
+ _info("no device registration requests found")
2461
+ return
2462
+
2463
+ _p()
2464
+ print(f" {_C}◈{_RS} {_W}Agent Device Registrations{_RS} {_dim(f'({len(reqs)})')}")
2465
+ _p()
2466
+ print(f" {_DM}{'ID':<12} {'AGENT':<16} {'IP':<15} {'LOCATION':<20} STATUS{_RS}")
2467
+ print(f" {_GR}{'─'*72}{_RS}")
2468
+ for r in reqs:
2469
+ st_color = _G if r["status"] == "approved" else (_Y if r["status"] == "pending" else _R)
2470
+ loc = f"{r['city']}, {r['country']}"
2471
+ print(f" {_ye(r['request_id']):<21} {_W}{r['client_name'][:15]:<16}{_RS} {r['ip_address']:<15} {loc[:19]:<20} {st_color}{r['status'].upper()}{_RS}")
2472
+ _p()
2473
+ _info("Inspect details: akash agent inspect <ID>")
2474
+ _info("Approve access: akash agent approve <ID>")
2475
+
2476
+ @agent_app.command("inspect")
2477
+ def agent_inspect(request_id: str):
2478
+ """View full contextual intelligence card for a request."""
2479
+ api_key, base = _resolve_client(url, require_key=False)
2480
+ hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2481
+ if api_key: hdrs["x-api-key"] = api_key
2482
+ with _Spin(f"inspecting {request_id}…"):
2483
+ res = httpx.get(
2484
+ f"{base}/api/agent/requests",
2485
+ headers=hdrs,
2486
+ timeout=10.0
2487
+ )
2488
+ reqs = res.json().get("requests", [])
2489
+ match = next((r for r in reqs if r["request_id"] == request_id), None)
2490
+ if not match:
2491
+ _err(f"Request ID '{request_id}' not found.")
2492
+ raise typer.Exit(1)
2493
+ _render_inspection_card(match)
2494
+
2495
+ @agent_app.command("approve")
2496
+ def agent_approve(request_id: str):
2497
+ """Approve a device registration directly from your terminal."""
2498
+ api_key, base = _resolve_client(url, require_key=False)
2499
+ hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2500
+ if api_key: hdrs["x-api-key"] = api_key
2501
+ with _Spin(f"approving {request_id}…"):
2502
+ res = httpx.post(
2503
+ f"{base}/api/agent/requests/{request_id}/approve",
2504
+ headers=hdrs,
2505
+ timeout=10.0
2506
+ )
2507
+ if res.status_code == 200:
2508
+ _ok(f"Device request {_ye(request_id)} APPROVED! Access token released to agent.")
2509
+ else:
2510
+ _err(f"Approval failed: {res.text}")
2511
+
2512
+ @agent_app.command("reject")
2513
+ def agent_reject(request_id: str):
2514
+ """Reject a device registration request."""
2515
+ api_key, base = _resolve_client(url, require_key=False)
2516
+ hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2517
+ if api_key: hdrs["x-api-key"] = api_key
2518
+ with _Spin(f"rejecting {request_id}…"):
2519
+ res = httpx.post(
2520
+ f"{base}/api/agent/requests/{request_id}/reject",
2521
+ headers=hdrs,
2522
+ timeout=10.0
2523
+ )
2524
+ if res.status_code == 200:
2525
+ _ok(f"Device request {_ye(request_id)} REJECTED.")
2526
+ else:
2527
+ _err(f"Rejection failed: {res.text}")
2528
+
2529
+ @app.command("guard")
2530
+ def guard_console(url: Optional[str] = typer.Option(None, "--url", help="Override server URL")):
2531
+ import time
2532
+ """Interactive real-time terminal console that listens for agent access requests."""
2533
+ api_key, base = _resolve_client(url, require_key=False)
2534
+ hdrs = {"Origin": "https://akash.cli", "x-device-id": _device_fingerprint()}
2535
+ if api_key: hdrs["x-api-key"] = api_key
2536
+ print(f"\n {_G}◉{_RS} {_W}Akash Guard Console Online{_RS}")
2537
+ print(f" {_DM}Listening for incoming agent device registrations (Ctrl-C to stop)…{_RS}\n")
2538
+
2539
+ handled_requests = set()
2540
+ while True:
2541
+ try:
2542
+ res = httpx.get(
2543
+ f"{base}/api/agent/requests",
2544
+ headers=hdrs,
2545
+ timeout=5.0
2546
+ )
2547
+ if res.status_code == 200:
2548
+ for r in res.json().get("requests", []):
2549
+ rid = r["request_id"]
2550
+ if r["status"] == "pending" and rid not in handled_requests:
2551
+ _render_inspection_card(r)
2552
+ try:
2553
+ sys.stdout.write(f" Approve access for this device? [y/N]: ")
2554
+ sys.stdout.flush()
2555
+ choice = sys.stdin.readline().strip().lower()
2556
+ except (KeyboardInterrupt, EOFError):
2557
+ choice = "n"
2558
+
2559
+ if choice == "y":
2560
+ httpx.post(f"{base}/api/agent/requests/{rid}/approve", headers=hdrs, timeout=5)
2561
+ _ok(f"Approved {rid} - Access token released to agent!\n")
2562
+ else:
2563
+ httpx.post(f"{base}/api/agent/requests/{rid}/reject", headers=hdrs, timeout=5)
2564
+ _warn(f"Rejected {rid}.\n")
2565
+ handled_requests.add(rid)
2566
+ time.sleep(2.0)
2567
+ except KeyboardInterrupt:
2568
+ print(f"\n {_DM}Guard console stopped.{_RS}\n")
2569
+ break
2570
+ except Exception:
2571
+ time.sleep(3.0)
2572
+
2573
+
2574
+ # =============================================================================
2575
+ # MODEL CONTEXT PROTOCOL (MCP) SERVER FOR AI AGENTS (Cursor, Claude, AutoGen)
2576
+ # =============================================================================
2577
+
2578
+ mcp_app = typer.Typer(help="Model Context Protocol (MCP) server for AI agents.")
2579
+ app.add_typer(mcp_app, name="mcp", help="Model Context Protocol (MCP) bridge for AI agents")
2580
+
2581
+
2582
+ def _sanitize_agent_command(command: str) -> tuple[bool, str]:
2583
+ """Inspects command for destructive patterns, exfiltration pipes, and jail escapes using Regex."""
2584
+ import re
2585
+ cmd = command.strip()
2586
+ cmd_lower = cmd.lower()
2587
+
2588
+ # 1. Block destructive system wipe commands (e.g. rm -rf /, rm -r -f ~)
2589
+ if re.search(r'rm\s+-[a-zA-Z\s]*[rf]+[a-zA-Z\s]*\s+(/|/\*|~|~\/\*)(?:\s|$)', cmd_lower):
2590
+ return False, "CRITICAL SECURITY BLOCK: Destructive system command rejected (rm -rf root/home)."
2591
+
2592
+ FORBIDDEN_BINARIES = [
2593
+ ":(){ :|:& };:", "mkfs", "dd if=", "> /dev/sda", "> /dev/nvme",
2594
+ "shutdown", "reboot", "init 0", "init 6"
2595
+ ]
2596
+ for fb in FORBIDDEN_BINARIES:
2597
+ if fb in cmd_lower:
2598
+ return False, f"CRITICAL SECURITY BLOCK: Dangerous system command rejected: '{fb}'"
2599
+
2600
+ # 2. Block reverse shells and unverified network pipes (curl/wget ... | bash)
2601
+ if re.search(r'(curl|wget)\b.*?\|\s*(bash|sh|zsh)\b', cmd_lower):
2602
+ return False, "SECURITY BLOCK: Unauthorized network execution pipe rejected."
2603
+
2604
+ NETWORK_EXFIL = ["/dev/tcp/", "/dev/udp/", "nc -e", "ncat -e", "netcat -e"]
2605
+ for np in NETWORK_EXFIL:
2606
+ if np in cmd_lower:
2607
+ return False, f"SECURITY BLOCK: Potential reverse shell rejected: '{np}'"
2608
+
2609
+ # 3. Block access to private credential directories
2610
+ CREDENTIAL_TARGETS = [
2611
+ ".ssh", ".aws", ".akash/machine.key", ".akash/config.enc",
2612
+ "/etc/shadow", "/etc/sudoers"
2613
+ ]
2614
+ for ct in CREDENTIAL_TARGETS:
2615
+ if ct in cmd_lower:
2616
+ return False, f"SECURITY BLOCK: Access to sensitive credential path rejected: '{ct}'"
2617
+
2618
+ return True, ""
2619
+
2620
+ def _redact_agent_output(output: str) -> str:
2621
+ """Masks database passwords, private keys, and API tokens before returning to AI."""
2622
+ from .terminal import sanitize_secret_text
2623
+ return sanitize_secret_text(output)
2624
+
2625
+ @mcp_app.command("serve")
2626
+ def mcp_serve(
2627
+ url: Optional[str] = typer.Option(None, "--url", help="Override server URL"),
2628
+ client_id: str = typer.Option(None, "--client-id", help="Agent Client ID"),
2629
+ client_secret: str = typer.Option(None, "--client-secret", help="Agent Client Secret"),
2630
+ profile_id: str = typer.Option(None, "--id", help="Local Agent Profile ID")
2631
+ ):
2632
+ """Runs standard Model Context Protocol (MCP) JSON-RPC 2.0 server over stdio for AI agents."""
2633
+ import sys, json, platform
2634
+ _, base = _resolve_client(url, require_key=False)
2635
+
2636
+ if profile_id:
2637
+ from pathlib import Path
2638
+ try:
2639
+ agents = json.loads((Path.home() / ".akash" / "agents.json").read_text())
2640
+ if profile_id in agents:
2641
+ client_id = agents[profile_id]["client_id"]
2642
+ client_secret = agents[profile_id]["client_secret"]
2643
+ except Exception:
2644
+ pass
2645
+
2646
+ cid = client_id or os.getenv("AKASH_CLIENT_ID")
2647
+ csec = client_secret or os.getenv("AKASH_CLIENT_SECRET")
2648
+
2649
+ if not cid or not csec:
2650
+ sys.stderr.write("\r\n[MCP ERROR] Missing Client ID or Secret. Please pass --id, or --client-id and --client-secret.\r\n")
2651
+ sys.exit(1)
2652
+
2653
+ # 1. Automatic Handshake & Knock
2654
+ dev_fp = _device_fingerprint()
2655
+ hostname = platform.node() or "unknown-host"
2656
+ os_info = f"{platform.system()} {platform.release()} {platform.machine()}"
2657
+
2658
+ token = None
2659
+ # Check if this machine is already approved or needs to knock
2660
+ try:
2661
+ res = httpx.post(f"{base}/api/agent/token", json={
2662
+ "client_id": cid,
2663
+ "client_secret": csec,
2664
+ "device_fingerprint": dev_fp,
2665
+ "hostname": hostname,
2666
+ "platform": os_info,
2667
+ "target_device": "*"
2668
+ }, timeout=5.0)
2669
+
2670
+ if res.status_code == 202:
2671
+ # Device needs human approval in terminal
2672
+ req_id = res.json().get("request_id")
2673
+ sys.stderr.write(f"\r\n[MCP GATE] Device '{hostname}' ({dev_fp[:16]}...) waiting for approval in 'akash guard' terminal...\r\n")
2674
+ sys.stderr.flush()
2675
+
2676
+ # Poll until human types 'y' in akash guard
2677
+ while not token:
2678
+ time.sleep(1.5)
2679
+ poll_res = httpx.get(f"{base}/api/agent/token/poll/{req_id}", timeout=5.0)
2680
+ if poll_res.status_code == 200:
2681
+ d = poll_res.json()
2682
+ if d.get("status") == "approved":
2683
+ token = d.get("access_token")
2684
+ sys.stderr.write("\r\n[MCP GATE] Device approved! AI Agent connection active.\r\n")
2685
+ sys.stderr.flush()
2686
+ break
2687
+ elif d.get("status") == "rejected":
2688
+ sys.stderr.write("\r\n[MCP GATE] Device rejected by human in Akash Guard.\r\n")
2689
+ sys.stderr.flush()
2690
+ sys.exit(1)
2691
+ elif res.status_code == 200:
2692
+ token = res.json().get("access_token")
2693
+ except Exception as e:
2694
+ sys.stderr.write(f"\r\n[MCP NOTICE] Operating in local sandbox mode ({e})\r\n")
2695
+
2696
+ # 2. Standard MCP Tools Declaration
2697
+ TOOLS = [
2698
+ {
2699
+ "name": "akash_exec_command",
2700
+ "description": "Execute a shell command inside the project workspace. Output is bounded and sanitized.",
2701
+ "inputSchema": {
2702
+ "type": "object",
2703
+ "properties": {
2704
+ "command": {"type": "string", "description": "Command to run (e.g. 'pytest', 'ls -la', 'python3 main.py')"},
2705
+ "cwd": {"type": "string", "description": "Directory relative to workspace root", "default": "."}
2706
+ },
2707
+ "required": ["command"]
2708
+ }
2709
+ },
2710
+ {
2711
+ "name": "akash_checkpoint_create",
2712
+ "description": "Create an encrypted rollback checkpoint in Akash Vault before editing files.",
2713
+ "inputSchema": {
2714
+ "type": "object",
2715
+ "properties": {
2716
+ "label": {"type": "string", "description": "Checkpoint name (e.g. 'pre_refactor')"}
2717
+ },
2718
+ "required": ["label"]
2719
+ }
2720
+ },
2721
+ {
2722
+ "name": "akash_checkpoint_restore",
2723
+ "description": "Roll back project workspace to a previous checkpoint code if mistakes were made.",
2724
+ "inputSchema": {
2725
+ "type": "object",
2726
+ "properties": {
2727
+ "code": {"type": "string", "description": "Vault code to roll back to"}
2728
+ },
2729
+ "required": ["code"]
2730
+ }
2731
+ }
2732
+ ]
2733
+
2734
+ # 3. JSON-RPC 2.0 stdio message loop (Standard MCP protocol)
2735
+ while True:
2736
+ try:
2737
+ line = sys.stdin.readline()
2738
+ if not line:
2739
+ break
2740
+ msg = json.loads(line.strip())
2741
+ msg_id = msg.get("id")
2742
+ method = msg.get("method")
2743
+ params = msg.get("params", {})
2744
+
2745
+ # JSON-RPC 2.0 Specification: Do NOT respond to Notifications (id is None)
2746
+ if "id" not in msg or msg_id is None:
2747
+ continue
2748
+
2749
+ if method == "initialize":
2750
+ reply = {
2751
+ "jsonrpc": "2.0",
2752
+ "id": msg_id,
2753
+ "result": {
2754
+ "protocolVersion": "2024-11-05",
2755
+ "serverInfo": {"name": "akash-mcp", "version": "4.6.7"},
2756
+ "capabilities": {"tools": {}}
2757
+ }
2758
+ }
2759
+ elif method == "tools/list":
2760
+ reply = {
2761
+ "jsonrpc": "2.0",
2762
+ "id": msg_id,
2763
+ "result": {"tools": TOOLS}
2764
+ }
2765
+ elif method == "tools/call":
2766
+ tool_name = params.get("name")
2767
+ args = params.get("arguments", {})
2768
+
2769
+ if tool_name == "akash_exec_command":
2770
+ cmd = args.get("command", "")
2771
+
2772
+ # 1. Pass through Security Firewall
2773
+ is_safe, sec_reason = _sanitize_agent_command(cmd)
2774
+ if not is_safe:
2775
+ sys.stderr.write(f"\r\n🚨 [FIREWALL BLOCKED] {sec_reason}\r\n")
2776
+ sys.stderr.flush()
2777
+ reply = {
2778
+ "jsonrpc": "2.0",
2779
+ "id": msg_id,
2780
+ "result": {
2781
+ "content": [{"type": "text", "text": f"⛔ {sec_reason}"}],
2782
+ "isError": True
2783
+ }
2784
+ }
2785
+ else:
2786
+ # 2. Run safely inside workspace with timeout & secret redaction
2787
+ import subprocess
2788
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
2789
+ raw_out = r.stdout + r.stderr
2790
+ sanitized_out = _redact_agent_output(raw_out)
2791
+
2792
+ # Bounded output ceiling (32 KB max to prevent LLM token flooding)
2793
+ if len(sanitized_out) > 32768:
2794
+ sanitized_out = sanitized_out[:32768] + "\n... [Output truncated at 32 KB to protect context window]"
2795
+
2796
+ reply = {
2797
+ "jsonrpc": "2.0",
2798
+ "id": msg_id,
2799
+ "result": {
2800
+ "content": [{"type": "text", "text": sanitized_out or "(no output)"}],
2801
+ "isError": r.returncode != 0
2802
+ }
2803
+ }
2804
+ elif tool_name == "akash_checkpoint_create":
2805
+ label = args.get("label", "agent_checkpoint")
2806
+ from .terminal import build_snapshot
2807
+ archive_bytes, manifest, name = build_snapshot(label=label)
2808
+ reply = {
2809
+ "jsonrpc": "2.0",
2810
+ "id": msg_id,
2811
+ "result": {
2812
+ "content": [{"type": "text", "text": f"Checkpoint '{label}' created successfully ({len(manifest.get('files', []))} files tracked)."}]
2813
+ }
2814
+ }
2815
+ else:
2816
+ reply = {
2817
+ "jsonrpc": "2.0",
2818
+ "id": msg_id,
2819
+ "error": {"code": -32601, "message": f"Tool '{tool_name}' not found."}
2820
+ }
2821
+ else:
2822
+ reply = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
2823
+
2824
+ sys.stdout.write(json.dumps(reply) + "\n")
2825
+ sys.stdout.flush()
2826
+
2827
+ except Exception as e:
2828
+ err_reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": str(e)}}
2829
+ sys.stdout.write(json.dumps(err_reply) + "\n")
2830
+ sys.stdout.flush()
@@ -25,7 +25,7 @@ Wire protocol (all JSON frames over the relay WebSocket):
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
- AKASH_VERSION = "4.6.7"
28
+ AKASH_VERSION = "4.6.9"
29
29
 
30
30
  import base64
31
31
  import json
@@ -424,7 +424,8 @@ def _handle_pty(ws, frame: dict) -> None:
424
424
  # ── connect() — controller side ───────────────────────────────────────────────
425
425
 
426
426
  def connect_run(api_key: str, base_url: str, command: str,
427
- target: Optional[str] = None) -> int:
427
+ target: Optional[str] = None,
428
+ on_connected: Optional[Callable[[str], None]] = None) -> int:
428
429
  """
429
430
  Run a command on a remote device via the relay.
430
431
  Returns the remote exit code.
@@ -441,6 +442,8 @@ def connect_run(api_key: str, base_url: str, command: str,
441
442
  if ack.get("type") == "error":
442
443
  raise RuntimeError(ack.get("message", "relay error"))
443
444
  device = ack.get("device", target or "?")
445
+ if on_connected:
446
+ on_connected(device)
444
447
 
445
448
  # Send the run command
446
449
  ws.send(json.dumps({"type": "run", "command": command}))
@@ -473,7 +476,8 @@ def connect_run(api_key: str, base_url: str, command: str,
473
476
 
474
477
 
475
478
  def connect_shell(api_key: str, base_url: str,
476
- target: Optional[str] = None) -> int:
479
+ target: Optional[str] = None,
480
+ on_connected: Optional[Callable[[str], None]] = None) -> int:
477
481
  """
478
482
  Open a full interactive PTY shell on a remote device via the relay.
479
483
  Returns the remote exit code.
@@ -517,6 +521,8 @@ def connect_shell(api_key: str, base_url: str,
517
521
  raise RuntimeError(ack.get("message", "relay error"))
518
522
 
519
523
  device = ack.get("device", target or "?")
524
+ if on_connected:
525
+ on_connected(device)
520
526
  # Tell device to open a PTY shell
521
527
  ws.send(json.dumps({
522
528
  "type": "pty_run",
@@ -160,39 +160,54 @@ class VaultUploader:
160
160
  interval: float = 2.0
161
161
  ) -> UploadResult:
162
162
  """
163
- Polls /api/status/{file_code} until the server reports 'done' or 'error'.
163
+ Polls /api/status/{file_code} with animated server-stage feedback.
164
164
  Blocks for at most `timeout` seconds.
165
165
  """
166
166
  deadline = time.time() + timeout
167
167
  url = f"{self.client.base_url}/api/status/{result.file_code}"
168
168
 
169
- while time.time() < deadline:
170
- time.sleep(interval)
169
+ spin = None
170
+ if sys.stdout.isatty():
171
171
  try:
172
- resp = httpx.get(url, headers=self.client._get_headers(), timeout=10)
173
- if resp.status_code == 404:
174
- break # Task cleaned up — treat as done
175
- d = resp.json()
176
- status = d.get("status", "")
177
- if status == "done":
178
- # file_code may be updated in the task record
179
- final_code = d.get("file_code") or result.file_code
180
- return UploadResult(
181
- status="done",
182
- file_code=final_code,
183
- embed_url=result.embed_url.replace(result.file_code, final_code),
184
- direct_url=result.direct_url.replace(result.file_code, final_code),
185
- input_type=result.input_type,
186
- dna=result.dna
187
- )
188
- if status == "error":
189
- raise UploadFailed(f"Server failed to vault file: {d.get('detail', 'unknown error')}")
190
- except UploadFailed:
191
- raise
172
+ from .cli import _Spin
173
+ spin = _Spin(f"vaulting {result.file_code}…")
174
+ spin.__enter__()
192
175
  except Exception:
193
- pass # Transient network hiccup — keep polling
176
+ spin = None
177
+
178
+ try:
179
+ while time.time() < deadline:
180
+ time.sleep(interval)
181
+ try:
182
+ resp = httpx.get(url, headers=self.client._get_headers(), timeout=10)
183
+ if resp.status_code == 404:
184
+ break
185
+ d = resp.json()
186
+ status = d.get("status", "")
187
+ detail = d.get("detail", "")
188
+ if spin and (status or detail):
189
+ stage_txt = detail or status
190
+ spin.update(f"vaulting {result.file_code} ({stage_txt})…")
191
+ if status == "done":
192
+ final_code = d.get("file_code") or result.file_code
193
+ return UploadResult(
194
+ status="done",
195
+ file_code=final_code,
196
+ embed_url=result.embed_url.replace(result.file_code, final_code),
197
+ direct_url=result.direct_url.replace(result.file_code, final_code),
198
+ input_type=result.input_type,
199
+ dna=result.dna
200
+ )
201
+ if status == "error":
202
+ raise UploadFailed(f"Server failed to vault file: {d.get('detail', 'unknown error')}")
203
+ except UploadFailed:
204
+ raise
205
+ except Exception:
206
+ pass
207
+ finally:
208
+ if spin:
209
+ spin.__exit__(None, None, None)
194
210
 
195
- # Timeout: return what we have (caller can still use embed_url)
196
211
  return result
197
212
 
198
213
  def upload_directory(self, folder_path: str, max_workers: int = 3) -> List[UploadResult]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akashcli
3
- Version: 4.6.7
3
+ Version: 4.6.9
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akashcli"
3
- version = "4.6.7"
3
+ version = "4.6.9"
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