forgexa-cli 1.14.6__tar.gz → 1.14.7__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: forgexa-cli
3
- Version: 1.14.6
3
+ Version: 1.14.7
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -26,8 +26,10 @@ Classifier: Topic :: Software Development :: Quality Assurance
26
26
  Requires-Python: >=3.9
27
27
  Description-Content-Type: text/markdown
28
28
  Requires-Dist: httpx>=0.24
29
+ Requires-Dist: socksio>=1.0.0
29
30
  Provides-Extra: daemon
30
31
  Requires-Dist: httpx>=0.24; extra == "daemon"
32
+ Requires-Dist: socksio>=1.0.0; extra == "daemon"
31
33
 
32
34
  # Forgexa CLI
33
35
 
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.14.6"
2
+ __version__ = "1.14.7"
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
523
523
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
524
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
525
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.14.6"
526
+ DAEMON_VERSION = "1.14.7"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -162,21 +162,14 @@ def _refresh_access_token() -> bool:
162
162
  if not refresh_token:
163
163
  return False
164
164
 
165
- import urllib.request
166
- import urllib.error
165
+ import httpx
167
166
 
168
167
  url = f"{_api_url()}/api/v1/auth/refresh"
169
- body = json.dumps({"refresh_token": refresh_token}).encode()
170
- req = urllib.request.Request(
171
- url,
172
- data=body,
173
- headers={"Content-Type": "application/json"},
174
- method="POST",
175
- )
176
168
  try:
177
- with urllib.request.urlopen(req, timeout=15) as resp:
178
- payload = json.loads(resp.read())
179
- except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, OSError):
169
+ with httpx.Client(timeout=15) as client:
170
+ resp = client.post(url, json={"refresh_token": refresh_token})
171
+ payload = resp.json()
172
+ except Exception:
180
173
  return False
181
174
 
182
175
  access_token = str(payload.get("access_token") or "").strip()
@@ -412,6 +405,117 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
412
405
  )
413
406
 
414
407
 
