forgexa-cli 1.20.2__tar.gz → 1.20.4__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.20.2
3
+ Version: 1.20.4
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-Expression: MIT
@@ -108,6 +108,9 @@ forgexa board --project <project-id>
108
108
  | `forgexa daemon start -d` | Start daemon in background |
109
109
  | `forgexa daemon status` | Show your daemon statuses |
110
110
  | `forgexa daemon stop` | Stop local daemon |
111
+ | `forgexa check` | Verify locally-installed agent CLIs are genuinely usable (runs a real task per agent) |
112
+ | `forgexa check --quick` | Same, but discovery only — no live task, no API calls |
113
+ | `forgexa check --agent <name>` | Check a single agent (e.g. `claude`, `codex`, `kimi`) |
111
114
  | `forgexa runtimes list` | List your runtimes |
112
115
  | `forgexa version` | Show CLI version |
113
116
  | `forgexa upgrade` | Upgrade to the latest CLI release |
@@ -188,6 +191,40 @@ forgexa runtimes list
188
191
  forgexa runtimes list --all
189
192
  ```
190
193
 
194
+ ### Agent Health Check
195
+
196
+ Before starting the daemon (or when tasks keep failing on this machine), verify that every
197
+ installed agent CLI is genuinely usable — not just present on `PATH`. `forgexa check` reuses the
198
+ exact discovery and execution code the daemon uses in production, so a `PASS` means the agent
199
+ really works (auth, quota, and sandbox problems only ever surface on a real invocation).
200
+
201
+ ```bash
202
+ # Discover installed agents, then run one real, minimal task through each to confirm it works
203
+ forgexa check
204
+
205
+ # Fast pass: discovery only, no live task, no API calls
206
+ forgexa check --quick
207
+
208
+ # Check a single agent
209
+ forgexa check --agent claude
210
+ ```
211
+
212
+ Example output:
213
+
214
+ ```
215
+ Agent Availability Report
216
+ ----------------------------------------------------------------------
217
+ ✓ claude v2.1.14 (Claude Code)
218
+ ✗ codex not installed
219
+ 'codex' not found on PATH, or found but 'codex --version' failed / did not
220
+ report a version. Install (or reinstall) the agent CLI and make sure it is
221
+ on PATH, then re-run 'forgexa check'.
222
+ ✓ kimi v0.27.0
223
+ ----------------------------------------------------------------------
224
+ 2/3 agent(s) available for real platform tasks.
225
+ ```
226
+
227
+
191
228
  ### Supported AI Agents
192
229
 
193
230
  The daemon automatically discovers these agents if installed on your system:
@@ -76,6 +76,9 @@ forgexa board --project <project-id>
76
76
  | `forgexa daemon start -d` | Start daemon in background |
77
77
  | `forgexa daemon status` | Show your daemon statuses |
78
78
  | `forgexa daemon stop` | Stop local daemon |
79
+ | `forgexa check` | Verify locally-installed agent CLIs are genuinely usable (runs a real task per agent) |
80
+ | `forgexa check --quick` | Same, but discovery only — no live task, no API calls |
81
+ | `forgexa check --agent <name>` | Check a single agent (e.g. `claude`, `codex`, `kimi`) |
79
82
  | `forgexa runtimes list` | List your runtimes |
80
83
  | `forgexa version` | Show CLI version |
81
84
  | `forgexa upgrade` | Upgrade to the latest CLI release |
@@ -156,6 +159,40 @@ forgexa runtimes list
156
159
  forgexa runtimes list --all
157
160
  ```
158
161
 
162
+ ### Agent Health Check
163
+
164
+ Before starting the daemon (or when tasks keep failing on this machine), verify that every
165
+ installed agent CLI is genuinely usable — not just present on `PATH`. `forgexa check` reuses the
166
+ exact discovery and execution code the daemon uses in production, so a `PASS` means the agent
167
+ really works (auth, quota, and sandbox problems only ever surface on a real invocation).
168
+
169
+ ```bash
170
+ # Discover installed agents, then run one real, minimal task through each to confirm it works
171
+ forgexa check
172
+
173
+ # Fast pass: discovery only, no live task, no API calls
174
+ forgexa check --quick
175
+
176
+ # Check a single agent
177
+ forgexa check --agent claude
178
+ ```
179
+
180
+ Example output:
181
+
182
+ ```
183
+ Agent Availability Report
184
+ ----------------------------------------------------------------------
185
+ ✓ claude v2.1.14 (Claude Code)
186
+ ✗ codex not installed
187
+ 'codex' not found on PATH, or found but 'codex --version' failed / did not
188
+ report a version. Install (or reinstall) the agent CLI and make sure it is
189
+ on PATH, then re-run 'forgexa check'.
190
+ ✓ kimi v0.27.0
191
+ ----------------------------------------------------------------------
192
+ 2/3 agent(s) available for real platform tasks.
193
+ ```
194
+
195
+
159
196
  ### Supported AI Agents
160
197
 
161
198
  The daemon automatically discovers these agents if installed on your system:
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.20.2"
2
+ __version__ = "1.20.4"
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
555
555
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
556
556
  # Kept in sync with pyproject.toml version via bump-version.sh.
557
557
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
558
- DAEMON_VERSION = "1.20.2"
558
+ DAEMON_VERSION = "1.20.4"
559
559
 
560
560
 
561
561
  def _detect_client_type() -> str:
@@ -8892,9 +8892,46 @@ class RuntimeDaemon:
8892
8892
  if pending_count >= 10 or (now - _chunk_state["last_flush"]) >= 8.0:
8893
8893
  await _flush_output_to_server()
8894
8894
 
8895
- result = await self.process_manager.run_agent(
8896
- agent, fake_task, workspace_path, on_chunk=on_chunk,
8897
- )
8895
+ # P1-4 fix: periodic keep-alive heartbeat, independent of output activity.
8896
+ # report_ai_job_progress() on the server unconditionally renews
8897
+ # job.lease_until by AI_JOB_DISPATCHER_LEASE_SECONDS (600s) on every call,
8898
+ # but on_chunk above only POSTs when the agent actually produces new
8899
+ # stdout lines. A long silent stretch (agent "thinking", a quiet
8900
+ # build/test command, etc.) is explicitly tolerated for up to
8901
+ # AGENT_IDLE_TIMEOUT (1200s) before the daemon itself would consider the
8902
+ # agent hung — but the AIJob's server-side lease would already have
8903
+ # expired at 600s with no on_chunk activity, causing the dispatcher's
8904
+ # stale-job recovery to reset the job to pending and re-dispatch it while
8905
+ # this daemon execution is still legitimately running (duplicate
8906
+ # execution / clobbered state). A heartbeat well under the lease
8907
+ # duration closes that gap.
8908
+ _heartbeat_stop = asyncio.Event()
8909
+
8910
+ async def _lease_heartbeat():
8911
+ while not _heartbeat_stop.is_set():
8912
+ try:
8913
+ await asyncio.wait_for(_heartbeat_stop.wait(), timeout=60)
8914
+ except asyncio.TimeoutError:
8915
+ pass
8916
+ if _heartbeat_stop.is_set():
8917
+ break
8918
+ try:
8919
+ await conn.client.post(f"{reporter_url}/progress", json={}, timeout=10)
8920
+ except Exception:
8921
+ pass # never let heartbeat errors affect agent execution
8922
+
8923
+ _heartbeat_task = asyncio.create_task(_lease_heartbeat())
8924
+ try:
8925
+ result = await self.process_manager.run_agent(
8926
+ agent, fake_task, workspace_path, on_chunk=on_chunk,
8927
+ )
8928
+ finally:
8929
+ _heartbeat_stop.set()
8930
+ _heartbeat_task.cancel()
8931
+ try:
8932
+ await _heartbeat_task
8933
+ except asyncio.CancelledError:
8934
+ pass
8898
8935
  # Flush any remaining buffered lines after agent finishes
8899
8936
  await _flush_output_to_server()
8900
8937
 
@@ -8925,9 +8962,35 @@ class RuntimeDaemon:
8925
8962
  agent = _aj_fallback
8926
8963
  _aj_tried.add(agent.agent_id)
8927
8964
  fake_task.agent_type = agent.agent_id
8928
- result = await self.process_manager.run_agent(
8929
- agent, fake_task, workspace_path, on_chunk=on_chunk,
8930
- )
8965
+ # Same lease-renewal heartbeat as the primary run above (P1-4) —
8966
+ # a fallback agent run can also go silent for a while.
8967
+ _fb_heartbeat_stop = asyncio.Event()
8968
+
8969
+ async def _fb_lease_heartbeat():
8970
+ while not _fb_heartbeat_stop.is_set():
8971
+ try:
8972
+ await asyncio.wait_for(_fb_heartbeat_stop.wait(), timeout=60)
8973
+ except asyncio.TimeoutError:
8974
+ pass
8975
+ if _fb_heartbeat_stop.is_set():
8976
+ break
8977
+ try:
8978
+ await conn.client.post(f"{reporter_url}/progress", json={}, timeout=10)
8979
+ except Exception:
8980
+ pass
8981
+
8982
+ _fb_heartbeat_task = asyncio.create_task(_fb_lease_heartbeat())
8983
+ try:
8984
+ result = await self.process_manager.run_agent(
8985
+ agent, fake_task, workspace_path, on_chunk=on_chunk,
8986
+ )
8987
+ finally:
8988
+ _fb_heartbeat_stop.set()
8989
+ _fb_heartbeat_task.cancel()
8990
+ try:
8991
+ await _fb_heartbeat_task
8992
+ except asyncio.CancelledError:
8993
+ pass
8931
8994
  await _flush_output_to_server()
8932
8995
 
8933
8996
  # 4. Capture branch/commit metadata without mutating the repository.
@@ -24,6 +24,9 @@ Usage:
24
24
  forgexa daemon agents
25
25
  forgexa daemon restart
26
26
  forgexa daemon stop
27
+ forgexa check
28
+ forgexa check --quick
29
+ forgexa check --agent claude
27
30
  forgexa gates pending
28
31
  forgexa config show
29
32
  forgexa --help
@@ -1324,6 +1327,208 @@ def cmd_daemon_agents(args: argparse.Namespace) -> None:
1324
1327
  )
1325
1328
 
1326
1329
 
1330
+ # ── forgexa check ──
1331
+ # Verifies locally-installed agent CLIs are genuinely usable, using the exact
1332
+ # discovery (AgentDiscovery) and execution (ProcessManager.run_agent) code
1333
+ # paths the real daemon uses to run platform tasks. A binary being on PATH or
1334
+ # passing `--version` is not sufficient proof it works (auth, quota, sandbox,
1335
+ # and config problems only surface on a real invocation) — this command runs
1336
+ # one minimal real task per agent to confirm it end-to-end.
1337
+
1338
+ _CHECK_TASK_PROMPT = (
1339
+ "This is an automated Forgexa CLI health check, not a real task. "
1340
+ "Do not read, create, or modify any files in this directory. "
1341
+ "Simply reply with the single word: OK"
1342
+ )
1343
+
1344
+
1345
+ def _check_not_installed_reason(agent_id: str, registry: dict) -> str:
1346
+ spec = registry.get(agent_id, {})
1347
+ cmd = (spec.get("commands") or [agent_id])[0]
1348
+ detect = spec.get("detect", f"{cmd} --version")
1349
+ return (
1350
+ f"'{cmd}' not found on PATH, or found but '{detect}' failed / did not "
1351
+ "report a version. Install (or reinstall) the agent CLI and make sure "
1352
+ "it is on PATH, then re-run 'forgexa check'."
1353
+ )
1354
+
1355
+
1356
+ async def _live_check_agent(pm, agent, timeout: int) -> tuple[bool, str]:
1357
+ """Run one real, minimal task through the agent CLI.
1358
+
1359
+ Uses ProcessManager.run_agent() — the exact same method the daemon calls
1360
+ when the platform dispatches a real task — in an isolated scratch
1361
+ directory so nothing in the user's real projects is touched.
1362
+
1363
+ Returns (available, reason). reason is empty on success.
1364
+ """
1365
+ import tempfile
1366
+ from forgexa_cli.daemon import TaskInfo
1367
+
1368
+ workspace = Path(tempfile.mkdtemp(prefix="forgexa-check-"))
1369
+ try:
1370
+ subprocess.run(
1371
+ ["git", "init", "-q"],
1372
+ cwd=workspace, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
1373
+ check=False,
1374
+ )
1375
+ except Exception:
1376
+ pass
1377
+
1378
+ try:
1379
+ task = TaskInfo(
1380
+ task_id=f"check-{agent.agent_id}",
1381
+ graph_id="forgexa-check",
1382
+ node_type="review",
1383
+ agent_type=agent.agent_id,
1384
+ input_prompt=_CHECK_TASK_PROMPT,
1385
+ input_data={},
1386
+ timeout_seconds=timeout,
1387
+ max_retries=0,
1388
+ retry_count=0,
1389
+ project={},
1390
+ work_item={},
1391
+ )
1392
+ result = await pm.run_agent(agent, task, workspace)
1393
+ if result.status == "success":
1394
+ return True, ""
1395
+ reason = (result.error or "").strip() or f"exited with code {result.exit_code}"
1396
+ if reason.lower().startswith("exited with code") and (result.stderr or "").strip():
1397
+ tail = result.stderr.strip().splitlines()[-1][:300]
1398
+ reason = f"{reason}: {tail}"
1399
+ return False, reason
1400
+ except Exception as exc:
1401
+ return False, f"health check crashed: {exc}"
1402
+ finally:
1403
+ shutil.rmtree(workspace, ignore_errors=True)
1404
+
1405
+
1406
+ async def _run_agent_check(agent_ids: list[str], quick: bool, timeout: int, narrate: bool) -> list[dict]:
1407
+ import asyncio
1408
+ from forgexa_cli.daemon import AgentDiscovery, ProcessManager
1409
+
1410
+ registry = AgentDiscovery.AGENT_REGISTRY
1411
+ discovered = await AgentDiscovery().discover()
1412
+ discovered_by_id = {a.agent_id: a for a in discovered}
1413
+
1414
+ results: list[dict] = []
1415
+ live_jobs: dict[str, "asyncio.Task"] = {}
1416
+ pm = ProcessManager()
1417
+
1418
+ for agent_id in agent_ids:
1419
+ agent = discovered_by_id.get(agent_id)
1420
+ results.append({
1421
+ "agent_id": agent_id,
1422
+ "installed": agent is not None,
1423
+ "version": agent.version if agent else "",
1424
+ "available": agent is not None,
1425
+ "reason": "" if agent else _check_not_installed_reason(agent_id, registry),
1426
+ })
1427
+ if agent is not None and not quick:
1428
+ live_jobs[agent_id] = asyncio.create_task(_live_check_agent(pm, agent, timeout))
1429
+
1430
+ if live_jobs:
1431
+ if narrate:
1432
+ print(
1433
+ f"Running a live smoke-test task on {len(live_jobs)} installed agent(s) "
1434
+ f"(per-agent timeout {timeout}s)..."
1435
+ )
1436
+ print()
1437
+ for entry in results:
1438
+ job = live_jobs.get(entry["agent_id"])
1439
+ if job is None:
1440
+ continue
1441
+ ok, reason = await job
1442
+ entry["available"] = ok
1443
+ entry["reason"] = reason
1444
+ if narrate:
1445
+ print(f" {'PASS' if ok else 'FAIL'} {entry['agent_id']}")
1446
+
1447
+ return results
1448
+
1449
+
1450
+ def _print_check_report(results: list[dict], quick: bool) -> None:
1451
+ fmt = _fmt()
1452
+ if fmt == "json":
1453
+ print(json.dumps(results, indent=2, ensure_ascii=False))
1454
+ return
1455
+ if fmt == "quiet":
1456
+ for r in results:
1457
+ print(f"{r['agent_id']}\t{'available' if r['available'] else 'unavailable'}")
1458
+ return
1459
+
1460
+ print()
1461
+ print("Agent Availability Report")
1462
+ print("-" * 70)
1463
+ for r in results:
1464
+ mark = "✓" if r["available"] else "✗"
1465
+ label = f"v{r['version']}" if r.get("version") else "not installed"
1466
+ print(f" {mark} {r['agent_id']:<10} {label}")
1467
+ if r["reason"]:
1468
+ print(f" {r['reason']}")
1469
+ print("-" * 70)
1470
+
1471
+ usable = sum(1 for r in results if r["available"])
1472
+ total = len(results)
1473
+ print(f"{usable}/{total} agent(s) available for real platform tasks.")
1474
+ if quick:
1475
+ print(
1476
+ "(ran with --quick: discovery only, no live task executed — "
1477
+ "run 'forgexa check' without --quick to confirm agents really work)"
1478
+ )
1479
+ if usable == 0:
1480
+ print()
1481
+ print("No usable agents on this machine — tasks dispatched here will fail.")
1482
+ print("Install at least one supported agent CLI (Claude Code, Codex, OpenCode,")
1483
+ print("Gemini CLI, Kimi Code, or GitHub Copilot CLI), then re-run: forgexa check")
1484
+
1485
+
1486
+ def cmd_check(args: argparse.Namespace) -> None:
1487
+ """Verify locally-installed AI agent CLIs are genuinely usable for platform tasks.
1488
+
1489
+ Reuses the exact discovery (AgentDiscovery) and execution
1490
+ (ProcessManager.run_agent) code paths the real daemon uses to run tasks,
1491
+ so a PASS here means the agent actually works end-to-end — not just that
1492
+ a binary happens to exist on PATH.
1493
+ """
1494
+ import asyncio
1495
+
1496
+ os.environ.setdefault("DAEMON_NO_CONSOLE_LOG", "1")
1497
+ from forgexa_cli.daemon import AgentDiscovery
1498
+
1499
+ only_agent = getattr(args, "agent", None)
1500
+ quick = bool(getattr(args, "quick", False))
1501
+ timeout = getattr(args, "timeout", None) or 90
1502
+
1503
+ known_ids = list(AgentDiscovery.AGENT_REGISTRY.keys())
1504
+ if only_agent:
1505
+ if only_agent not in known_ids:
1506
+ print(f"Unknown agent '{only_agent}'. Known agents: {', '.join(known_ids)}", file=sys.stderr)
1507
+ sys.exit(1)
1508
+ agent_ids = [only_agent]
1509
+ else:
1510
+ agent_ids = known_ids
1511
+
1512
+ narrate = _fmt() == "table"
1513
+ if narrate:
1514
+ print("Scanning for installed agent CLIs...")
1515
+ if not quick:
1516
+ print("Each installed agent will run one real, minimal task to confirm it works")
1517
+ print(f"(this performs a live API call per agent; per-agent timeout: {timeout}s).")
1518
+ print()
1519
+
1520
+ try:
1521
+ results = asyncio.run(_run_agent_check(agent_ids, quick, timeout, narrate))
1522
+ except Exception as exc:
1523
+ print(f"Agent check failed unexpectedly: {exc}", file=sys.stderr)
1524
+ sys.exit(1)
1525
+
1526
+ _print_check_report(results, quick=quick)
1527
+
1528
+ if not any(r["available"] for r in results):
1529
+ sys.exit(1)
1530
+
1531
+
1327
1532
  def cmd_runtimes_list(args: argparse.Namespace) -> None:
