forgexa-cli 1.20.3__tar.gz → 1.20.5__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.20.3 → forgexa_cli-1.20.5}/PKG-INFO +38 -1
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/README.md +37 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli/daemon.py +73 -23
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli/main.py +221 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli.egg-info/PKG-INFO +38 -1
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli.egg-info/SOURCES.txt +2 -1
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/pyproject.toml +1 -1
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/tests/test_auth_and_runtime_commands.py +20 -0
- forgexa_cli-1.20.5/tests/test_check_command.py +142 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.20.3 → forgexa_cli-1.20.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.20.
|
|
3
|
+
Version: 1.20.5
|
|
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
|
+
__version__ = "1.20.5"
|
|
@@ -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.
|
|
558
|
+
DAEMON_VERSION = "1.20.5"
|
|
559
559
|
|
|
560
560
|
|
|
561
561
|
def _detect_client_type() -> str:
|
|
@@ -773,6 +773,33 @@ class DiscoveredAgent:
|
|
|
773
773
|
compatibility_level: str
|
|
774
774
|
|
|
775
775
|
|
|
776
|
+
def _prepare_agent_command(command: list[str]) -> list[str]:
|
|
777
|
+
"""Make Windows script launchers executable through their shell hosts."""
|
|
778
|
+
if sys.platform != "win32" or not command:
|
|
779
|
+
return command
|
|
780
|
+
|
|
781
|
+
suffix = Path(command[0]).suffix.lower()
|
|
782
|
+
if suffix in {".cmd", ".bat"}:
|
|
783
|
+
return [
|
|
784
|
+
os.environ.get("COMSPEC", "cmd.exe"),
|
|
785
|
+
"/d",
|
|
786
|
+
"/s",
|
|
787
|
+
"/c",
|
|
788
|
+
subprocess.list2cmdline(command),
|
|
789
|
+
]
|
|
790
|
+
if suffix == ".ps1":
|
|
791
|
+
return [
|
|
792
|
+
"powershell.exe",
|
|
793
|
+
"-NoProfile",
|
|
794
|
+
"-NonInteractive",
|
|
795
|
+
"-ExecutionPolicy",
|
|
796
|
+
"Bypass",
|
|
797
|
+
"-File",
|
|
798
|
+
*command,
|
|
799
|
+
]
|
|
800
|
+
return command
|
|
801
|
+
|
|
802
|
+
|
|
776
803
|
@dataclass
|
|
777
804
|
class TaskInfo:
|
|
778
805
|
task_id: str
|
|
@@ -1593,21 +1620,10 @@ class AgentDiscovery:
|
|
|
1593
1620
|
import re
|
|
1594
1621
|
try:
|
|
1595
1622
|
parts = detect_cmd.split()
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
if sys.platform == "win32":
|
|
1601
|
-
resolved = shutil.which(parts[0])
|
|
1602
|
-
if resolved:
|
|
1603
|
-
ext = Path(resolved).suffix.lower()
|
|
1604
|
-
if ext in ('.cmd', '.bat'):
|
|
1605
|
-
parts = ['cmd', '/c', resolved] + parts[1:]
|
|
1606
|
-
elif ext == '.ps1':
|
|
1607
|
-
parts = [
|
|
1608
|
-
'powershell', '-NoProfile', '-NonInteractive',
|
|
1609
|
-
'-Command', resolved,
|
|
1610
|
-
] + parts[1:]
|
|
1623
|
+
resolved = shutil.which(parts[0])
|
|
1624
|
+
if resolved:
|
|
1625
|
+
parts[0] = resolved
|
|
1626
|
+
parts = _prepare_agent_command(parts)
|
|
1611
1627
|
proc = await asyncio.create_subprocess_exec(
|
|
1612
1628
|
*parts,
|
|
1613
1629
|
stdin=asyncio.subprocess.DEVNULL,
|
|
@@ -3920,6 +3936,22 @@ class ProcessManager:
|
|
|
3920
3936
|
or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
|
|
3921
3937
|
)
|
|
3922
3938
|
|
|
3939
|
+
@staticmethod
|
|
3940
|
+
def is_silent_idle_timeout(result: "TaskResult") -> bool:
|
|
3941
|
+
"""Return whether an agent was killed before producing any evidence.
|
|
3942
|
+
|
|
3943
|
+
A timeout after output or workspace changes can represent completed work
|
|
3944
|
+
and must be handled by the normal recovery path. A completely silent
|
|
3945
|
+
timeout instead means this specific CLI is stuck before it can begin,
|
|
3946
|
+
so one alternate agent is safe to try.
|
|
3947
|
+
"""
|
|
3948
|
+
return (
|
|
3949
|
+
result.failure_code == "agent_idle_timeout"
|
|
3950
|
+
and not result.stdout.strip()
|
|
3951
|
+
and not result.stderr.strip()
|
|
3952
|
+
and not result.files_changed
|
|
3953
|
+
)
|
|
3954
|
+
|
|
3923
3955
|
@staticmethod
|
|
3924
3956
|
def _detect_agent_output_failure(result: "TaskResult", agent_id: str) -> str | None:
|
|
3925
3957
|
"""Detect agent-level failures despite exit code 0.
|
|
@@ -4440,6 +4472,7 @@ class ProcessManager:
|
|
|
4440
4472
|
"--no-session-persistence",
|
|
4441
4473
|
"--dangerously-skip-permissions",
|
|
4442
4474
|
]
|
|
4475
|
+
cmd = _prepare_agent_command(cmd)
|
|
4443
4476
|
|
|
4444
4477
|
env = os.environ.copy()
|
|
4445
4478
|
for key in (
|
|
@@ -4539,6 +4572,7 @@ class ProcessManager:
|
|
|
4539
4572
|
stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
4540
4573
|
stderr=getattr(exc, "stderr", "")[-10000:],
|
|
4541
4574
|
error=_err,
|
|
4575
|
+
failure_code="agent_idle_timeout" if isinstance(exc, _IdleTimeoutError) else "",
|
|
4542
4576
|
)
|
|
4543
4577
|
except Exception as exc:
|
|
4544
4578
|
logger.exception("Claude stream error for task %s", task_id)
|
|
@@ -5160,6 +5194,7 @@ class ProcessManager:
|
|
|
5160
5194
|
_task_file = None
|
|
5161
5195
|
|
|
5162
5196
|
cmd += ["-C", str(cwd), "-p", _effective_prompt]
|
|
5197
|
+
cmd = _prepare_agent_command(cmd)
|
|
5163
5198
|
|
|
5164
5199
|
# Log the exact invocation (redact prompt body) so operators can
|
|
5165
5200
|
# reproduce failures manually and diagnose CLI version incompatibilities.
|
|
@@ -5345,6 +5380,7 @@ class ProcessManager:
|
|
|
5345
5380
|
stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
5346
5381
|
stderr=getattr(exc, "stderr", "")[-10000:],
|
|
5347
5382
|
error=_err,
|
|
5383
|
+
failure_code="agent_idle_timeout" if isinstance(exc, _IdleTimeoutError) else "",
|
|
5348
5384
|
)
|
|
5349
5385
|
except Exception as exc:
|
|
5350
5386
|
logger.exception("Copilot stream error for task %s", task_id)
|
|
@@ -5383,6 +5419,7 @@ class ProcessManager:
|
|
|
5383
5419
|
stream_stderr: bool = False,
|
|
5384
5420
|
) -> TaskResult:
|
|
5385
5421
|
try:
|
|
5422
|
+
cmd = _prepare_agent_command(cmd)
|
|
5386
5423
|
proc = await asyncio.create_subprocess_exec(
|
|
5387
5424
|
*cmd,
|
|
5388
5425
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -5421,6 +5458,7 @@ class ProcessManager:
|
|
|
5421
5458
|
stdout=getattr(exc, "stdout", "")[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
5422
5459
|
stderr=getattr(exc, "stderr", "")[-10000:],
|
|
5423
5460
|
error=_err,
|
|
5461
|
+
failure_code="agent_idle_timeout" if isinstance(exc, _IdleTimeoutError) else "",
|
|
5424
5462
|
)
|
|
5425
5463
|
except Exception as exc:
|
|
5426
5464
|
logger.exception("CLI stream error for task %s", task_id)
|
|
@@ -7786,12 +7824,21 @@ class RuntimeDaemon:
|
|
|
7786
7824
|
|
|
7787
7825
|
tried_agents.add(agent.agent_id)
|
|
7788
7826
|
|
|
7789
|
-
# ── Agent fallback:
|
|
7827
|
+
# ── Agent fallback: try another CLI after a backend failure or a fully silent hang ──
|
|
7790
7828
|
# Guard: if the agent already produced file changes in the workspace, it DID
|
|
7791
7829
|
# meaningful work — don't trigger fallback even if it crashed after completing.
|
|
7792
7830
|
# Let the recovery logic (step 4.1) handle non-zero exit with committed work.
|
|
7831
|
+
is_rate_limited = self.process_manager.is_rate_limited(result)
|
|
7832
|
+
is_silent_idle_timeout = self.process_manager.is_silent_idle_timeout(result)
|
|
7833
|
+
fallback_reason = (
|
|
7834
|
+
"rate_limit"
|
|
7835
|
+
if is_rate_limited
|
|
7836
|
+
else "agent_idle_timeout"
|
|
7837
|
+
if is_silent_idle_timeout
|
|
7838
|
+
else ""
|
|
7839
|
+
)
|
|
7793
7840
|
_skip_fallback = False
|
|
7794
|
-
if
|
|
7841
|
+
if fallback_reason:
|
|
7795
7842
|
_pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
|
|
7796
7843
|
_pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(
|
|
7797
7844
|
workspace_path,
|
|
@@ -7810,10 +7857,10 @@ class RuntimeDaemon:
|
|
|
7810
7857
|
)
|
|
7811
7858
|
_skip_fallback = True
|
|
7812
7859
|
|
|
7813
|
-
if
|
|
7860
|
+
if fallback_reason and not _skip_fallback:
|
|
7814
7861
|
logger.warning(
|
|
7815
|
-
"Agent '%s'
|
|
7816
|
-
agent.agent_id, task.task_id,
|
|
7862
|
+
"Agent '%s' failed for task %s (%s), attempting fallback",
|
|
7863
|
+
agent.agent_id, task.task_id, fallback_reason,
|
|
7817
7864
|
)
|
|
7818
7865
|
fallback_agent = self._select_fallback_agent(
|
|
7819
7866
|
agent.agent_id, task.fallback_chain, tried_agents
|
|
@@ -7830,7 +7877,7 @@ class RuntimeDaemon:
|
|
|
7830
7877
|
await reporter.report_progress(
|
|
7831
7878
|
task.task_id, 10,
|
|
7832
7879
|
f"agent_fallback: retrying with {agent.agent_id}",
|
|
7833
|
-
output_lines=[f"[daemon] Agent
|
|
7880
|
+
output_lines=[f"[daemon] Agent {fallback_reason}, switching to {agent.agent_id}"],
|
|
7834
7881
|
)
|
|
7835
7882
|
|
|
7836
7883
|
# Re-run with fallback agent
|
|
@@ -7877,6 +7924,9 @@ class RuntimeDaemon:
|
|
|
7877
7924
|
)
|
|
7878
7925
|
_line_buffer.clear()
|
|
7879
7926
|
|
|
7927
|
+
# A silent hang gets exactly one alternate-agent attempt.
|
|
7928
|
+
# Unlike quota failures, do not serially spend the full idle
|
|
7929
|
+
# timeout on every installed CLI when the host is offline.
|
|
7880
7930
|
if not self.process_manager.is_rate_limited(result):
|
|
7881
7931
|
break # Success or non-retriable failure
|
|
7882
7932
|
|
|
@@ -8260,7 +8310,7 @@ class RuntimeDaemon:
|
|
|
8260
8310
|
result.metrics["actual_agent"] = agent.agent_id
|
|
8261
8311
|
if agent.agent_id != task.agent_type:
|
|
8262
8312
|
result.metrics["original_agent"] = task.agent_type
|
|
8263
|
-
result.metrics["fallback_reason"] =
|
|
8313
|
+
result.metrics["fallback_reason"] = fallback_reason
|
|
8264
8314
|
await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed")
|
|
8265
8315
|
await reporter.report_complete(task.task_id, result)
|
|
8266
8316
|
|
|
@@ -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.
|
|
3
|
+
Version: 1.20.5
|
|
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
|
|
@@ -59,6 +59,26 @@ def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.Monkey
|
|
|
59
59
|
assert (tmp_path / ".forgexa" / "token").read_text() == "access-1"
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
@pytest.mark.parametrize("suffix", [".cmd", ".bat"])
|
|
63
|
+
def test_windows_batch_agent_commands_use_cmd_exe(
|
|
64
|
+
suffix: str, monkeypatch: pytest.MonkeyPatch,
|
|
65
|
+
) -> None:
|
|
66
|
+
launcher = r"C:\Windows\System32\cmd.exe"
|
|
67
|
+
command = [rf"C:\Users\agent\AppData\Roaming\npm\copilot{suffix}", "--version"]
|
|
68
|
+
monkeypatch.setattr(daemon.sys, "platform", "win32")
|
|
69
|
+
monkeypatch.setenv("COMSPEC", launcher)
|
|
70
|
+
|
|
71
|
+
result = daemon._prepare_agent_command(command)
|
|
72
|
+
|
|
73
|
+
assert result == [
|
|
74
|
+
launcher,
|
|
75
|
+
"/d",
|
|
76
|
+
"/s",
|
|
77
|
+
"/c",
|
|
78
|
+
daemon.subprocess.list2cmdline(command),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
62
82
|
def test_get_retries_once_after_refresh_on_401(
|
|
63
83
|
tmp_path: Path,
|
|
64
84
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|