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.
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/PKG-INFO +1 -1
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli/daemon.py +36 -2
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli/main.py +415 -34
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/pyproject.toml +1 -1
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/tests/test_auth_and_runtime_commands.py +36 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/README.md +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.13.1 → forgexa_cli-1.13.3}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.13.
|
|
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.
|
|
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
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
692
|
-
print(f"
|
|
693
|
-
print(f"
|
|
694
|
-
print(f"
|
|
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
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
1006
|
-
daemon_start_p
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
1108
|
-
"
|
|
1109
|
-
"
|
|
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,
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|