forgexa-cli 1.58.1__tar.gz → 1.58.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.
Files changed (29) hide show
  1. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/PKG-INFO +1 -1
  2. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/daemon.py +268 -12
  4. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/pyproject.toml +1 -1
  6. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/README.md +0 -0
  7. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/zcode_protocol.py +0 -0
  15. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/setup.cfg +0 -0
  21. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.58.1
3
+ Version: 1.58.3
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.58.1"
2
+ __version__ = "1.58.3"
@@ -1058,6 +1058,32 @@ except (ImportError, ModuleNotFoundError):
1058
1058
  def GIT_CLONE_TIMEOUT(self) -> int:
1059
1059
  return int(os.environ.get("GIT_CLONE_TIMEOUT", "1800"))
1060
1060
 
1061
+ @property
1062
+ def GIT_WORKTREE_TIMEOUT(self) -> int:
1063
+ """Timeout (seconds) for `git worktree add` operations.
1064
+
1065
+ `worktree add` materialises a full checkout of the target branch
1066
+ into the new worktree. On large repos (e.g. HP SI) or busy disks
1067
+ this easily exceeds the 120 s default for ordinary git commands
1068
+ (SI-1181: a fresh-start worktree add timed out after 120 s and
1069
+ failed the node even though the checkout was still progressing).
1070
+
1071
+ Precedence:
1072
+ 1. GIT_WORKTREE_TIMEOUT env var (explicit override)
1073
+ 2. GIT_CLONE_TIMEOUT env var (backwards compat — an operator
1074
+ who raised the clone timeout for a huge repo wants local
1075
+ checkouts to get comparable headroom)
1076
+ 3. 600 s default (10 min — local disk I/O only, no network,
1077
+ so shorter than the clone/fetch/push ceiling)
1078
+ """
1079
+ env_worktree = os.environ.get("GIT_WORKTREE_TIMEOUT")
1080
+ if env_worktree:
1081
+ return int(env_worktree)
1082
+ env_clone = os.environ.get("GIT_CLONE_TIMEOUT")
1083
+ if env_clone:
1084
+ return int(env_clone)
1085
+ return 600
1086
+
1061
1087
  @property
1062
1088
  def GIT_FETCH_TIMEOUT(self) -> int:
1063
1089
  """Timeout (seconds) for incremental git fetch operations.
@@ -1096,6 +1122,26 @@ except (ImportError, ModuleNotFoundError):
1096
1122
  return int(env_clone)
1097
1123
  return 1800
1098
1124
 
1125
+ @property
1126
+ def GIT_INDEX_LOCK_WAIT(self) -> int:
1127
+ """Seconds to wait for a concurrently-held git index.lock (SI-1509).
1128
+
1129
+ A live git process (huge add/status on a slow disk) legitimately
1130
+ holds the lock for minutes; failing the node sync after seconds of
1131
+ retries just burns a requeue cycle on the same contention.
1132
+ """
1133
+ return int(os.environ.get("GIT_INDEX_LOCK_WAIT", "300"))
1134
+
1135
+ @property
1136
+ def GIT_STALE_LOCK_AGE(self) -> int:
1137
+ """Age (seconds) after which an index.lock is orphaned (SI-1509).
1138
+
1139
+ A lock older than this with the sync still failing is left behind
1140
+ by a SIGKILL'd git process (daemon timeout kills the whole process
1141
+ group) and is removed, as git's own error message recommends.
1142
+ """
1143
+ return int(os.environ.get("GIT_STALE_LOCK_AGE", "180"))
1144
+
1099
1145
  @property
1100
1146
  def GIT_CLONE_FILTER(self) -> str:
1101
1147
  """Optional partial-clone filter, e.g. 'blob:none' for blobless clones.
@@ -1150,7 +1196,7 @@ except (ImportError, ModuleNotFoundError):
1150
1196
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1151
1197
  # Kept in sync with pyproject.toml version via bump-version.sh.
1152
1198
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1153
- DAEMON_VERSION = "1.58.1"
1199
+ DAEMON_VERSION = "1.58.3"
1154
1200
 
1155
1201
 
1156
1202
  def _detect_client_type() -> str:
@@ -3419,7 +3465,10 @@ class WorkspaceManager:
3419
3465
  else:
3420
3466
  try:
3421
3467
  # Ensure we're on the correct branch
3422
- await self._git("checkout", branch_name, cwd=ws_path)
3468
+ await self._git_index_op(
3469
+ "checkout", branch_name, cwd=ws_path,
3470
+ context=f"final sync checkout {branch_name} in {ws_path}",
3471
+ )
3423
3472
  except RuntimeError as exc:
3424
3473
  sync_error = f"checkout branch failed: {exc}"
3425
3474
  else:
@@ -3427,9 +3476,11 @@ class WorkspaceManager:
3427
3476
  # branch has diverged (force-pushed by prior phase),
3428
3477
  # reset --hard is used below.
3429
3478
  try:
3430
- await self._git(
3479
+ await self._git_index_op(
3431
3480
  "pull", "--ff-only", "origin", branch_name,
3432
3481
  cwd=ws_path, project_key=project_key,
3482
+ timeout=settings.GIT_FETCH_TIMEOUT,
3483
+ context=f"final sync pull --ff-only {branch_name} in {ws_path}",
3433
3484
  )
3434
3485
  sync_success = True
3435
3486
  except RuntimeError as exc:
@@ -3439,9 +3490,10 @@ class WorkspaceManager:
3439
3490
  # ff-only failed (diverged or remote not yet created) — try
3440
3491
  # reset --hard to force-sync to whatever the remote has.
3441
3492
  try:
3442
- await self._git(
3493
+ await self._git_index_op(
3443
3494
  "reset", "--hard", f"origin/{branch_name}",
3444
3495
  cwd=ws_path,
3496
+ context=f"final sync reset to origin/{branch_name} in {ws_path}",
3445
3497
  )
3446
3498
  sync_success = True
3447
3499
  except RuntimeError as exc:
@@ -3826,6 +3878,157 @@ class WorkspaceManager:
3826
3878
  backup_tag,
3827
3879
  )
3828
3880
 
3881
+ # ── git index.lock contention recovery (SI-1509) ─────────────────────
3882
+ # A git command that writes the index (checkout, reset, pull, add,
3883
+ # commit, status-refresh) refuses to run while the worktree's
3884
+ # index.lock exists. Two very different situations produce that file:
3885
+ # 1. A LIVE concurrent git process (e.g. a huge `git add`/`git status`
3886
+ # on a slow disk) — must be waited out; removing the lock mid-op
3887
+ # corrupts the index.
3888
+ # 2. A STALE lock orphaned by a SIGKILL'd process (the daemon kills
3889
+ # agent process trees on timeout) — never disappears on its own
3890
+ # and must be removed; git's own error message says exactly this.
3891
+ # Before SI-1509 neither case was handled: sync failed after ~6 s of
3892
+ # blind retries with a misleading "fetch timed out or credentials
3893
+ # missing/invalid" message, and the node was requeued to repeat the
3894
+ # same failure (the real checkout error text was dropped).
3895
+
3896
+ _INDEX_LOCK_RE = re.compile(r"'([^']*index\.lock)'")
3897
+
3898
+ @classmethod
3899
+ def _extract_index_lock_path(cls, err_text: str) -> Path | None:
3900
+ """Parse the index.lock path git reports when it refuses to proceed.
3901
+
3902
+ Git prints the lock path in single quotes in every locale, e.g.
3903
+ fatal: Unable to create '/w/…/_main/.git/worktrees/<id>/index.lock': File exists.
3904
+ fatal: 无法创建 '/w/…/index.lock':文件已存在。
3905
+ """
3906
+ m = cls._INDEX_LOCK_RE.search(err_text)
3907
+ if not m:
3908
+ return None
3909
+ raw = m.group(1)
3910
+ # On Windows git may print an MSYS-style path (/c/…); leave those
3911
+ # alone — only recover locks whose path we can act on directly.
3912
+ p = Path(raw)
3913
+ return p if p.is_absolute() else None
3914
+
3915
+ @staticmethod
3916
+ def _linked_worktree_index_lock_path(
3917
+ ws_path: Path, main_repo: Path | None,
3918
+ ) -> Path | None:
3919
+ """Return the index.lock path of a structurally-valid linked worktree.
3920
+
3921
+ Unlike `_is_healthy_worktree` this isolates the *lock* condition:
3922
+ if this returns a path, the worktree is fine apart from being
3923
+ locked, so lock recovery alone can restore it without the
3924
+ destructive remove-and-recreate path.
3925
+ """
3926
+ git_file = ws_path / ".git"
3927
+ if not git_file.exists() or git_file.is_dir():
3928
+ return None
3929
+ try:
3930
+ content = git_file.read_text().strip()
3931
+ except OSError:
3932
+ return None
3933
+ if not content.startswith("gitdir: "):
3934
+ return None
3935
+ gitdir = Path(content.split("gitdir: ", 1)[1].strip())
3936
+ if main_repo is not None:
3937
+ try:
3938
+ gitdir.resolve().relative_to((main_repo / ".git" / "worktrees").resolve())
3939
+ except (ValueError, OSError):
3940
+ return None
3941
+ lock = gitdir / "index.lock"
3942
+ return lock if lock.exists() else None
3943
+
3944
+ async def _wait_or_remove_index_lock(
3945
+ self, lock_path: Path, *, max_wait: float, stale_age: float,
3946
+ context: str, poll_interval: float = 5.0,
3947
+ ) -> bool:
3948
+ """Wait out a live index.lock holder; remove the lock once stale.
3949
+
3950
+ Policy:
3951
+ • Poll every 5 s; the holder finishing removes the lock itself.
3952
+ • If the lock's mtime age exceeds ``stale_age`` seconds, the
3953
+ holder is almost certainly gone (SIGKILL leaves exactly this
3954
+ state) — unlink it as git's error message recommends.
3955
+ • Give up after ``max_wait`` seconds and return False so callers
3956
+ can fall back to their existing failure handling.
3957
+ """
3958
+ deadline = time.monotonic() + max_wait
3959
+ last_age = -1.0
3960
+ while True:
3961
+ if not lock_path.exists():
3962
+ if last_age >= 0:
3963
+ logger.info(
3964
+ "%s: index.lock %s disappeared — holder finished; retrying",
3965
+ context, lock_path,
3966
+ )
3967
+ return True
3968
+ try:
3969
+ age = time.time() - lock_path.stat().st_mtime
3970
+ except FileNotFoundError:
3971
+ continue # lock vanished between exists() and stat()
3972
+ except OSError:
3973
+ age = 0.0 # unreadable mtime (permissions) — treat as fresh
3974
+ if age > stale_age:
3975
+ try:
3976
+ lock_path.unlink()
3977
+ except FileNotFoundError:
3978
+ return True
3979
+ except OSError as rm_exc:
3980
+ logger.warning(
3981
+ "%s: could not remove stale index.lock %s (%s) — retrying",
3982
+ context, lock_path, rm_exc,
3983
+ )
3984
+ else:
3985
+ logger.warning(
3986
+ "%s: removed stale index.lock %s (age %.0fs > %.0fs; "
3987
+ "left behind by an interrupted git process)",
3988
+ context, lock_path, age, stale_age,
3989
+ )
3990
+ return True
3991
+ if time.monotonic() >= deadline:
3992
+ logger.warning(
3993
+ "%s: index.lock %s still held after %.0fs — giving up",
3994
+ context, lock_path, max_wait,
3995
+ )
3996
+ return False
3997
+ if last_age < 0:
3998
+ logger.info(
3999
+ "%s: waiting up to %.0fs for concurrent git process holding %s",
4000
+ context, max_wait, lock_path,
4001
+ )
4002
+ last_age = age
4003
+ await asyncio.sleep(poll_interval)
4004
+
4005
+ async def _git_index_op(
4006
+ self, *args: str, cwd: Path | str, context: str,
4007
+ project_key: str | None = None, timeout: int = 120,
4008
+ ) -> str:
4009
+ """Run a git command that writes the worktree index, with lock recovery.
4010
+
4011
+ On index.lock contention (live or stale) this waits out or clears
4012
+ the lock and retries once instead of failing the whole node sync —
4013
+ see the SI-1509 note above the helpers.
4014
+ """
4015
+ try:
4016
+ return await self._git(*args, cwd=cwd, timeout=timeout, project_key=project_key)
4017
+ except RuntimeError as exc:
4018
+ lock_path = self._extract_index_lock_path(str(exc))
4019
+ if lock_path is None:
4020
+ raise
4021
+ recovered = await self._wait_or_remove_index_lock(
4022
+ lock_path,
4023
+ max_wait=settings.GIT_INDEX_LOCK_WAIT,
4024
+ stale_age=settings.GIT_STALE_LOCK_AGE,
4025
+ context=context,
4026
+ )
4027
+ if not recovered:
4028
+ raise
4029
+ logger.info("%s: index.lock cleared — retrying git %s", context, args[0])
4030
+ return await self._git(*args, cwd=cwd, timeout=timeout, project_key=project_key)
4031
+
3829
4032
  async def _is_healthy_worktree(
3830
4033
  self,
3831
4034
  ws_path: Path,
@@ -4426,11 +4629,35 @@ class WorkspaceManager:
4426
4629
  # valid main repo and has no stale Git index lock. Worktrees break
4427
4630
  # when directories are moved or an interrupted Git process leaves
4428
4631
  # its index locked.
4429
- if not await self._is_healthy_worktree(
4632
+ _worktree_healthy = await self._is_healthy_worktree(
4430
4633
  ws_path,
4431
4634
  main_repo=main_repo,
4432
4635
  require_unlocked=True,
4433
- ):
4636
+ )
4637
+ if not _worktree_healthy:
4638
+ # SI-1509: when the only problem is an index.lock (live
4639
+ # concurrent git process, or a stale one left by a SIGKILL'd
4640
+ # process), recovering just the lock keeps the worktree and
4641
+ # any uncommitted state intact — no need to nuke and re-clone.
4642
+ _lock_path = self._linked_worktree_index_lock_path(ws_path, main_repo)
4643
+ if _lock_path is not None:
4644
+ logger.warning(
4645
+ "Worktree %s is locked by %s — attempting lock recovery before "
4646
+ "falling back to worktree recreation",
4647
+ ws_path, _lock_path,
4648
+ )
4649
+ if await self._wait_or_remove_index_lock(
4650
+ _lock_path,
4651
+ max_wait=settings.GIT_INDEX_LOCK_WAIT,
4652
+ stale_age=settings.GIT_STALE_LOCK_AGE,
4653
+ context=f"worktree health check for {ws_path}",
4654
+ ):
4655
+ _worktree_healthy = await self._is_healthy_worktree(
4656
+ ws_path,
4657
+ main_repo=main_repo,
4658
+ require_unlocked=True,
4659
+ )
4660
+ if not _worktree_healthy:
4434
4661
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
4435
4662
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
4436
4663
  else:
@@ -4660,15 +4887,25 @@ class WorkspaceManager:
4660
4887
  sync_success = False
4661
4888
  for _sync_attempt in range(3):
4662
4889
  try:
4663
- await self._git("checkout", branch_name, cwd=ws_path)
4890
+ await self._git_index_op(
4891
+ "checkout", branch_name, cwd=ws_path,
4892
+ context=f"sync checkout {branch_name} in {ws_path}",
4893
+ )
4664
4894
  except RuntimeError:
4665
4895
  # Branch might not exist locally yet — create tracking branch
4666
4896
  try:
4667
- await self._git(
4897
+ await self._git_index_op(
4668
4898
  "checkout", "-B", branch_name, f"origin/{branch_name}",
4669
4899
  cwd=ws_path,
4900
+ context=f"sync checkout -B {branch_name} in {ws_path}",
4670
4901
  )
4671
4902
  except RuntimeError as exc:
4903
+ # SI-1509: record the REAL checkout error (lock
4904
+ # contention, bad ref, …) — without this the
4905
+ # raised message fell back to a misleading
4906
+ # "fetch timed out or credentials missing/invalid"
4907
+ # even though every fetch had succeeded.
4908
+ _last_sync_err = str(exc)[:300]
4672
4909
  if _sync_attempt < 2:
4673
4910
  logger.info(
4674
4911
  "Branch %s not available yet (attempt %d/3), re-fetching...",
@@ -4693,13 +4930,15 @@ class WorkspaceManager:
4693
4930
  logger.warning("Failed to checkout %s after retries: %s", branch_name, exc)
4694
4931
  # Reset working tree to match remote branch (fast-forward)
4695
4932
  try:
4696
- await self._git(
4933
+ await self._git_index_op(
4697
4934
  "reset", "--hard", f"origin/{branch_name}",
4698
4935
  cwd=ws_path,
4936
+ context=f"sync reset to origin/{branch_name} in {ws_path}",
4699
4937
  )
4700
4938
  sync_success = True
4701
4939
  break
4702
4940
  except RuntimeError as exc:
4941
+ _last_sync_err = str(exc)[:300]
4703
4942
  if _sync_attempt < 2:
4704
4943
  logger.info(
4705
4944
  "Could not reset to origin/%s (attempt %d/3), re-fetching...",
@@ -4761,7 +5000,9 @@ class WorkspaceManager:
4761
5000
  logger.warning("Could not remove stale worktree %s: %s", ws_path, _rm_exc)
4762
5001
  _err_detail = (
4763
5002
  f"Git error: {_last_sync_err}" if _last_sync_err
4764
- else "fetch timed out or credentials missing/invalid"
5003
+ else "checkout/reset failed repeatedly with no captured "
5004
+ "git error (possible stale index.lock or interrupted "
5005
+ "git process on the daemon host)"
4765
5006
  )
4766
5007
  raise RuntimeError(
4767
5008
  f"Failed to sync branch '{branch_name}' from remote after 3 attempts "
@@ -5221,7 +5462,8 @@ class WorkspaceManager:
5221
5462
  ) from recovery_exc
5222
5463
  self._safe_rmtree(ws_path)
5223
5464
  raise RuntimeError(
5224
- f"Failed to create clean fresh worktree for branch '{branch_name}'. "
5465
+ f"Failed to create clean fresh worktree for branch '{branch_name}': "
5466
+ f"{self._summarize_git_error(exc)}. "
5225
5467
  "Refusing to reuse a stale local branch."
5226
5468
  ) from exc
5227
5469
  else:
@@ -5257,13 +5499,18 @@ class WorkspaceManager:
5257
5499
  """Optional cleanup after task. For now, keep workspaces for debugging."""
5258
5500
  pass
5259
5501
 
5260
- async def _git(self, *args: str, cwd: Path | str | None = None, timeout: int = 120,
5502
+ async def _git(self, *args: str, cwd: Path | str | None = None, timeout: int | None = None,
5261
5503
  project_key: str | None = None) -> str:
5262
5504
  """Run a git command safely using exec (no shell injection).
5263
5505
 
5264
5506
  If a project SSH key is registered, remote-touching commands
5265
5507
  (clone, fetch, pull, push) will use GIT_SSH_COMMAND.
5266
5508
 
5509
+ Timeout resolution (SI-1181): when ``timeout`` is None the default
5510
+ is 120 s for ordinary git commands, but `worktree add` — which
5511
+ materialises a full checkout of the branch — gets the dedicated
5512
+ GIT_WORKTREE_TIMEOUT (default 600 s) instead.
5513
+
5267
5514
  Process-group isolation: git is started in its own session so that
5268
5515
  SIGKILL on timeout propagates to all of git's children (especially
5269
5516
  the ssh subprocess that git forks for remote operations). Without
@@ -5276,6 +5523,15 @@ class WorkspaceManager:
5276
5523
  accepts but never sends the git protocol banner) is detected within
5277
5524
  ~90 s rather than waiting forever.
5278
5525
  """
5526
+ if timeout is None:
5527
+ # `git worktree add` is a full checkout of the target branch;
5528
+ # on large repos (e.g. HP SI) or busy disks it legitimately
5529
+ # exceeds the 120 s default for ordinary git commands.
5530
+ timeout = (
5531
+ settings.GIT_WORKTREE_TIMEOUT
5532
+ if args[:2] == ("worktree", "add")
5533
+ else 120
5534
+ )
5279
5535
  env = None
5280
5536
  git_prefix_args: list[str] = []
5281
5537
  # Inject SSH key for commands that touch the remote
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.58.1
3
+ Version: 1.58.3
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.58.1"
3
+ version = "1.58.3"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes