forgexa-cli 1.4.2__tar.gz → 1.5.2__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.4.2
3
+ Version: 1.5.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.4.2"
2
+ __version__ = "1.5.2"
@@ -279,6 +279,10 @@ except (ImportError, ModuleNotFoundError):
279
279
  def AGENT_TIMEOUT(self) -> int:
280
280
  return int(os.environ.get("AGENT_TIMEOUT", "3600"))
281
281
 
282
+ @property
283
+ def GIT_CLONE_TIMEOUT(self) -> int:
284
+ return int(os.environ.get("GIT_CLONE_TIMEOUT", "600"))
285
+
282
286
  @property
283
287
  def AGENT_MAX_OUTPUT_SIZE(self) -> int:
284
288
  return int(os.environ.get("AGENT_MAX_OUTPUT_SIZE", "100000"))
@@ -303,7 +307,7 @@ except (ImportError, ModuleNotFoundError):
303
307
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
304
308
  # Kept in sync with pyproject.toml version via bump-version.sh.
305
309
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
306
- DAEMON_VERSION = "1.4.2"
310
+ DAEMON_VERSION = "1.5.2"
307
311
 
308
312
 
309
313
  def _detect_client_type() -> str:
@@ -1085,7 +1089,10 @@ class WorkspaceManager:
1085
1089
 
1086
1090
  # Ensure _main repo is present and up-to-date
1087
1091
  if not main_repo.exists():
1088
- await self._git("clone", repo_url, str(main_repo), timeout=600, project_key=project_key)
1092
+ await self._git(
1093
+ "clone", "--single-branch", "--no-tags",
1094
+ repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1095
+ )
1089
1096
  else:
1090
1097
  await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
1091
1098
 
@@ -1173,7 +1180,10 @@ class WorkspaceManager:
1173
1180
  )
1174
1181
  except Exception:
1175
1182
  ws_path.mkdir(parents=True, exist_ok=True)
1176
- await self._git("clone", repo_url, str(ws_path), project_key=project_key)
1183
+ await self._git(
1184
+ "clone", "--single-branch", "--no-tags",
1185
+ repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1186
+ )
1177
1187
  # Ensure we're on the correct branch after clone
1178
1188
  try:
1179
1189
  await self._git("checkout", "-B", branch_name, cwd=ws_path)
@@ -1195,7 +1205,10 @@ class WorkspaceManager:
1195
1205
  except Exception:
1196
1206
  # Fallback to simple clone
1197
1207
  ws_path.mkdir(parents=True, exist_ok=True)
1198
- await self._git("clone", repo_url, str(ws_path), project_key=project_key)
1208
+ await self._git(
1209
+ "clone", "--single-branch", "--no-tags",
1210
+ repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1211
+ )
1199
1212
  # Ensure we're on the correct branch after clone
1200
1213
  try:
1201
1214
  await self._git("checkout", "-B", branch_name, cwd=ws_path)
@@ -1422,21 +1435,29 @@ class ProcessManager:
1422
1435
  Returns True for rate/quota limits AND API unavailability errors,
1423
1436
  since a different agent (using a different API backend) may succeed.
1424
1437
 
1425
- IMPORTANT: Only checks stderr, error message, and the tail of stdout.
1426
- The full stdout contains the agent's work output (e.g., analysis text
1427
- about APIs, retry logic, HTTP status codes) which naturally contains
1428
- patterns like "429", "try again", "capacity" these are NOT indicators
1429
- of the agent CLI itself being rate-limited.
1438
+ IMPORTANT: Only checks stderr and error message. When exit code is
1439
+ non-zero, also checks the tail of stdout (last 3000 chars) since the
1440
+ error is likely at the end. When exit code is 0 (agent reported
1441
+ success but _detect_agent_output_failure set status to failed), do
1442
+ NOT scan stdout — it contains the agent's work output (configs, code)
1443
+ which naturally has terms like "rate_limit", "API_RATE_LIMIT_PER_MINUTE"
1444
+ that trigger false positives.
1430
1445
  """
1431
1446
  if result.status == "success":
1432
1447
  return False
1433
- # Search error channels: stderr (CLI errors) + error message + tail of stdout
1434
- # (last 3000 chars catches any CLI-level error at the end of output)
1435
- error_text = (
1436
- (result.stderr or "")
1437
- + "\n" + (result.error or "")
1438
- + "\n" + (result.stdout or "")[-3000:]
1439
- ).lower()
1448
+ # When exit code is 0, _detect_agent_output_failure already checked
1449
+ # stderr+error for rate-limit patterns. Don't re-scan stdout here.
1450
+ if result.exit_code == 0:
1451
+ error_text = (
1452
+ (result.stderr or "")
1453
+ + "\n" + (result.error or "")
1454
+ ).lower()
1455
+ else:
1456
+ error_text = (
1457
+ (result.stderr or "")
1458
+ + "\n" + (result.error or "")
1459
+ + "\n" + (result.stdout or "")[-3000:]
1460
+ ).lower()
1440
1461
  return (
1441
1462
  any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS)
1442
1463
  or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
@@ -1456,16 +1477,16 @@ class ProcessManager:
1456
1477
  if result.status != "success":
1457
1478
  return None
1458
1479
 
1459
- # For rate/unavailability pattern detection, only check error channels
1460
- # (stderr, error field) plus the TAIL of stdout. The full stdout contains
1461
- # the agent's work output (analysis text, generated docs) which naturally
1462
- # mentions terms like "rate limit", "429", "capacity", "credit" etc.
1463
- error_channels = (
1464
- (result.stderr or "")
1465
- + "\n" + (result.error or "")
1466
- + "\n" + (result.stdout or "")[-3000:]
1467
- )
1468
- pattern_failure = ProcessManager._has_failure_pattern(error_channels)
1480
+ # For exit-code-0 (success) cases, only scan stderr and the error field
1481
+ # for rate-limit / unavailability patterns. Stdout contains the agent's
1482
+ # actual task output (code, configs, analysis docs) which may legitimately
1483
+ # contain substrings like "rate_limit", "429", "quota", etc. — e.g. writing
1484
+ # a config file with API_RATE_LIMIT_PER_MINUTE=1000 would previously trigger
1485
+ # a false "quota exhaustion" failure even though the agent succeeded.
1486
+ # stdout[-N:] is only safe to scan when the agent already failed (exit != 0),
1487
+ # which is handled by is_rate_limited() called at the orchestrator level.
1488
+ error_only_channels = (result.stderr or "") + "\n" + (result.error or "")
1489
+ pattern_failure = ProcessManager._has_failure_pattern(error_only_channels)
1469
1490
  if pattern_failure:
1470
1491
  return pattern_failure
1471
1492
 
@@ -1849,7 +1870,13 @@ class ProcessManager:
1849
1870
  on_chunk: Any = None,
1850
1871
  ) -> TaskResult:
1851
1872
  """Run OpenCode CLI in non-interactive mode."""
1852
- cmd = [agent.command, "run", "--format", "json", "--dangerously-skip-permissions", prompt]
1873
+ cmd = [
1874
+ agent.command, "run",
1875
+ "--format", "json",
1876
+ "--dangerously-skip-permissions",
1877
+ "--cwd", str(cwd),
1878
+ prompt,
1879
+ ]
1853
1880
  result = await self._run_cli(cmd, cwd, timeout, task_id, on_chunk=on_chunk)
1854
1881
  parsed_metrics = self._parse_agent_jsonl_output(result.stdout)
1855
1882
  result.metrics.update(parsed_metrics)
@@ -4,17 +4,20 @@ Forgexa CLI — command-line client for the Forgexa platform.
4
4
  A lightweight, standalone CLI that communicates with the Forgexa server via REST API.
5
5
  Zero external dependencies — uses only Python stdlib.
6
6
 
7
- Configuration:
8
- FORGEXA_SERVER_URL Server URL (default: https://api.forgexa.net)
9
- FORGEXA_TOKEN Bearer token (obtain via `forgexa login`)
7
+ Configuration (in priority order):
8
+ 1. --server-url flag Per-command override
9
+ 2. FORGEXA_SERVER_URL env var Session-level override
10
+ 3. ~/.forgexa/config Saved via `forgexa login --server <url>`
11
+ 4. Build default https://api.forgexa.net
10
12
 
11
13
  Usage:
12
- forgexa login
14
+ forgexa login --server https://your-server.com
13
15
  forgexa workspace list
14
16
  forgexa project list --workspace <id>
15
17
  forgexa requirement list --project <id>
16
18
  forgexa daemon status
17
19
  forgexa gates pending
20
+ forgexa config show
18
21
  forgexa --help
19
22
  """
20
23
  from __future__ import annotations
@@ -27,23 +30,56 @@ import signal
27
30
  import sys
28
31
  from pathlib import Path
29
32
 
30
- # ── HTTP helpers (stdlib only) ──
33
+ # ── Build-time default ──
34
+ # Resolved in priority order at runtime (see _api_url()):
35
+ # 1. --server-url flag
36
+ # 2. FORGEXA_SERVER_URL environment variable
37
+ # 3. ~/.forgexa/config (saved by `forgexa login --server <url>`)
38
+ # 4. This build default
31
39
 
32
-
33
- # Default server URL — resolved in priority order:
34
- # 1. FORGEXA_SERVER_URL environment variable (runtime override)
35
- # 2. _build_config.py — generated by publish.sh at wheel-build time
36
- # 3. Hardcoded fallback — https://api.forgexa.net
37
- #
38
- # For local development use: FORGEXA_SERVER_URL=http://localhost:8000 forgexa ...
39
40
  try:
40
- from forgexa_cli._build_config import BUILD_SERVER_URL as _DEFAULT_SERVER_URL
41
+ from forgexa_cli._build_config import BUILD_SERVER_URL as _BUILD_DEFAULT
41
42
  except ImportError:
42
- _DEFAULT_SERVER_URL = "https://api.forgexa.net"
43
+ _BUILD_DEFAULT = "https://api.forgexa.net"
44
+
45
+ # Module-level override applied by main() when --server-url is passed.
46
+ _SERVER_URL_OVERRIDE: str | None = None
47
+
48
+
49
+ # ── Config file helpers (~/.forgexa/config) ──
50
+
51
+ def _config_path() -> Path:
52
+ return Path.home() / ".forgexa" / "config"
53
+
54
+
55
+ def _load_config() -> dict:
56
+ p = _config_path()
57
+ if p.exists():
58
+ try:
59
+ return json.loads(p.read_text())
60
+ except Exception:
61
+ return {}
62
+ return {}
63
+
64
+
65
+ def _save_config(data: dict) -> None:
66
+ p = _config_path()
67
+ p.parent.mkdir(exist_ok=True)
68
+ p.write_text(json.dumps(data, indent=2))
69
+ p.chmod(0o600)
43
70
 
44
71
 
45
72
  def _api_url() -> str:
46
- return os.environ.get("FORGEXA_SERVER_URL", _DEFAULT_SERVER_URL)
73
+ """Resolve the server URL using priority chain."""
74
+ if _SERVER_URL_OVERRIDE:
75
+ return _SERVER_URL_OVERRIDE
76
+ env = os.environ.get("FORGEXA_SERVER_URL")
77
+ if env:
78
+ return env
79
+ cfg = _load_config()
80
+ if cfg.get("server_url"):
81
+ return cfg["server_url"]
82
+ return _BUILD_DEFAULT
47
83
 
48
84
 
49
85
  def _token() -> str | None:
@@ -53,7 +89,8 @@ def _token() -> str | None:
53
89
  token_file = Path.home() / ".forgexa" / "token"
54
90
  if token_file.exists():
55
91
  return token_file.read_text().strip()
56
- return None
92
+ cfg = _load_config()
93
+ return cfg.get("token") or None
57
94
 
58
95
 
59
96
  def _headers() -> dict[str, str]:
@@ -157,26 +194,83 @@ def _print_table(headers: list[str], rows: list[list[str]], fmt: str | None = No
157
194
 
158
195
 
159
196
  def cmd_login(args: argparse.Namespace) -> None:
197
+ # If --server was given, apply it for this login request and save it.
198
+ server = getattr(args, "server", None)
199
+ if server:
200
+ global _SERVER_URL_OVERRIDE
201
+ _SERVER_URL_OVERRIDE = server.rstrip("/")
202
+
160
203
  email = args.email or input("Email: ")
161
204
  password = args.password or getpass.getpass("Password: ")
162
205
  result = _post("/auth/login", {"email": email, "password": password})
163
206
  token = result.get("access_token", "")
164
- # Save token to file
207
+
208
+ # Save to config file (server_url + token in one place)
209
+ cfg = _load_config()
210
+ if server:
211
+ cfg["server_url"] = _SERVER_URL_OVERRIDE
212
+ cfg["token"] = token
213
+ _save_config(cfg)
214
+
215
+ # Also keep the legacy token file for backwards compatibility
165
216
  token_dir = Path.home() / ".forgexa"
166
217
  token_dir.mkdir(exist_ok=True)
167
218
  (token_dir / "token").write_text(token)
168
219
  (token_dir / "token").chmod(0o600)
169
- print(f"Login successful. Token saved to ~/.forgexa/token")
170
- print(f"Or set manually: export FORGEXA_TOKEN={token}")
220
+
221
+ active_server = _api_url()
222
+ print(f"Login successful.")
223
+ print(f" Server : {active_server}")
224
+ print(f" Config : ~/.forgexa/config")
171
225
 
172
226
 
173
227
  def cmd_logout(_args: argparse.Namespace) -> None:
228
+ cleared = False
174
229
  token_file = Path.home() / ".forgexa" / "token"
175
230
  if token_file.exists():
176
231
  token_file.unlink()
232
+ cleared = True
233
+ cfg = _load_config()
234
+ if "token" in cfg:
235
+ del cfg["token"]
236
+ _save_config(cfg)
237
+ cleared = True
238
+ if cleared:
177
239
  print("Logged out. Token removed.")
178
240
  else:
179
- print("No token file found.")
241
+ print("No token found.")
242
+
243
+
244
+ def cmd_config_show(_args: argparse.Namespace) -> None:
245
+ """Show current CLI configuration."""
246
+ cfg = _load_config()
247
+ token = _token()
248
+ active_url = _api_url()
249
+ source = "build default"
250
+ if _SERVER_URL_OVERRIDE:
251
+ source = "--server-url flag"
252
+ elif os.environ.get("FORGEXA_SERVER_URL"):
253
+ source = "FORGEXA_SERVER_URL env var"
254
+ elif cfg.get("server_url"):
255
+ source = f"~/.forgexa/config"
256
+ print(f"Server URL : {active_url} (source: {source})")
257
+ print(f"Auth token : {'set' if token else 'not set'}")
258
+ print(f"Config file: {_config_path()}")
259
+
260
+
261
+ def cmd_config_set(args: argparse.Namespace) -> None:
262
+ """Set a configuration value."""
263
+ cfg = _load_config()
264
+ if args.key == "server-url":
265
+ url = args.value.rstrip("/")
266
+ cfg["server_url"] = url
267
+ _save_config(cfg)
268
+ print(f"Server URL set to: {url}")
269
+ print(f"Saved to: {_config_path()}")
270
+ else:
271
+ print(f"Unknown config key: {args.key}", file=sys.stderr)
272
+ print("Available keys: server-url", file=sys.stderr)
273
+ sys.exit(1)
180
274
 
181
275
 
182
276
  def cmd_daemon_status(_args: argparse.Namespace) -> None:
@@ -467,20 +561,28 @@ def cmd_version(_args: argparse.Namespace) -> None:
467
561
 
468
562
 
469
563
  def main() -> None:
564
+ global _SERVER_URL_OVERRIDE, _output_format
565
+
566
+ active_url = _api_url() # resolved before parsing (for help text)
567
+
470
568
  parser = argparse.ArgumentParser(
471
569
  prog="forgexa",
472
570
  description="Forgexa CLI — communicates with the Forgexa server via REST API.",
473
571
  epilog=(
474
572
  "Configuration:\n"
475
- f" FORGEXA_SERVER_URL Server URL (default: {_DEFAULT_SERVER_URL})\n"
476
- " FORGEXA_TOKEN Bearer token (or use `forgexa login`)\n"
573
+ f" Current server : {active_url}\n"
574
+ " Change server : forgexa login --server <url>\n"
575
+ " forgexa config set server-url <url>\n"
576
+ " export FORGEXA_SERVER_URL=<url>\n"
577
+ " Auth token : forgexa login (saved to ~/.forgexa/config)\n"
477
578
  ),
478
579
  formatter_class=argparse.RawDescriptionHelpFormatter,
479
580
  )
480
581
  parser.add_argument(
481
582
  "--server-url",
482
583
  default=None,
483
- help=f"Server URL (default: $FORGEXA_SERVER_URL or {_DEFAULT_SERVER_URL})",
584
+ metavar="URL",
585
+ help="Server URL override for this command (also: $FORGEXA_SERVER_URL or ~/.forgexa/config)",
484
586
  )
485
587
  parser.add_argument(
486
588
  "--format",
@@ -495,10 +597,19 @@ def main() -> None:
495
597
 
496
598
  # auth
497
599
  login_p = sub.add_parser("login", help="Login and save access token")
600
+ login_p.add_argument("--server", metavar="URL", help="Server URL to connect to (saved to ~/.forgexa/config)")
498
601
  login_p.add_argument("--email", help="Email address")
499
602
  login_p.add_argument("--password", help="Password")
500
603
  sub.add_parser("logout", help="Remove saved access token")
501
604
 
605
+ # config
606
+ config_p = sub.add_parser("config", help="View or change CLI configuration")
607
+ config_sub = config_p.add_subparsers(dest="config_cmd")
608
+ config_sub.add_parser("show", help="Show current configuration")
609
+ cfg_set = config_sub.add_parser("set", help="Set a configuration value")
610
+ cfg_set.add_argument("key", choices=["server-url"], help="Config key")
611
+ cfg_set.add_argument("value", help="Config value")
612
+
502
613
  # daemon (remote API only — use forgexa-daemon for local daemon management)
503
614
  daemon_p = sub.add_parser("daemon", help="Daemon management")
504
615
  daemon_sub = daemon_p.add_subparsers(dest="daemon_cmd")
@@ -583,9 +694,8 @@ def main() -> None:
583
694
  args = parser.parse_args()
584
695
 
585
696
  if args.server_url:
586
- os.environ["FORGEXA_SERVER_URL"] = args.server_url
697
+ _SERVER_URL_OVERRIDE = args.server_url.rstrip("/")
587
698
 
588
- global _output_format
589
699
  _output_format = args.format or "table"
590
700
 
591
701
  cmd = args.command
@@ -597,6 +707,10 @@ def main() -> None:
597
707
  "version": cmd_version,
598
708
  "login": cmd_login,
599
709
  "logout": cmd_logout,
710
+ "config": lambda a: {
711
+ "show": cmd_config_show,
712
+ "set": cmd_config_set,
713
+ }.get(a.config_cmd, lambda _: config_p.print_help())(a),
600
714
  "daemon": lambda a: {
601
715
  "start": cmd_daemon_start,
602
716
  "status": cmd_daemon_status,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.4.2
3
+ Version: 1.5.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.4.2"
3
+ version = "1.5.2"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
File without changes
File without changes