408
+ # ── Update-check helpers ──────────────────────────────────────────────────────
409
+ #
410
+ # Strategy (borrowed from gh CLI / update-notifier):
411
+ # 1. At the start of main(): launch a daemon thread that calls PyPI and
412
+ # writes ~/.forgexa/update-check.json (only when the cache is stale).
413
+ # 2. At the end of main(): read the cache (instant) and, if a newer version
414
+ # is available, print one notice to stderr.
415
+ #
416
+ # Zero latency on the critical path — we never wait for the network.
417
+ # Auto-skipped in CI, non-TTY stderr, `forgexa upgrade`, and --format json.
418
+
419
+ _UPDATE_CACHE_PATH: Path = Path.home() / ".forgexa" / "update-check.json"
420
+ _UPDATE_CHECK_TTL: int = 24 * 3600 # seconds
421
+
422
+
423
+ def _read_update_cache() -> tuple[str | None, float]:
424
+ """Return (latest_version, checked_at_epoch). Never raises."""
425
+ try:
426
+ data = json.loads(_UPDATE_CACHE_PATH.read_text())
427
+ version = str(data.get("latest_version") or "").strip()
428
+ checked_at = float(data.get("checked_at") or 0)
429
+ return (version or None, checked_at)
430
+ except Exception:
431
+ return (None, 0.0)
432
+
433
+
434
+ def _write_update_cache(latest_version: str) -> None:
435
+ """Atomically write the update cache. Never raises."""
436
+ try:
437
+ _UPDATE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
438
+ tmp = _UPDATE_CACHE_PATH.with_suffix(".json.tmp")
439
+ tmp.write_text(
440
+ json.dumps({"latest_version": latest_version, "checked_at": time.time()})
441
+ )
442
+ tmp.replace(_UPDATE_CACHE_PATH)
443
+ except Exception:
444
+ pass
445
+
446
+
447
+ def _version_is_newer(candidate: str, reference: str) -> bool:
448
+ """Return True if candidate is strictly newer than reference (numeric tuple comparison)."""
449
+ def _parts(v: str) -> tuple[int, ...]:
450
+ return tuple(int(x) for x in v.split(".")[:4] if x.isdigit())
451
+ try:
452
+ return _parts(candidate) > _parts(reference)
453
+ except Exception:
454
+ return False
455
+
456
+
457
+ def _launch_update_check_thread() -> None:
458
+ """Start a fire-and-forget daemon thread that fetches the latest PyPI version.
459
+
460
+ The thread only runs if the cache is stale (> TTL). Because it is a daemon
461
+ thread the OS kills it automatically when the main process exits, so it never
462
+ delays shutdown.
463
+ """
464
+ _, checked_at = _read_update_cache()
465
+ if time.time() - checked_at <= _UPDATE_CHECK_TTL:
466
+ return # Cache is fresh — nothing to do
467
+
468
+ import threading
469
+
470
+ def _worker() -> None:
471
+ try:
472
+ import httpx
473
+ with httpx.Client(timeout=5) as client:
474
+ resp = client.get("https://pypi.org/pypi/forgexa-cli/json")
475
+ latest = str(resp.json()["info"]["version"])
476
+ _write_update_cache(latest)
477
+ except Exception:
478
+ pass # Network errors are silently ignored
479
+
480
+ t = threading.Thread(target=_worker, daemon=True, name="forgexa-update-check")
481
+ t.start()
482
+
483
+
484
+ def _print_update_notice_if_available() -> None:
485
+ """Print an update notice to stderr when a newer version is cached.
486
+
487
+ Silently skipped when:
488
+ - FORGEXA_NO_UPDATE_CHECK=1 is set
489
+ - stderr is not a TTY (pipes, CI, scripts)
490
+ - The CI env var is set
491
+ - Output format is json (machine-readable)
492
+ """
493
+ if os.environ.get("FORGEXA_NO_UPDATE_CHECK"):
494
+ return
495
+ if os.environ.get("CI"):
496
+ return
497
+ if not sys.stderr.isatty():
498
+ return
499
+ if _output_format == "json":
500
+ return
501
+
502
+ current = _installed_cli_version()
503
+ if not current:
504
+ return
505
+
506
+ latest, _ = _read_update_cache()
507
+ if not latest or not _version_is_newer(latest, current):
508
+ return
509
+
510
+ print(
511
+ f"\n Update available: forgexa {current} → {latest}\n"
512
+ f" Run: forgexa upgrade\n",
513
+ file=sys.stderr,
514
+ )
515
+
516
+
517
+ # ── Daemon PID-file helpers ───────────────────────────────────────────────────
518
+
415
519
  def _daemon_pid_files() -> list[Path]:
416
520
  return [
417
521
  Path.home() / ".forgexa-daemon.pid",
@@ -505,19 +609,16 @@ def _request_json(
505
609
  timeout: int = 15,
506
610
  allow_refresh: bool = True,
507
611
  ) -> dict | list | None:
508
- import urllib.request
509
- import urllib.error
612
+ import httpx
510
613
 
511
614
  url = f"{_api_url()}/api/v1{path}"
512
- body = json.dumps(data).encode() if data is not None else None
513
- req = urllib.request.Request(url, data=body, headers=_headers(), method=method)
514
615
  try:
515
- with urllib.request.urlopen(req, timeout=timeout) as resp:
516
- content = resp.read()
517
- return json.loads(content) if content else None
518
- except urllib.error.HTTPError as e:
519
- body_text = e.read().decode(errors="replace")
520
- if e.code == 401 and allow_refresh and _refresh_access_token():
616
+ with httpx.Client(headers=_headers(), timeout=timeout) as client:
617
+ resp = client.request(method, url, json=data)
618
+ resp.raise_for_status()
619
+ return resp.json() if resp.content else None
620
+ except httpx.HTTPStatusError as e:
621
+ if e.response.status_code == 401 and allow_refresh and _refresh_access_token():
521
622
  return _request_json(
522
623
  path,
523
624
  method=method,
@@ -525,10 +626,11 @@ def _request_json(
525
626
  timeout=timeout,
526
627
  allow_refresh=False,
527
628
  )
528
- print(f"Error {e.code}: {body_text}", file=sys.stderr)
629
+ body_text = e.response.text
630
+ print(f"Error {e.response.status_code}: {body_text}", file=sys.stderr)
529
631
  sys.exit(1)
530
- except urllib.error.URLError as e:
531
- print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
632
+ except httpx.RequestError as e:
633
+ print(f"Connection error: {e}\nServer: {_api_url()}", file=sys.stderr)
532
634
  sys.exit(1)
533
635
 
534
636
 
@@ -664,15 +766,14 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
664
766
 
665
767
  def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
666
768
  """Like _get_runtimes() but returns [] instead of sys.exit on any error."""
667
- import urllib.request
769
+ import httpx
668
770
 
669
771
  path = "/runtimes" if include_all else "/runtimes/me"
670
772
  url = f"{_api_url()}/api/v1{path}"
671
- req = urllib.request.Request(url, headers=_headers(), method="GET")
672
773
  try:
673
- with urllib.request.urlopen(req, timeout=10) as resp:
674
- content = resp.read()
675
- result = json.loads(content) if content else []
774
+ with httpx.Client(headers=_headers(), timeout=10) as client:
775
+ resp = client.get(url)
776
+ result = resp.json() if resp.content else []
676
777
  return result if isinstance(result, list) else []
677
778
  except Exception:
678
779
  return []
@@ -1535,6 +1636,16 @@ def main() -> None:
1535
1636
  parser.print_help()
1536
1637
  return
1537
1638
 
1639
+ # Start background update-check now (writes cache if stale; zero latency).
1640
+ # Skip for `upgrade` (user is already updating) and raw log tailing
1641
+ # (`daemon logs -f` / `logs -f`) which never returns.
1642
+ _skip_update_check = cmd == "upgrade" or (
1643
+ cmd in ("daemon", "logs")
1644
+ and getattr(args, "follow", False)
1645
+ )
1646
+ if not _skip_update_check:
1647
+ _launch_update_check_thread()
1648
+
1538
1649
  dispatch: dict = {
1539
1650
  "version": cmd_version,
1540
1651
  "upgrade": cmd_upgrade,
@@ -1599,6 +1710,10 @@ def main() -> None:
1599
1710
  else:
1600
1711
  parser.print_help()
1601
1712
 
1713
+ # Show update notice as a footer (reads local cache — no network latency).
1714
+ if not _skip_update_check:
1715
+ _print_update_notice_if_available()
1716
+
1602
1717
 
1603
1718
  if __name__ == "__main__":
1604
1719
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.14.6
3
+ Version: 1.14.7
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -26,8 +26,10 @@ Classifier: Topic :: Software Development :: Quality Assurance
26
26
  Requires-Python: >=3.9
27
27
  Description-Content-Type: text/markdown
28
28
  Requires-Dist: httpx>=0.24
29
+ Requires-Dist: socksio>=1.0.0
29
30
  Provides-Extra: daemon
30
31
  Requires-Dist: httpx>=0.24; extra == "daemon"
32
+ Requires-Dist: socksio>=1.0.0; extra == "daemon"
31
33
 
32
34
  # Forgexa CLI
33
35
 
@@ -1,4 +1,6 @@
1
1
  httpx>=0.24
2
+ socksio>=1.0.0
2
3
 
3
4
  [daemon]
4
5
  httpx>=0.24
6
+ socksio>=1.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.14.6"
3
+ version = "1.14.7"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
@@ -27,11 +27,13 @@ classifiers = [
27
27
  ]
28
28
  # httpx is required for the daemon (core feature). Declaring it here ensures pip/pipx
29
29
  # installs it automatically — no fragile runtime auto-install needed.
30
- dependencies = ["httpx>=0.24"]
30
+ # socksio is required for SOCKS proxy support (httpx picks up ALL_PROXY/SOCKS_PROXY env vars
31
+ # automatically, and raises ImportError if socksio is missing).
32
+ dependencies = ["httpx>=0.24", "socksio>=1.0.0"]
31
33
 
32
34
  [project.optional-dependencies]
33
35
  # Keep [daemon] extra for backward compatibility / explicit opt-in.
34
- daemon = ["httpx>=0.24"]
36
+ daemon = ["httpx>=0.24", "socksio>=1.0.0"]
35
37
 
36
38
  [project.urls]
37
39
  Homepage = "https://forgexa.net"
File without changes
File without changes