1328
1533
  runtimes = _get_runtimes(include_all=getattr(args, "all", False))
1329
1534
  if not runtimes:
@@ -1737,6 +1942,21 @@ def main() -> None:
1737
1942
  )
1738
1943
  _agents_sc.add_argument("--all", action="store_true", help="Include all users' runtimes (admin only)")
1739
1944
 
1945
+ # check
1946
+ check_p = sub.add_parser(
1947
+ "check",
1948
+ help="Verify locally-installed agent CLIs are genuinely usable for platform tasks",
1949
+ )
1950
+ check_p.add_argument("--agent", default=None, help="Check only this agent (e.g. claude, codex, kimi)")
1951
+ check_p.add_argument(
1952
+ "--quick", action="store_true",
1953
+ help="Discovery only — skip the live task execution test (no API calls)",
1954
+ )
1955
+ check_p.add_argument(
1956
+ "--timeout", type=int, default=90, metavar="SECONDS",
1957
+ help="Per-agent timeout for the live execution test (default: 90)",
1958
+ )
1959
+
1740
1960
  # runtimes
1741
1961
  rt_p = sub.add_parser("runtimes", help="Runtime management")
1742
1962
  rt_sub = rt_p.add_subparsers(dest="rt_cmd")
@@ -1857,6 +2077,7 @@ def main() -> None:
1857
2077
  "status": cmd_daemon_status,
1858
2078
  "logs": cmd_daemon_logs,
1859
2079
  "agents": cmd_daemon_agents,
2080
+ "check": cmd_check,
1860
2081
  "runtimes": lambda a: {
1861
2082
  "list": cmd_runtimes_list,
1862
2083
  }.get(a.rt_cmd, lambda _: rt_p.print_help())(a),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.20.2
3
+ Version: 1.20.4
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-Expression: MIT
@@ -108,6 +108,9 @@ forgexa board --project <project-id>
108
108
  | `forgexa daemon start -d` | Start daemon in background |
109
109
  | `forgexa daemon status` | Show your daemon statuses |
110
110
  | `forgexa daemon stop` | Stop local daemon |
111
+ | `forgexa check` | Verify locally-installed agent CLIs are genuinely usable (runs a real task per agent) |
112
+ | `forgexa check --quick` | Same, but discovery only — no live task, no API calls |
113
+ | `forgexa check --agent <name>` | Check a single agent (e.g. `claude`, `codex`, `kimi`) |
111
114
  | `forgexa runtimes list` | List your runtimes |
112
115
  | `forgexa version` | Show CLI version |
113
116
  | `forgexa upgrade` | Upgrade to the latest CLI release |
@@ -188,6 +191,40 @@ forgexa runtimes list
188
191
  forgexa runtimes list --all
189
192
  ```
190
193
 
194
+ ### Agent Health Check
195
+
196
+ Before starting the daemon (or when tasks keep failing on this machine), verify that every
197
+ installed agent CLI is genuinely usable — not just present on `PATH`. `forgexa check` reuses the
198
+ exact discovery and execution code the daemon uses in production, so a `PASS` means the agent
199
+ really works (auth, quota, and sandbox problems only ever surface on a real invocation).
200
+
201
+ ```bash
202
+ # Discover installed agents, then run one real, minimal task through each to confirm it works
203
+ forgexa check
204
+
205
+ # Fast pass: discovery only, no live task, no API calls
206
+ forgexa check --quick
207
+
208
+ # Check a single agent
209
+ forgexa check --agent claude
210
+ ```
211
+
212
+ Example output:
213
+
214
+ ```
215
+ Agent Availability Report
216
+ ----------------------------------------------------------------------
217
+ ✓ claude v2.1.14 (Claude Code)
218
+ ✗ codex not installed
219
+ 'codex' not found on PATH, or found but 'codex --version' failed / did not
220
+ report a version. Install (or reinstall) the agent CLI and make sure it is
221
+ on PATH, then re-run 'forgexa check'.
222
+ ✓ kimi v0.27.0
223
+ ----------------------------------------------------------------------
224
+ 2/3 agent(s) available for real platform tasks.
225
+ ```
226
+
227
+
191
228
  ### Supported AI Agents
192
229
 
193
230
  The daemon automatically discovers these agents if installed on your system:
@@ -11,4 +11,5 @@ forgexa_cli.egg-info/dependency_links.txt
11
11
  forgexa_cli.egg-info/entry_points.txt
12
12
  forgexa_cli.egg-info/requires.txt
13
13
  forgexa_cli.egg-info/top_level.txt
14
- tests/test_auth_and_runtime_commands.py
14
+ tests/test_auth_and_runtime_commands.py
15
+ tests/test_check_command.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.20.2"
3
+ version = "1.20.4"
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 = "MIT"
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from types import SimpleNamespace
5
+ from unittest.mock import AsyncMock
6
+
7
+ import pytest
8
+
9
+ from forgexa_cli import daemon, main
10
+
11
+
12
+ def _small_registry() -> dict:
13
+ """A small subset of AGENT_REGISTRY, isolated from the real one so these
14
+ tests don't depend on (or break from) future registry additions."""
15
+ return {
16
+ "claude": {"commands": ["claude"], "detect": "claude --version"},
17
+ "kimi": {"commands": ["kimi"], "detect": "kimi --version"},
18
+ "codex": {"commands": ["codex"], "detect": "codex --version"},
19
+ }
20
+
21
+
22
+ def _agent(agent_id: str, version: str = "1.0.0") -> daemon.DiscoveredAgent:
23
+ return daemon.DiscoveredAgent(
24
+ agent_id=agent_id,
25
+ command=agent_id,
26
+ version=version,
27
+ invoke_modes=["cli"],
28
+ compatibility_level="L2",
29
+ )
30
+
31
+
32
+ def test_check_quick_reports_installed_and_missing_agents(
33
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
34
+ ) -> None:
35
+ monkeypatch.setattr(daemon.AgentDiscovery, "AGENT_REGISTRY", _small_registry())
36
+ monkeypatch.setattr(
37
+ daemon.AgentDiscovery, "discover",
38
+ AsyncMock(return_value=[_agent("claude"), _agent("kimi")]),
39
+ )
40
+ run_agent_mock = AsyncMock()
41
+ monkeypatch.setattr(daemon.ProcessManager, "run_agent", run_agent_mock)
42
+
43
+ main.cmd_check(SimpleNamespace(agent=None, quick=True, timeout=90))
44
+
45
+ # --quick must never invoke the live execution path.
46
+ run_agent_mock.assert_not_called()
47
+
48
+ out = capsys.readouterr().out
49
+ assert "✓ claude" in out
50
+ assert "✓ kimi" in out
51
+ assert "✗ codex" in out
52
+ assert "not installed" in out
53
+ assert "2/3 agent(s) available" in out
54
+
55
+
56
+ def test_check_live_mode_passes_on_success(
57
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
58
+ ) -> None:
59
+ monkeypatch.setattr(daemon.AgentDiscovery, "AGENT_REGISTRY", _small_registry())
60
+ monkeypatch.setattr(
61
+ daemon.AgentDiscovery, "discover",
62
+ AsyncMock(return_value=[_agent("claude")]),
63
+ )
64
+ run_agent_mock = AsyncMock(
65
+ return_value=daemon.TaskResult(status="success", exit_code=0, stdout="OK", stderr="")
66
+ )
67
+ monkeypatch.setattr(daemon.ProcessManager, "run_agent", run_agent_mock)
68
+
69
+ main.cmd_check(SimpleNamespace(agent="claude", quick=False, timeout=5))
70
+
71
+ run_agent_mock.assert_awaited_once()
72
+ called_agent, called_task, _called_workspace = run_agent_mock.await_args.args
73
+ assert called_agent.agent_id == "claude"
74
+ assert called_task.input_prompt == main._CHECK_TASK_PROMPT
75
+ assert called_task.timeout_seconds == 5
76
+
77
+ out = capsys.readouterr().out
78
+ assert "PASS claude" in out
79
+ assert "✓ claude" in out
80
+ assert "1/1 agent(s) available" in out
81
+
82
+
83
+ def test_check_live_mode_reports_failure_reason_and_exits_nonzero(
84
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
85
+ ) -> None:
86
+ monkeypatch.setattr(daemon.AgentDiscovery, "AGENT_REGISTRY", _small_registry())
87
+ monkeypatch.setattr(
88
+ daemon.AgentDiscovery, "discover",
89
+ AsyncMock(return_value=[_agent("kimi")]),
90
+ )
91
+ monkeypatch.setattr(
92
+ daemon.ProcessManager, "run_agent",
93
+ AsyncMock(return_value=daemon.TaskResult(
94
+ status="failed", exit_code=-1, stdout="", stderr="",
95
+ error="Kimi authentication required: please run 'kimi' and log in.",
96
+ )),
97
+ )
98
+
99
+ with pytest.raises(SystemExit) as exc_info:
100
+ main.cmd_check(SimpleNamespace(agent="kimi", quick=False, timeout=5))
101
+
102
+ assert exc_info.value.code == 1
103
+ out = capsys.readouterr().out
104
+ assert "✗ kimi" in out
105
+ assert "Kimi authentication required" in out
106
+ assert "0/1 agent(s) available" in out
107
+
108
+
109
+ def test_check_unknown_agent_exits_with_error(
110
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
111
+ ) -> None:
112
+ monkeypatch.setattr(daemon.AgentDiscovery, "AGENT_REGISTRY", _small_registry())
113
+
114
+ with pytest.raises(SystemExit) as exc_info:
115
+ main.cmd_check(SimpleNamespace(agent="bogus", quick=True, timeout=90))
116
+
117
+ assert exc_info.value.code == 1
118
+ err = capsys.readouterr().err
119
+ assert "Unknown agent 'bogus'" in err
120
+
121
+
122
+ def test_check_json_output_is_parseable(
123
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
124
+ ) -> None:
125
+ monkeypatch.setattr(daemon.AgentDiscovery, "AGENT_REGISTRY", _small_registry())
126
+ monkeypatch.setattr(
127
+ daemon.AgentDiscovery, "discover",
128
+ AsyncMock(return_value=[_agent("claude", version="2.0.0")]),
129
+ )
130
+ monkeypatch.setattr(main, "_output_format", "json")
131
+
132
+ main.cmd_check(SimpleNamespace(agent="claude", quick=True, timeout=90))
133
+
134
+ out = capsys.readouterr().out
135
+ payload = json.loads(out)
136
+ assert payload == [{
137
+ "agent_id": "claude",
138
+ "installed": True,
139
+ "version": "2.0.0",
140
+ "available": True,
141
+ "reason": "",
142
+ }]
File without changes