forgexa-cli 1.13.2__tar.gz → 1.13.3__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.13.2
3
+ Version: 1.13.3
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
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.13.2"
2
+ __version__ = "1.13.3"
@@ -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.13.2"
526
+ DAEMON_VERSION = "1.13.3"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -555,6 +555,8 @@ _CLIENT_TYPE = _detect_client_type()
555
555
  # (Makefile, Tauri desktop app, `forgexa daemon start --detach`, etc.).
556
556
  # A StreamHandler is added only when stderr is a TTY (foreground mode)
557
557
  # so logs are visible on the terminal without duplication.
558
+ # Set DAEMON_NO_CONSOLE_LOG=1 to suppress the stream handler even on a TTY
559
+ # (used by `forgexa daemon start` foreground mode for clean UX).
558
560
  _log_dir = Path.home() / ".forgexa" / "daemon"
559
561
  _log_dir.mkdir(parents=True, exist_ok=True)
560
562
  DAEMON_LOG_PATH = _log_dir / "daemon.log"
@@ -566,7 +568,7 @@ _log_handlers: list[logging.Handler] = [
566
568
  backupCount=5,
567
569
  ),
568
570
  ]
569
- if sys.stderr.isatty():
571
+ if sys.stderr.isatty() and not os.environ.get("DAEMON_NO_CONSOLE_LOG"):
570
572
  _log_handlers.append(logging.StreamHandler(sys.stderr))
571
573
 
