forgexa-cli 1.13.1__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.1
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.1"
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.1"
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(
@@ -2792,8 +2794,11 @@ class ProcessManager:
2792
2794
  msg_data = data.get("data") or {}
2793
2795
  if isinstance(msg_data.get("content"), str) and msg_data["content"].strip():
2794
2796
  has_result = True
2797
+ elif ev_type == "assistant.message_start":
2798
+ has_assistant_events = True
2795
2799
  elif ev_type == "assistant.message_delta":
2796
2800
  has_meaningful_content = True
2801
+ has_assistant_events = True
2797
2802
  # ── Generic / Claude "result" event ────────────────────────────
2798
2803
  elif ev_type == "result":
2799
2804
  # Copilot result format: {"type":"result","exitCode":0,"usage":{...}}
@@ -2916,6 +2921,19 @@ class ProcessManager:
2916
2921
  or signals["has_meaningful_content"]
2917
2922
  )
2918
2923
 
2924
+ @staticmethod
2925
+ def _copilot_completed_without_result(stdout: str) -> bool:
2926
+ """Detect Copilot runs that completed a turn but omitted the final result event."""
2927
+ signals = ProcessManager._extract_output_signals(stdout)
2928
+ return bool(
2929
+ signals["has_turn_completed"]
2930
+ and signals["has_assistant_events"]
2931
+ and signals["has_meaningful_content"]
2932
+ and not signals["has_result"]
2933
+ and not signals["has_turn_failed"]
2934
+ and not signals["error_messages"]
2935
+ )
2936
+
2919
2937
  @staticmethod
2920
2938
  def is_rate_limited(result: "TaskResult") -> bool:
2921
2939
  """Check if an agent failure warrants trying a different agent.
@@ -3930,6 +3948,7 @@ class ProcessManager:
3930
3948
  # Copilot always exits 0 on normal completion; check result.exitCode
3931
3949
  # from the JSONL "result" event for a true success signal.
3932
3950
  copilot_exit = self._extract_copilot_exit_code(stdout)
3951
+ completed_without_result = self._copilot_completed_without_result(stdout)
3933
3952
  effective_rc = copilot_exit if copilot_exit is not None else returncode
3934
3953
 
3935
3954
  if effective_rc == 0 and returncode == 0:
@@ -3940,6 +3959,21 @@ class ProcessManager:
3940
3959
  stderr=stderr[-10000:],
3941
3960
  metrics=metrics,
3942
3961
  )
3962
+ elif copilot_exit is None and returncode != 0 and completed_without_result:
3963
+ logger.warning(
3964
+ "Copilot exited with return code %s for task %s despite a completed assistant turn and no result event; recovering to validation path",
3965
+ returncode,
3966
+ task_id,
3967
+ )
3968
+ metrics["recovered_missing_result_event"] = True
3969
+ metrics["recovered_process_returncode"] = returncode
3970
+ return TaskResult(
3971
+ status="success",
3972
+ exit_code=0,
3973
+ stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
3974
+ stderr=stderr[-10000:],
3975
+ metrics=metrics,
3976
+ )
3943
3977
  else:
3944
3978
  return TaskResult(
3945
3979
  status="failed",
@@ -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
@@ -247,6 +254,31 @@ def _is_virtualenv() -> bool:
247
254
  )
248
255
 
249
256
 
257
+ def _is_pep668_environment() -> bool:
258
+ """Return True if the active Python is governed by PEP 668 (externally-managed).
259
+
260
+ Debian 12, Ubuntu 22.10+/24.04+, Fedora 38+ and other modern distros place
261
+ an EXTERNALLY-MANAGED marker in their stdlib directory. pip refuses both
262
+ user-site and system-wide installs on these environments unless
263
+ --break-system-packages is explicitly passed — even ``pip install --user``
264
+ is blocked. We detect the marker up-front so ``forgexa upgrade`` can add
265
+ the flag automatically rather than failing and asking the user to do it.
266
+ """
267
+ try:
268
+ import sysconfig as _sysconfig
269
+ stdlib = _sysconfig.get_path("stdlib")
270
+ if stdlib and (Path(stdlib) / "EXTERNALLY-MANAGED").exists():
271
+ return True
272
+ except Exception:
273
+ pass
274
+ try:
275
+ if (Path(sys.prefix) / "EXTERNALLY-MANAGED").exists():
276
+ return True
277
+ except Exception:
278
+ pass
279
+ return False
280
+
281
+
250
282
  def _is_user_site_install(dist_root: Path | None) -> bool:
251
283
  if dist_root is None:
252
284
  return False
@@ -355,6 +387,12 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
355
387
  command = [python, "-m", "pip", "install", "--upgrade"]
356
388
  if _is_user_site_install(dist_root) and not _is_virtualenv():
357
389
  command.append("--user")
390
+ if _is_pep668_environment():
391
+ # PEP 668 (Ubuntu 24.04+, Debian 12+, Fedora 38+, …) blocks pip
392
+ # installs — including --user — unless this flag is passed. The
393
+ # original installation on this machine must have used it too, so
394
+ # adding it here is consistent and safe.
395
+ command.append("--break-system-packages")
358
396
  command.append(_package_spec_for_version(target_version))
359
397
  return UpgradePlan(
360
398
  installer="pip",
@@ -614,26 +652,185 @@ def _get_runtimes(include_all: bool = False) -> list[dict]:
614
652
  return runtimes if isinstance(runtimes, list) else []
615
653
 
616
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
+
617
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 ───────────────────────────────────────────
618
771
  runtimes = _get_runtimes(include_all=getattr(args, "all", False))
619
772
  if not runtimes:
620
- 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)")
621
776
  return
622
- _print_table(
623
- ["ID", "Daemon", "Device", "Status", "Active", "Max", "Heartbeat"],
624
- [
625
- [
626
- r["id"][:8],
627
- r["daemon_id"],
628
- r.get("device_name", ""),
629
- r["status"],
630
- str(r.get("active_tasks", 0)),
631
- str(r.get("max_concurrent_tasks", 0)),
632
- 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
633
814
  ]
634
- for r in runtimes
635
- ],
636
- )
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")
637
834
 
638
835
 
639
836
  def cmd_daemon_stop(_args: argparse.Namespace) -> None:
@@ -657,49 +854,189 @@ def cmd_daemon_stop(_args: argparse.Namespace) -> None:
657
854
  print(f"Sent SIGTERM to daemon (PID {pid})")
658
855
 
659
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
+
660
881
  def cmd_daemon_start(args: argparse.Namespace) -> None:
661
882
  """Start the local daemon (agent runtime)."""
662
- 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()
663
891
  os.environ["DAEMON_SERVER_URL"] = server_url
664
892
 
665
- # Pass token if available
666
893
  token = _token()
667
894
  if token:
668
895
  os.environ.setdefault("DAEMON_API_TOKEN", token)
669
896
 
670
897
  if getattr(args, "detach", False):
671
- # Background mode: spawn forgexa-daemon as detached subprocess.
672
- # Redirect stderr to the canonical log file so logs are not lost.
898
+ # ── Background (detach) mode ─────────────────────────────────────────
673
899
  import subprocess as sp
674
900
 
675
901
  log_path = Path.home() / ".forgexa" / "daemon" / "daemon.log"
676
902
  log_path.parent.mkdir(parents=True, exist_ok=True)
677
903
 
904
+ start_ts = time.time()
678
905
  cmd = [sys.executable, "-m", "forgexa_cli.daemon"]
679
906
  with open(log_path, "a", encoding="utf-8") as log_fh:
680
907
  popen_kwargs: dict = dict(stdout=sp.DEVNULL, stderr=log_fh)
681
908
  if sys.platform == "win32":
682
- # Windows: use creation flags to detach the process
683
909
  popen_kwargs["creationflags"] = (
684
910
  sp.CREATE_NEW_PROCESS_GROUP | sp.DETACHED_PROCESS
685
911
  )
686
912
  else:
687
913
  popen_kwargs["start_new_session"] = True
688
914
  proc = sp.Popen(cmd, **popen_kwargs)
915
+
689
916
  pid_file = Path.home() / ".forgexa-daemon.pid"
690
917
  pid_file.write_text(str(proc.pid))
691
- print(f"Daemon started in background (PID {proc.pid})")
692
- print(f"Server: {server_url}")
693
- print(f"Logs: {log_path}")
694
- 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
+
695
935
  else:
696
- # Foreground mode: run daemon directly
697
- print(f"Starting daemon (server: {server_url})...")
698
- 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"
699
950
  from forgexa_cli.daemon import main_sync
700
951
  main_sync()
701
952
 
702
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
+
703
1040
  def cmd_runtimes_list(args: argparse.Namespace) -> None:
704
1041
  runtimes = _get_runtimes(include_all=getattr(args, "all", False))
705
1042
  if not runtimes:
@@ -1002,12 +1339,53 @@ def main() -> None:
1002
1339
  # daemon (remote API only — use forgexa-daemon for local daemon management)
1003
1340
  daemon_p = sub.add_parser("daemon", help="Daemon management")
1004
1341
  daemon_sub = daemon_p.add_subparsers(dest="daemon_cmd")
1005
- daemon_start_p = daemon_sub.add_parser("start", help="Start local daemon (discovers and registers AI agents)")
1006
- 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
+ )
1007
1352
  daemon_start_p.add_argument("--server-url", default=None, help="Server URL to connect to")
1008
- 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
+ )
1009
1366
  daemon_status_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
1010
- 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)")
1011
1389
 
1012
1390
  # runtimes
1013
1391
  rt_p = sub.add_parser("runtimes", help="Runtime management")
@@ -1104,9 +1482,12 @@ def main() -> None:
1104
1482
  "set": cmd_config_set,
1105
1483
  }.get(a.config_cmd, lambda _: config_p.print_help())(a),
1106
1484
  "daemon": lambda a: {
1107
- "start": cmd_daemon_start,
1108
- "status": cmd_daemon_status,
1109
- "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,
1110
1491
  }.get(a.daemon_cmd, lambda _: daemon_p.print_help())(a),
1111
1492
  "runtimes": lambda a: {
1112
1493
  "list": cmd_runtimes_list,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.13.1
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.1"
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" }
@@ -244,6 +244,7 @@ def test_build_upgrade_plan_for_pip_user_site(monkeypatch: pytest.MonkeyPatch) -
244
244
  monkeypatch.setattr(main.importlib.util, "find_spec", lambda name: object() if name == "pip" else None)
245
245
  monkeypatch.setattr(main, "_is_user_site_install", lambda root: True)
246
246
  monkeypatch.setattr(main, "_is_virtualenv", lambda: False)
247
+ monkeypatch.setattr(main, "_is_pep668_environment", lambda: False)
247
248
  monkeypatch.setattr(main.sys, "executable", "/usr/bin/python3")
248
249
 
249
250
  plan = main._build_upgrade_plan("1.12.3")
@@ -260,6 +261,41 @@ def test_build_upgrade_plan_for_pip_user_site(monkeypatch: pytest.MonkeyPatch) -
260
261
  ]
261
262
 
262
263
 
264
+ def test_build_upgrade_plan_adds_break_system_packages_on_pep668(monkeypatch: pytest.MonkeyPatch) -> None:
265
+ monkeypatch.setattr(main, "_cli_distribution", lambda: SimpleNamespace(version="1.13.1"))
266
+ monkeypatch.setattr(main, "_distribution_root", lambda dist: Path("/usr/lib/python3/dist-packages"))
267
+ monkeypatch.setattr(main, "_running_from_distribution_root", lambda root: True)
268
+ monkeypatch.setattr(main, "_direct_url_metadata", lambda dist: None)
269
+ monkeypatch.setattr(main, "_looks_like_pipx_environment", lambda python=None: False)
270
+ monkeypatch.setattr(main.importlib.util, "find_spec", lambda name: object() if name == "pip" else None)
271
+ monkeypatch.setattr(main, "_is_user_site_install", lambda root: True)
272
+ monkeypatch.setattr(main, "_is_virtualenv", lambda: False)
273
+ monkeypatch.setattr(main, "_is_pep668_environment", lambda: True)
274
+ monkeypatch.setattr(main.sys, "executable", "/usr/bin/python3")
275
+
276
+ plan = main._build_upgrade_plan(None)
277
+
278
+ assert "--break-system-packages" in plan.command
279
+ assert "--user" in plan.command
280
+ assert plan.command[-1] == "forgexa-cli"
281
+
282
+
283
+ def test_pep668_detection_returns_false_in_normal_venv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
284
+ monkeypatch.setattr(main.sys, "prefix", str(tmp_path))
285
+ import sysconfig as _sc
286
+ monkeypatch.setattr(_sc, "get_path", lambda name, **kw: str(tmp_path))
287
+
288
+ assert main._is_pep668_environment() is False
289
+
290
+
291
+ def test_pep668_detection_returns_true_when_marker_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
292
+ (tmp_path / "EXTERNALLY-MANAGED").write_text("[externally-managed]\n")
293
+ import sysconfig as _sc
294
+ monkeypatch.setattr(_sc, "get_path", lambda name, **kw: str(tmp_path))
295
+
296
+ assert main._is_pep668_environment() is True
297
+
298
+
263
299
  def test_build_upgrade_plan_rejects_editable_install(monkeypatch: pytest.MonkeyPatch) -> None:
264
300
  monkeypatch.setattr(main, "_cli_distribution", lambda: SimpleNamespace(version="1.12.2"))
265
301
  monkeypatch.setattr(main, "_distribution_root", lambda dist: Path("/installed/site-packages"))
File without changes
File without changes