572
574
  logging.basicConfig(
@@ -16,7 +16,14 @@ Usage:
16
16
  forgexa workspace list
17
17
  forgexa project list --workspace <id>
18
18
  forgexa requirement list --project <id>
19
+ forgexa daemon start -d
19
20
  forgexa daemon status
21
+ forgexa daemon status --verbose
22
+ forgexa daemon logs -f
23
+ forgexa daemon logs -n 100 -f
24
+ forgexa daemon agents
25
+ forgexa daemon restart
26
+ forgexa daemon stop
20
27
  forgexa gates pending
21
28
  forgexa config show
22
29
  forgexa --help
@@ -645,26 +652,185 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
645
652
  return runtimes if isinstance(runtimes, list) else []
646
653
 
647
654
 
655
+ def _get_runtimes_silent(include_all: bool = False) -> list[dict]:
656
+ """Like _get_runtimes() but returns [] instead of sys.exit on any error."""
657
+ import urllib.request
658
+
659
+ path = "/runtimes" if include_all else "/runtimes/me"
660
+ url = f"{_api_url()}/api/v1{path}"
661
+ req = urllib.request.Request(url, headers=_headers(), method="GET")
662
+ try:
663
+ with urllib.request.urlopen(req, timeout=10) as resp:
664
+ content = resp.read()
665
+ result = json.loads(content) if content else []
666
+ return result if isinstance(result, list) else []
667
+ except Exception:
668
+ return []
669
+
670
+
671
+ def _scan_local_agents() -> list[dict]:
672
+ """Run a quick local agent discovery scan and return results as dicts.
673
+
674
+ Uses asyncio.run() so it works from synchronous CLI context.
675
+ Silently returns [] if anything goes wrong.
676
+ """
677
+ try:
678
+ import asyncio as _asyncio
679
+ from forgexa_cli.daemon import AgentDiscovery
680
+ discovered = _asyncio.run(AgentDiscovery().discover())
681
+ return [
682
+ {
683
+ "agent_id": a.agent_id,
684
+ "version": a.version,
685
+ "compatibility_level": a.compatibility_level,
686
+ }
687
+ for a in discovered
688
+ ]
689
+ except Exception:
690
+ return []
691
+
692
+
693
+ def _show_discovered_agents(agents: list[dict], indent: str = " ") -> None:
694
+ """Print a friendly list of discovered agents."""
695
+ if not agents:
696
+ print(f"{indent}No agent CLIs found.")
697
+ print(f"{indent}Install Claude Code, OpenAI Codex, Gemini CLI, Kimi Code, etc.")
698
+ return
699
+ print(f"{indent}{len(agents)} agent(s) discovered:")
700
+ for a in agents:
701
+ agent_id = a.get("agent_id", "?") if isinstance(a, dict) else str(a)
702
+ version = a.get("version", "") if isinstance(a, dict) else ""
703
+ compat = a.get("compatibility_level", "") if isinstance(a, dict) else ""
704
+ badge = f" [{compat}]" if compat else ""
705
+ ver_str = f" {version}" if version else ""
706
+ print(f"{indent} ✓ {agent_id}{ver_str}{badge}")
707
+
708
+
709
+ def _wait_and_show_daemon_agents(start_ts: float, timeout: float = 12.0) -> bool:
710
+ """Poll server until daemon registers (heartbeat >= start_ts), then show agents.
711
+
712
+ Returns True if registration was confirmed, False on timeout.
713
+ Silently skips if user is not authenticated.
714
+ """
715
+ if not _token():
716
+ return False
717
+
718
+ from datetime import datetime, timezone as _tz
719
+
720
+ deadline = time.time() + timeout
721
+ spin = ["-", "\\", "|", "/"]
722
+ i = 0
723
+
724
+ sys.stdout.write(" Registering with server...")
725
+ sys.stdout.flush()
726
+
727
+ while time.time() < deadline:
728
+ runtimes = _get_runtimes_silent()
729
+ for r in runtimes:
730
+ hb = r.get("last_heartbeat_at") or ""
731
+ if not hb:
732
+ continue
733
+ try:
734
+ dt = datetime.fromisoformat(hb.replace("Z", "+00:00"))
735
+ hb_ts = dt.timestamp()
736
+ except Exception:
737
+ continue
738
+ # Allow 5-second clock-skew window before the recorded start timestamp
739
+ if hb_ts >= (start_ts - 5):
740
+ sys.stdout.write("\r" + " " * 60 + "\r")
741
+ sys.stdout.flush()
742
+ agents = r.get("available_agents", [])
743
+ status = r.get("status", "")
744
+ runtime_short = r["id"][:8]
745
+ print(f" Runtime ready (id: {runtime_short} status: {status})")
746
+ _show_discovered_agents(agents)
747
+ return True
748
+ spin_char = spin[i % 4]
749
+ sys.stdout.write(f"\r {spin_char} Registering with server...")
750
+ sys.stdout.flush()
751
+ i += 1
752
+ time.sleep(0.4)
753
+
754
+ sys.stdout.write("\r" + " " * 60 + "\r")
755
+ sys.stdout.flush()
756
+ return False
757
+
758
+
648
759
  def cmd_daemon_status(args: argparse.Namespace) -> None:
760
+ # ── Local process status ─────────────────────────────────────────────────
761
+ local_pid = _find_running_daemon_pid()
762
+ log_path = Path.home() / ".forgexa" / "daemon" / "daemon.log"
763
+ if local_pid:
764
+ print(f"Local daemon : running (PID {local_pid})")
765
+ else:
766
+ print("Local daemon : not running")
767
+ print(f"Log file : {log_path}")
768
+ print()
769
+
770
+ # ── Server-registered runtimes ───────────────────────────────────────────
649
771
  runtimes = _get_runtimes(include_all=getattr(args, "all", False))
650
772
  if not runtimes:
651
- print("No daemons registered.")
773
+ print("No runtimes registered on server.")
774
+ if local_pid:
775
+ print("(Daemon is running locally but has not registered yet — wait a moment)")
652
776
  return
653
- _print_table(
654
- ["ID", "Daemon", "Device", "Status", "Active", "Max", "Heartbeat"],
655
- [
656
- [
657
- r["id"][:8],
658
- r["daemon_id"],
659
- r.get("device_name", ""),
660
- r["status"],
661
- str(r.get("active_tasks", 0)),
662
- str(r.get("max_concurrent_tasks", 0)),
663
- r.get("last_heartbeat_at", "")[:19] if r.get("last_heartbeat_at") else "never",
777
+
778
+ verbose = getattr(args, "verbose", False)
779
+
780
+ if verbose:
781
+ # Expanded per-runtime block
782
+ for r in runtimes:
783
+ agents = r.get("available_agents", []) or []
784
+ hb = r.get("last_heartbeat_at", "")[:19] if r.get("last_heartbeat_at") else "never"
785
+ print(f"Runtime : {r['id'][:8]} ({r.get('status', '?')})")
786
+ print(f" Daemon : {r.get('daemon_id', '')}")
787
+ print(f" Device : {r.get('device_name', '')}")
788
+ print(f" Active : {r.get('active_tasks', 0)}/{r.get('max_concurrent_tasks', 0)} tasks")
789
+ print(f" Heartbeat : {hb}")
790
+ if agents:
791
+ print(f" Agents :")
792
+ for a in agents:
793
+ if isinstance(a, dict):
794
+ aid = a.get("agent_id", "?")
795
+ ver = a.get("version", "")
796
+ compat = a.get("compatibility_level", "")
797
+ else:
798
+ aid, ver, compat = str(a), "", ""
799
+ badge = f" [{compat}]" if compat else ""
800
+ ver_str = f" {ver}" if ver else ""
801
+ print(f" ✓ {aid}{ver_str}{badge}")
802
+ else:
803
+ print(f" Agents : (none found)")
804
+ print()
805
+ else:
806
+ # Compact table with Agents column
807
+ def _fmt_agents(r: dict) -> str:
808
+ agents = r.get("available_agents", []) or []
809
+ if not agents:
810
+ return "(none)"
811
+ names = [
812
+ a.get("agent_id", "?") if isinstance(a, dict) else str(a)
813
+ for a in agents
664
814
  ]
665
- for r in runtimes
666
- ],
667
- )
815
+ return ", ".join(names)
816
+
817
+ _print_table(
818
+ ["ID", "Daemon", "Device", "Status", "Agents", "Active/Max", "Heartbeat"],
819
+ [
820
+ [
821
+ r["id"][:8],
822
+ r.get("daemon_id", ""),
823
+ r.get("device_name", ""),
824
+ r["status"],
825
+ _fmt_agents(r),
826
+ f"{r.get('active_tasks', 0)}/{r.get('max_concurrent_tasks', 0)}",
827
+ r.get("last_heartbeat_at", "")[:19] if r.get("last_heartbeat_at") else "never",
828
+ ]
829
+ for r in runtimes
830
+ ],
831
+ )
832
+ print()
833
+ print("Tip: use --verbose for per-agent detail")
668
834
 
669
835
 
670
836
  def cmd_daemon_stop(_args: argparse.Namespace) -> None:
@@ -688,49 +854,189 @@ def cmd_daemon_stop(_args: argparse.Namespace) -> None:
688
854
  print(f"Sent SIGTERM to daemon (PID {pid})")
689
855
 
690
856
 
857
+ def cmd_daemon_restart(args: argparse.Namespace) -> None:
858
+ """Stop any running daemon, then start a fresh one in background."""
859
+ pid = _find_running_daemon_pid()
860
+ if pid is not None:
861
+ print(f"Stopping daemon (PID {pid})...")
862
+ try:
863
+ _stop_daemon_by_pid(pid)
864
+ except (PermissionError, RuntimeError) as exc:
865
+ print(f"Error stopping daemon: {exc}", file=sys.stderr)
866
+ sys.exit(1)
867
+ print("Daemon stopped.")
868
+ print()
869
+ else:
870
+ print("No daemon currently running — starting fresh.")
871
+ print()
872
+
873
+ # restart always runs in background so the shell is returned immediately
874
+ start_args = argparse.Namespace(
875
+ detach=True,
876
+ server_url=getattr(args, "server_url", None),
877
+ )
878
+ cmd_daemon_start(start_args)
879
+
880
+
691
881
  def cmd_daemon_start(args: argparse.Namespace) -> None:
692
882
  """Start the local daemon (agent runtime)."""
693
- server_url = args.server_url or _api_url()
883
+ # Refuse to start a second instance
884
+ existing_pid = _find_running_daemon_pid()
885
+ if existing_pid is not None:
886
+ print(f"Daemon is already running (PID {existing_pid}).")
887
+ print("Use 'forgexa daemon restart' to restart it.")
888
+ sys.exit(1)
889
+
890
+ server_url = getattr(args, "server_url", None) or _api_url()
694
891
  os.environ["DAEMON_SERVER_URL"] = server_url
695
892
 
696
- # Pass token if available
697
893
  token = _token()
698
894
  if token:
699
895
  os.environ.setdefault("DAEMON_API_TOKEN", token)
700
896
 
701
897
  if getattr(args, "detach", False):
702
- # Background mode: spawn forgexa-daemon as detached subprocess.
703
- # Redirect stderr to the canonical log file so logs are not lost.
898
+ # ── Background (detach) mode ─────────────────────────────────────────
704
899
  import subprocess as sp
705
900
 
706
901
  log_path = Path.home() / ".forgexa" / "daemon" / "daemon.log"
707
902
  log_path.parent.mkdir(parents=True, exist_ok=True)
708
903
 
904
+ start_ts = time.time()
709
905
  cmd = [sys.executable, "-m", "forgexa_cli.daemon"]
710
906
  with open(log_path, "a", encoding="utf-8") as log_fh:
711
907
  popen_kwargs: dict = dict(stdout=sp.DEVNULL, stderr=log_fh)
712
908
  if sys.platform == "win32":
713
- # Windows: use creation flags to detach the process
714
909
  popen_kwargs["creationflags"] = (
715
910
  sp.CREATE_NEW_PROCESS_GROUP | sp.DETACHED_PROCESS
716
911
  )
717
912
  else:
718
913
  popen_kwargs["start_new_session"] = True
719
914
  proc = sp.Popen(cmd, **popen_kwargs)
915
+
720
916
  pid_file = Path.home() / ".forgexa-daemon.pid"
721
917
  pid_file.write_text(str(proc.pid))
722
- print(f"Daemon started in background (PID {proc.pid})")
723
- print(f"Server: {server_url}")
724
- print(f"Logs: {log_path}")
725
- print(f"Stop with: forgexa daemon stop")
918
+
919
+ print(f"Daemon starting in background")
920
+ print(f" PID : {proc.pid}")
921
+ print(f" Server : {server_url}")
922
+ print(f" Logs : {log_path}")
923
+ print()
924
+
925
+ detected = _wait_and_show_daemon_agents(start_ts)
926
+ if not detected:
927
+ if _token():
928
+ print(" (Agent registration not confirmed yet — check with: forgexa daemon status)")
929
+ else:
930
+ print(" (Not logged in — run 'forgexa login' to verify agent registration)")
931
+ print()
932
+ print(f" Stop : forgexa daemon stop")
933
+ print(f" Logs : forgexa daemon logs -f")
934
+
726
935
  else:
727
- # Foreground mode: run daemon directly
728
- print(f"Starting daemon (server: {server_url})...")
729
- print("Press Ctrl+C to stop.\n")
936
+ # ── Foreground mode ──────────────────────────────────────────────────
937
+ # Scan agents locally for immediate friendly display, then run the daemon
938
+ # with console log suppressed so raw log lines don't flood the terminal.
939
+ print(f"Starting daemon (server: {server_url})")
940
+ print()
941
+ print(" Scanning for AI agents...")
942
+ agents = _scan_local_agents()
943
+ _show_discovered_agents(agents)
944
+ print()
945
+ print(" Press Ctrl+C to stop. Logs → forgexa daemon logs -f")
946
+ print()
947
+
948
+ # Suppress raw console log output; logs still written to file
949
+ os.environ["DAEMON_NO_CONSOLE_LOG"] = "1"
730
950
  from forgexa_cli.daemon import main_sync
731
951
  main_sync()
732
952
 
733
953
 
954
+ def cmd_daemon_logs(args: argparse.Namespace) -> None:
955
+ """Show daemon log output; optionally stream new lines like tail -f."""
956
+ log_path = Path.home() / ".forgexa" / "daemon" / "daemon.log"
957
+
958
+ if not log_path.exists():
959
+ print(f"No daemon log found.")
960
+ print(f" Expected : {log_path}")
961
+ print()
962
+ print("Start the daemon first: forgexa daemon start -d")
963
+ return
964
+
965
+ lines = getattr(args, "lines", 50)
966
+ follow = getattr(args, "follow", False)
967
+
968
+ # Print last N lines
969
+ try:
970
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
971
+ all_lines = f.readlines()
972
+ tail = all_lines[-lines:] if len(all_lines) > lines else all_lines
973
+ for line in tail:
974
+ print(line, end="")
975
+ except OSError as exc:
976
+ print(f"Cannot read log file: {exc}", file=sys.stderr)
977
+ sys.exit(1)
978
+
979
+ if not follow:
980
+ return
981
+
982
+ # Follow / tail-f mode
983
+ print(f"\n--- streaming {log_path} (Ctrl+C to stop) ---\n", flush=True)
984
+ try:
985
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
986
+ f.seek(0, 2) # jump to end
987
+ while True:
988
+ chunk = f.read(65536)
989
+ if chunk:
990
+ print(chunk, end="", flush=True)
991
+ else:
992
+ time.sleep(0.15)
993
+ except KeyboardInterrupt:
994
+ print() # ensure newline after Ctrl+C
995
+
996
+
997
+ def cmd_daemon_agents(args: argparse.Namespace) -> None:
998
+ """List all AI agents across every registered daemon runtime."""
999
+ runtimes = _get_runtimes(include_all=getattr(args, "all", False))
1000
+ if not runtimes:
1001
+ print("No daemons registered.")
1002
+ return
1003
+
1004
+ rows: list[list[str]] = []
1005
+ for r in runtimes:
1006
+ runtime_id = r["id"][:8]
1007
+ daemon_id = r.get("daemon_id", "")
1008
+ device = r.get("device_name", "")
1009
+ status = r.get("status", "")
1010
+ agents = r.get("available_agents", []) or []
1011
+
1012
+ if not agents:
1013
+ rows.append([runtime_id, daemon_id, device, status, "(none)", "", ""])
1014
+ else:
1015
+ first = True
1016
+ for a in agents:
1017
+ if isinstance(a, dict):
1018
+ agent_id = a.get("agent_id", "?")
1019
+ version = a.get("version", "")
1020
+ compat = a.get("compatibility_level", "")
1021
+ else:
1022
+ agent_id, version, compat = str(a), "", ""
1023
+ rows.append([
1024
+ runtime_id if first else "",
1025
+ daemon_id if first else "",
1026
+ device if first else "",
1027
+ status if first else "",
1028
+ agent_id,
1029
+ version,
1030
+ compat,
1031
+ ])
1032
+ first = False
1033
+
1034
+ _print_table(
1035
+ ["Runtime", "Daemon", "Device", "Status", "Agent", "Version", "Compat"],
1036
+ rows,
1037
+ )
1038
+
1039
+
734
1040
  def cmd_runtimes_list(args: argparse.Namespace) -> None:
735
1041
  runtimes = _get_runtimes(include_all=getattr(args, "all", False))
736
1042
  if not runtimes:
@@ -1033,12 +1339,53 @@ def main() -> None:
1033
1339
  # daemon (remote API only — use forgexa-daemon for local daemon management)
1034
1340
  daemon_p = sub.add_parser("daemon", help="Daemon management")
1035
1341
  daemon_sub = daemon_p.add_subparsers(dest="daemon_cmd")
1036
- daemon_start_p = daemon_sub.add_parser("start", help="Start local daemon (discovers and registers AI agents)")
1037
- daemon_start_p.add_argument("-d", "--detach", action="store_true", help="Run in background")
1342
+
1343
+ daemon_start_p = daemon_sub.add_parser(
1344
+ "start",
1345
+ help="Start local daemon (discovers and registers AI agents)",
1346
+ )
1347
+ daemon_start_p.add_argument(
1348
+ "-d", "--detach",
1349
+ action="store_true",
1350
+ help="Run in background (recommended for production use)",
1351
+ )
1038
1352
  daemon_start_p.add_argument("--server-url", default=None, help="Server URL to connect to")
1039
- daemon_status_p = daemon_sub.add_parser("status", help="Show your daemon statuses (from server)")
1353
+
1354
+ daemon_stop_p = daemon_sub.add_parser("stop", help="Stop local daemon (sends SIGTERM)")
1355
+
1356
+ daemon_restart_p = daemon_sub.add_parser(
1357
+ "restart",
1358
+ help="Restart local daemon (stop + start in background)",
1359
+ )
1360
+ daemon_restart_p.add_argument("--server-url", default=None, help="Server URL to connect to")
1361
+
1362
+ daemon_status_p = daemon_sub.add_parser(
1363
+ "status",
1364
+ help="Show local daemon status and registered agents",
1365
+ )
1040
1366
  daemon_status_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
1041
- daemon_sub.add_parser("stop", help="Stop local daemon (sends SIGTERM)")
1367
+ daemon_status_p.add_argument("-v", "--verbose", action="store_true", help="Expanded per-agent detail")
1368
+
1369
+ daemon_logs_p = daemon_sub.add_parser(
1370
+ "logs",
1371
+ help="Show daemon log output (optionally stream like tail -f)",
1372
+ )
1373
+ daemon_logs_p.add_argument(
1374
+ "-n", "--lines",
1375
+ type=int, default=50, metavar="N",
1376
+ help="Show last N lines before following (default: 50)",
1377
+ )
1378
+ daemon_logs_p.add_argument(
1379
+ "-f", "--follow",
1380
+ action="store_true",
1381
+ help="Stream new log lines as they arrive (like tail -f)",
1382
+ )
1383
+
1384
+ daemon_agents_p = daemon_sub.add_parser(
1385
+ "agents",
1386
+ help="List all AI agents across registered daemons",
1387
+ )
1388
+ daemon_agents_p.add_argument("--all", action="store_true", help="Include all users' runtimes (admin only)")
1042
1389
 
1043
1390
  # runtimes
1044
1391
  rt_p = sub.add_parser("runtimes", help="Runtime management")
@@ -1135,9 +1482,12 @@ def main() -> None:
1135
1482
  "set": cmd_config_set,
1136
1483
  }.get(a.config_cmd, lambda _: config_p.print_help())(a),
1137
1484
  "daemon": lambda a: {
1138
- "start": cmd_daemon_start,
1139
- "status": cmd_daemon_status,
1140
- "stop": cmd_daemon_stop,
1485
+ "start": cmd_daemon_start,
1486
+ "stop": cmd_daemon_stop,
1487
+ "restart": cmd_daemon_restart,
1488
+ "status": cmd_daemon_status,
1489
+ "logs": cmd_daemon_logs,
1490
+ "agents": cmd_daemon_agents,
1141
1491
  }.get(a.daemon_cmd, lambda _: daemon_p.print_help())(a),
1142
1492
  "runtimes": lambda a: {
1143
1493
  "list": cmd_runtimes_list,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.13.2
3
+ Version: 1.13.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.13.2"
3
+ version = "1.13.3"
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" }
File without changes
File without changes