forgexa-cli 1.4.2__tar.gz → 1.6.1__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.4.2 → forgexa_cli-1.6.1}/PKG-INFO +1 -1
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli/daemon.py +374 -53
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli/main.py +139 -25
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/pyproject.toml +1 -1
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/README.md +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.4.2 → forgexa_cli-1.6.1}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.6.1"
|
|
@@ -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.
|
|
310
|
+
DAEMON_VERSION = "1.6.1"
|
|
307
311
|
|
|
308
312
|
|
|
309
313
|
def _detect_client_type() -> str:
|
|
@@ -469,9 +473,12 @@ def get_os_info() -> str:
|
|
|
469
473
|
return f"{distro} {machine}"
|
|
470
474
|
return f"Linux {platform.release().split('-')[0]} {machine}"
|
|
471
475
|
elif system == "Windows":
|
|
472
|
-
#
|
|
473
|
-
|
|
474
|
-
|
|
476
|
+
# platform.version() returns the NT kernel version (10.0.x) for BOTH
|
|
477
|
+
# Windows 10 and Windows 11, so it cannot distinguish them.
|
|
478
|
+
# platform.win32_ver()[0] returns the marketing release: "10" or "11".
|
|
479
|
+
win_release = platform.win32_ver()[0] or platform.version().split('.')[0]
|
|
480
|
+
build = platform.version() # e.g. "10.0.26200"
|
|
481
|
+
return f"Windows {win_release} ({build}) {machine}"
|
|
475
482
|
else:
|
|
476
483
|
return f"{system} {platform.release()} {machine}"
|
|
477
484
|
|
|
@@ -865,12 +872,19 @@ class WorkspaceManager:
|
|
|
865
872
|
branch_name = f"feature/{workspace_key}"
|
|
866
873
|
|
|
867
874
|
# Determine whether this node is the "first" in a new graph that needs
|
|
868
|
-
# a fresh base from the default branch.
|
|
869
|
-
#
|
|
870
|
-
|
|
875
|
+
# a fresh base from the default branch. Only analysis nodes with
|
|
876
|
+
# explicit analysis_mode="fresh" should start fresh. All other modes
|
|
877
|
+
# (including the default when no mode is specified) preserve the
|
|
878
|
+
# existing branch to avoid destroying prior implementation commits.
|
|
879
|
+
#
|
|
880
|
+
# SAFETY: Defaulting to "refine" ensures that any code path which
|
|
881
|
+
# forgets to set analysis_mode will safely preserve the branch.
|
|
882
|
+
# Only the initial requirement analysis endpoint (requirements.py)
|
|
883
|
+
# explicitly passes "fresh" when creating a brand-new requirement branch.
|
|
884
|
+
analysis_mode = task.input_data.get("analysis_mode", "refine")
|
|
871
885
|
is_fresh_start = (
|
|
872
886
|
task.node_type == "analysis"
|
|
873
|
-
and analysis_mode
|
|
887
|
+
and analysis_mode == "fresh"
|
|
874
888
|
)
|
|
875
889
|
|
|
876
890
|
if repo_url:
|
|
@@ -973,11 +987,52 @@ class WorkspaceManager:
|
|
|
973
987
|
logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
|
|
974
988
|
await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
|
|
975
989
|
else:
|
|
976
|
-
# Healthy worktree — fetch and optionally reset
|
|
990
|
+
# Healthy worktree — fetch and optionally reset.
|
|
991
|
+
# The _main repo uses --single-branch, so `git fetch origin`
|
|
992
|
+
# only fetches the default branch. Explicitly fetch the
|
|
993
|
+
# feature branch with a full refspec so that
|
|
994
|
+
# origin/{branch_name} is available for checkout/reset.
|
|
977
995
|
try:
|
|
978
996
|
await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
|
|
979
997
|
except RuntimeError:
|
|
980
998
|
pass
|
|
999
|
+
try:
|
|
1000
|
+
await self._git(
|
|
1001
|
+
"fetch", "origin",
|
|
1002
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1003
|
+
cwd=ws_path, project_key=project_key,
|
|
1004
|
+
)
|
|
1005
|
+
except RuntimeError:
|
|
1006
|
+
pass # Branch may not exist on remote yet
|
|
1007
|
+
|
|
1008
|
+
if fresh_start:
|
|
1009
|
+
# Safety check: if the branch already exists on remote with
|
|
1010
|
+
# commits beyond the default branch, do NOT reset to
|
|
1011
|
+
# origin/default_branch — that would destroy prior work.
|
|
1012
|
+
# This guards against accidental branch destruction when
|
|
1013
|
+
# analysis_mode is incorrectly set to "fresh" for
|
|
1014
|
+
# verification or iterative nodes.
|
|
1015
|
+
branch_has_remote_commits = False
|
|
1016
|
+
try:
|
|
1017
|
+
result = await self._git(
|
|
1018
|
+
"rev-list", "--count",
|
|
1019
|
+
f"origin/{default_branch}..origin/{branch_name}",
|
|
1020
|
+
cwd=ws_path,
|
|
1021
|
+
)
|
|
1022
|
+
ahead_count = int(result.strip()) if result.strip() else 0
|
|
1023
|
+
branch_has_remote_commits = ahead_count > 0
|
|
1024
|
+
except (RuntimeError, ValueError):
|
|
1025
|
+
pass # Branch may not exist on remote yet
|
|
1026
|
+
|
|
1027
|
+
if branch_has_remote_commits:
|
|
1028
|
+
logger.warning(
|
|
1029
|
+
"Fresh start requested for %s but remote branch has %d commit(s) "
|
|
1030
|
+
"ahead of %s — switching to safe sync instead of resetting to "
|
|
1031
|
+
"avoid destroying prior work",
|
|
1032
|
+
branch_name, ahead_count, default_branch,
|
|
1033
|
+
)
|
|
1034
|
+
# Override: fall through to the non-fresh sync path
|
|
1035
|
+
fresh_start = False
|
|
981
1036
|
|
|
982
1037
|
if fresh_start:
|
|
983
1038
|
logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
|
|
@@ -1032,7 +1087,9 @@ class WorkspaceManager:
|
|
|
1032
1087
|
await asyncio.sleep(2 * (_sync_attempt + 1))
|
|
1033
1088
|
try:
|
|
1034
1089
|
await self._git(
|
|
1035
|
-
"fetch", "origin",
|
|
1090
|
+
"fetch", "origin",
|
|
1091
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1092
|
+
cwd=ws_path,
|
|
1036
1093
|
project_key=project_key,
|
|
1037
1094
|
)
|
|
1038
1095
|
except RuntimeError:
|
|
@@ -1057,7 +1114,9 @@ class WorkspaceManager:
|
|
|
1057
1114
|
await asyncio.sleep(2 * (_sync_attempt + 1))
|
|
1058
1115
|
try:
|
|
1059
1116
|
await self._git(
|
|
1060
|
-
"fetch", "origin",
|
|
1117
|
+
"fetch", "origin",
|
|
1118
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1119
|
+
cwd=ws_path,
|
|
1061
1120
|
project_key=project_key,
|
|
1062
1121
|
)
|
|
1063
1122
|
except RuntimeError:
|
|
@@ -1085,10 +1144,26 @@ class WorkspaceManager:
|
|
|
1085
1144
|
|
|
1086
1145
|
# Ensure _main repo is present and up-to-date
|
|
1087
1146
|
if not main_repo.exists():
|
|
1088
|
-
await self._git(
|
|
1147
|
+
await self._git(
|
|
1148
|
+
"clone", "--single-branch", "--no-tags",
|
|
1149
|
+
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1150
|
+
)
|
|
1089
1151
|
else:
|
|
1090
1152
|
await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
|
|
1091
1153
|
|
|
1154
|
+
# --single-branch clone only fetches the default branch.
|
|
1155
|
+
# Explicitly fetch the feature branch so origin/{branch_name}
|
|
1156
|
+
# is available for worktree creation and checkout.
|
|
1157
|
+
if not fresh_start:
|
|
1158
|
+
try:
|
|
1159
|
+
await self._git(
|
|
1160
|
+
"fetch", "origin",
|
|
1161
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1162
|
+
cwd=main_repo, timeout=60, project_key=project_key,
|
|
1163
|
+
)
|
|
1164
|
+
except RuntimeError:
|
|
1165
|
+
pass # Branch may not exist on remote yet (first analysis)
|
|
1166
|
+
|
|
1092
1167
|
# Prune stale worktree references (e.g. directories deleted externally
|
|
1093
1168
|
# when simulating cross-runtime or after disk cleanup). Without this,
|
|
1094
1169
|
# `git worktree add` refuses to create a branch that is "already checked out"
|
|
@@ -1137,7 +1212,11 @@ class WorkspaceManager:
|
|
|
1137
1212
|
)
|
|
1138
1213
|
await asyncio.sleep(2 * (_check_attempt + 1))
|
|
1139
1214
|
try:
|
|
1140
|
-
await self._git(
|
|
1215
|
+
await self._git(
|
|
1216
|
+
"fetch", "origin",
|
|
1217
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
1218
|
+
cwd=main_repo, timeout=60, project_key=project_key,
|
|
1219
|
+
)
|
|
1141
1220
|
except RuntimeError:
|
|
1142
1221
|
pass
|
|
1143
1222
|
|
|
@@ -1173,7 +1252,10 @@ class WorkspaceManager:
|
|
|
1173
1252
|
)
|
|
1174
1253
|
except Exception:
|
|
1175
1254
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
1176
|
-
await self._git(
|
|
1255
|
+
await self._git(
|
|
1256
|
+
"clone", "--single-branch", "--no-tags",
|
|
1257
|
+
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1258
|
+
)
|
|
1177
1259
|
# Ensure we're on the correct branch after clone
|
|
1178
1260
|
try:
|
|
1179
1261
|
await self._git("checkout", "-B", branch_name, cwd=ws_path)
|
|
@@ -1195,7 +1277,10 @@ class WorkspaceManager:
|
|
|
1195
1277
|
except Exception:
|
|
1196
1278
|
# Fallback to simple clone
|
|
1197
1279
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
1198
|
-
await self._git(
|
|
1280
|
+
await self._git(
|
|
1281
|
+
"clone", "--single-branch", "--no-tags",
|
|
1282
|
+
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1283
|
+
)
|
|
1199
1284
|
# Ensure we're on the correct branch after clone
|
|
1200
1285
|
try:
|
|
1201
1286
|
await self._git("checkout", "-B", branch_name, cwd=ws_path)
|
|
@@ -1214,6 +1299,18 @@ class WorkspaceManager:
|
|
|
1214
1299
|
|
|
1215
1300
|
If a project SSH key is registered, remote-touching commands
|
|
1216
1301
|
(clone, fetch, pull, push) will use GIT_SSH_COMMAND.
|
|
1302
|
+
|
|
1303
|
+
Process-group isolation: git is started in its own session so that
|
|
1304
|
+
SIGKILL on timeout propagates to all of git's children (especially
|
|
1305
|
+
the ssh subprocess that git forks for remote operations). Without
|
|
1306
|
+
this, killing git leaves orphaned ssh processes that hold the stderr
|
|
1307
|
+
pipe open — preventing asyncio from detecting EOF and causing the
|
|
1308
|
+
parent to hang until the GIT_CLONE_TIMEOUT fires.
|
|
1309
|
+
|
|
1310
|
+
SSH keepalives: GIT_SSH_COMMAND now includes ServerAliveInterval=30
|
|
1311
|
+
and ServerAliveCountMax=3 so that a stalled TCP connection (server
|
|
1312
|
+
accepts but never sends the git protocol banner) is detected within
|
|
1313
|
+
~90 s rather than waiting forever.
|
|
1217
1314
|
"""
|
|
1218
1315
|
env = None
|
|
1219
1316
|
git_prefix_args: list[str] = []
|
|
@@ -1242,6 +1339,14 @@ class WorkspaceManager:
|
|
|
1242
1339
|
f" -o StrictHostKeyChecking=accept-new"
|
|
1243
1340
|
f" -o UserKnownHostsFile=/dev/null"
|
|
1244
1341
|
f" -o IdentitiesOnly=yes"
|
|
1342
|
+
# Detect a stalled TCP connection (server accepts but
|
|
1343
|
+
# never sends the git protocol banner). After 30 s of
|
|
1344
|
+
# silence the client sends a keepalive; after 3 missed
|
|
1345
|
+
# responses (≤ 90 s total) SSH exits, closes the pipes,
|
|
1346
|
+
# and lets asyncio's communicate() return.
|
|
1347
|
+
f" -o ConnectTimeout=30"
|
|
1348
|
+
f" -o ServerAliveInterval=30"
|
|
1349
|
+
f" -o ServerAliveCountMax=3"
|
|
1245
1350
|
),
|
|
1246
1351
|
}
|
|
1247
1352
|
except Exception:
|
|
@@ -1261,18 +1366,53 @@ class WorkspaceManager:
|
|
|
1261
1366
|
if git_prefix_args:
|
|
1262
1367
|
env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
|
|
1263
1368
|
|
|
1369
|
+
# start_new_session=True puts git in its own process group.
|
|
1370
|
+
# On timeout we send SIGKILL to the entire group, which includes
|
|
1371
|
+
# any ssh/gpg/credential-helper children that git forked — preventing
|
|
1372
|
+
# orphaned processes from keeping pipes alive.
|
|
1373
|
+
# Windows note: start_new_session creates a new console process group;
|
|
1374
|
+
# we use taskkill /T there instead of killpg.
|
|
1264
1375
|
proc = await asyncio.create_subprocess_exec(
|
|
1265
1376
|
"git", *git_prefix_args, *args,
|
|
1266
1377
|
stdout=asyncio.subprocess.PIPE,
|
|
1267
1378
|
stderr=asyncio.subprocess.PIPE,
|
|
1268
1379
|
cwd=str(cwd) if cwd else None,
|
|
1269
1380
|
env=env,
|
|
1381
|
+
start_new_session=True,
|
|
1270
1382
|
)
|
|
1271
1383
|
try:
|
|
1272
1384
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
1273
1385
|
except (TimeoutError, asyncio.TimeoutError):
|
|
1274
|
-
|
|
1275
|
-
|
|
1386
|
+
# Kill the entire process group so that SSH (and any other
|
|
1387
|
+
# child processes git may have spawned) are also terminated.
|
|
1388
|
+
# A plain proc.kill() only kills the direct child (git); the
|
|
1389
|
+
# ssh grandchild becomes orphaned, keeps the stderr pipe open,
|
|
1390
|
+
# and proc.communicate() can never return EOF.
|
|
1391
|
+
try:
|
|
1392
|
+
if sys.platform != "win32":
|
|
1393
|
+
import signal as _signal
|
|
1394
|
+
try:
|
|
1395
|
+
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
|
1396
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1397
|
+
pass
|
|
1398
|
+
else:
|
|
1399
|
+
# Windows: taskkill /F /T kills the process tree
|
|
1400
|
+
import subprocess as _subprocess
|
|
1401
|
+
_subprocess.run(
|
|
1402
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1403
|
+
capture_output=True,
|
|
1404
|
+
)
|
|
1405
|
+
except Exception:
|
|
1406
|
+
pass
|
|
1407
|
+
finally:
|
|
1408
|
+
try:
|
|
1409
|
+
proc.kill()
|
|
1410
|
+
except Exception:
|
|
1411
|
+
pass
|
|
1412
|
+
try:
|
|
1413
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
1414
|
+
except Exception:
|
|
1415
|
+
pass
|
|
1276
1416
|
raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s")
|
|
1277
1417
|
finally:
|
|
1278
1418
|
# Clean up temp SSH key file if created
|
|
@@ -1340,11 +1480,27 @@ class ProcessManager:
|
|
|
1340
1480
|
|
|
1341
1481
|
@staticmethod
|
|
1342
1482
|
def _extract_output_signals(text: str) -> dict[str, Any]:
|
|
1343
|
-
"""Parse stdout/stderr-like streams for success and failure signals.
|
|
1483
|
+
"""Parse stdout/stderr-like streams for success and failure signals.
|
|
1484
|
+
|
|
1485
|
+
Uses structural signal detection — NOT keyword matching on result text.
|
|
1486
|
+
|
|
1487
|
+
The key invariant for Claude stream-json:
|
|
1488
|
+
- A genuine execution always emits at least one 'assistant' event AND
|
|
1489
|
+
has total_input_tokens > 0 in the result event, because the CLI made
|
|
1490
|
+
a real API call.
|
|
1491
|
+
- A pre-call failure (API Error, Connection error, auth failure) exits
|
|
1492
|
+
immediately: no 'assistant' events, and total_input_tokens == 0 in
|
|
1493
|
+
the result event. The result text contains the CLI error message.
|
|
1494
|
+
|
|
1495
|
+
This avoids false positives from agent work output that legitimately
|
|
1496
|
+
contains words like 'rate limit', 'connection error', 'API error', etc.
|
|
1497
|
+
(e.g. implementing a rate-limiter feature, or documenting error codes).
|
|
1498
|
+
"""
|
|
1344
1499
|
has_turn_completed = False
|
|
1345
1500
|
has_turn_failed = False
|
|
1346
1501
|
has_result = False
|
|
1347
1502
|
has_meaningful_content = False
|
|
1503
|
+
has_assistant_events = False # True once any 'assistant' event is seen
|
|
1348
1504
|
error_messages: list[str] = []
|
|
1349
1505
|
json_line_count = 0
|
|
1350
1506
|
|
|
@@ -1374,18 +1530,31 @@ class ProcessManager:
|
|
|
1374
1530
|
elif isinstance(err, str):
|
|
1375
1531
|
error_messages.append(err)
|
|
1376
1532
|
elif ev_type == "result":
|
|
1533
|
+
result_text = str(data.get("result", "") or "")
|
|
1377
1534
|
if data.get("is_error"):
|
|
1378
|
-
err_text =
|
|
1535
|
+
err_text = result_text or str(data.get("error", "") or "result marked as error")
|
|
1379
1536
|
error_messages.append(err_text)
|
|
1380
1537
|
else:
|
|
1381
|
-
|
|
1382
|
-
|
|
1538
|
+
# Structural check: if no tokens were consumed AND no assistant
|
|
1539
|
+
# events appeared, the CLI never made an API call. The result
|
|
1540
|
+
# text is a CLI-level error (e.g. "API Error: Connection error.")
|
|
1541
|
+
# rather than the agent's actual work output.
|
|
1542
|
+
tok_in = int(data.get("total_input_tokens", 0) or 0)
|
|
1543
|
+
tok_out = int(data.get("total_output_tokens", 0) or 0)
|
|
1544
|
+
no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
|
|
1545
|
+
if no_api_call and result_text:
|
|
1546
|
+
error_messages.append(result_text)
|
|
1547
|
+
else:
|
|
1548
|
+
has_result = True
|
|
1549
|
+
has_meaningful_content = True
|
|
1383
1550
|
elif ev_type == "error":
|
|
1384
1551
|
msg = data.get("message", "")
|
|
1385
1552
|
if msg:
|
|
1386
1553
|
error_messages.append(msg)
|
|
1554
|
+
elif ev_type == "assistant":
|
|
1555
|
+
has_meaningful_content = True
|
|
1556
|
+
has_assistant_events = True
|
|
1387
1557
|
elif ev_type in (
|
|
1388
|
-
"assistant",
|
|
1389
1558
|
"content_block_delta",
|
|
1390
1559
|
"message_delta",
|
|
1391
1560
|
"step_finish",
|
|
@@ -1422,21 +1591,29 @@ class ProcessManager:
|
|
|
1422
1591
|
Returns True for rate/quota limits AND API unavailability errors,
|
|
1423
1592
|
since a different agent (using a different API backend) may succeed.
|
|
1424
1593
|
|
|
1425
|
-
IMPORTANT: Only checks stderr
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1594
|
+
IMPORTANT: Only checks stderr and error message. When exit code is
|
|
1595
|
+
non-zero, also checks the tail of stdout (last 3000 chars) since the
|
|
1596
|
+
error is likely at the end. When exit code is 0 (agent reported
|
|
1597
|
+
success but _detect_agent_output_failure set status to failed), do
|
|
1598
|
+
NOT scan stdout — it contains the agent's work output (configs, code)
|
|
1599
|
+
which naturally has terms like "rate_limit", "API_RATE_LIMIT_PER_MINUTE"
|
|
1600
|
+
that trigger false positives.
|
|
1430
1601
|
"""
|
|
1431
1602
|
if result.status == "success":
|
|
1432
1603
|
return False
|
|
1433
|
-
#
|
|
1434
|
-
#
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1604
|
+
# When exit code is 0, _detect_agent_output_failure already checked
|
|
1605
|
+
# stderr+error for rate-limit patterns. Don't re-scan stdout here.
|
|
1606
|
+
if result.exit_code == 0:
|
|
1607
|
+
error_text = (
|
|
1608
|
+
(result.stderr or "")
|
|
1609
|
+
+ "\n" + (result.error or "")
|
|
1610
|
+
).lower()
|
|
1611
|
+
else:
|
|
1612
|
+
error_text = (
|
|
1613
|
+
(result.stderr or "")
|
|
1614
|
+
+ "\n" + (result.error or "")
|
|
1615
|
+
+ "\n" + (result.stdout or "")[-3000:]
|
|
1616
|
+
).lower()
|
|
1440
1617
|
return (
|
|
1441
1618
|
any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS)
|
|
1442
1619
|
or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
|
|
@@ -1456,16 +1633,16 @@ class ProcessManager:
|
|
|
1456
1633
|
if result.status != "success":
|
|
1457
1634
|
return None
|
|
1458
1635
|
|
|
1459
|
-
# For
|
|
1460
|
-
#
|
|
1461
|
-
#
|
|
1462
|
-
#
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
)
|
|
1468
|
-
pattern_failure = ProcessManager._has_failure_pattern(
|
|
1636
|
+
# For exit-code-0 (success) cases, only scan stderr and the error field
|
|
1637
|
+
# for rate-limit / unavailability patterns. Stdout contains the agent's
|
|
1638
|
+
# actual task output (code, configs, analysis docs) which may legitimately
|
|
1639
|
+
# contain substrings like "rate_limit", "429", "quota", etc. — e.g. writing
|
|
1640
|
+
# a config file with API_RATE_LIMIT_PER_MINUTE=1000 would previously trigger
|
|
1641
|
+
# a false "quota exhaustion" failure even though the agent succeeded.
|
|
1642
|
+
# stdout[-N:] is only safe to scan when the agent already failed (exit != 0),
|
|
1643
|
+
# which is handled by is_rate_limited() called at the orchestrator level.
|
|
1644
|
+
error_only_channels = (result.stderr or "") + "\n" + (result.error or "")
|
|
1645
|
+
pattern_failure = ProcessManager._has_failure_pattern(error_only_channels)
|
|
1469
1646
|
if pattern_failure:
|
|
1470
1647
|
return pattern_failure
|
|
1471
1648
|
|
|
@@ -1536,7 +1713,10 @@ class ProcessManager:
|
|
|
1536
1713
|
start_time = time.monotonic()
|
|
1537
1714
|
|
|
1538
1715
|
if agent.agent_id == "claude-code":
|
|
1539
|
-
result = await self._run_claude(
|
|
1716
|
+
result = await self._run_claude(
|
|
1717
|
+
agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
|
|
1718
|
+
node_type=task.node_type,
|
|
1719
|
+
)
|
|
1540
1720
|
elif agent.agent_id == "codex":
|
|
1541
1721
|
result = await self._run_codex(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1542
1722
|
elif agent.agent_id == "opencode":
|
|
@@ -1757,18 +1937,37 @@ class ProcessManager:
|
|
|
1757
1937
|
|
|
1758
1938
|
async def _run_claude(
|
|
1759
1939
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
1760
|
-
on_chunk: Any = None,
|
|
1940
|
+
on_chunk: Any = None, node_type: str = "",
|
|
1761
1941
|
) -> TaskResult:
|
|
1762
1942
|
"""Run Claude Code CLI in print mode with auto-approve permissions.
|
|
1763
1943
|
|
|
1764
1944
|
Uses stdin pipe for prompt delivery to avoid ARG_MAX limits.
|
|
1765
1945
|
Uses --dangerously-skip-permissions for autonomous file operations.
|
|
1946
|
+
Uses --max-turns to prevent context window overflow during long sessions.
|
|
1766
1947
|
"""
|
|
1948
|
+
# Determine max turns based on node type.
|
|
1949
|
+
# Analysis/coding nodes need more turns (read many files, write multiple outputs).
|
|
1950
|
+
# Review/testing nodes are typically shorter.
|
|
1951
|
+
# This prevents runaway context accumulation that leads to "Prompt is too long".
|
|
1952
|
+
max_turns_map = {
|
|
1953
|
+
"analysis": 80,
|
|
1954
|
+
"coding": 80,
|
|
1955
|
+
"design": 50,
|
|
1956
|
+
"testing": 60,
|
|
1957
|
+
"review": 40,
|
|
1958
|
+
"fix": 60,
|
|
1959
|
+
}
|
|
1960
|
+
max_turns = int(os.environ.get(
|
|
1961
|
+
"FACTORY_CLAUDE_MAX_TURNS",
|
|
1962
|
+
str(max_turns_map.get(node_type, 60)),
|
|
1963
|
+
))
|
|
1964
|
+
|
|
1767
1965
|
cmd = [
|
|
1768
1966
|
agent.command,
|
|
1769
1967
|
"-p",
|
|
1770
1968
|
"--output-format", "stream-json",
|
|
1771
1969
|
"--verbose",
|
|
1970
|
+
"--max-turns", str(max_turns),
|
|
1772
1971
|
"--dangerously-skip-permissions",
|
|
1773
1972
|
]
|
|
1774
1973
|
|
|
@@ -1804,6 +2003,24 @@ class ProcessManager:
|
|
|
1804
2003
|
metrics=metrics,
|
|
1805
2004
|
)
|
|
1806
2005
|
else:
|
|
2006
|
+
# Detect context overflow: Claude exits 1 with "Prompt is too long" in output
|
|
2007
|
+
_combined_output = stdout[-20000:] + stderr[-500:]
|
|
2008
|
+
if "Prompt is too long" in _combined_output or "prompt is too long" in _combined_output:
|
|
2009
|
+
logger.error(
|
|
2010
|
+
"Context overflow for task %s: Claude context window exceeded "
|
|
2011
|
+
"(%d stdout chars). Consider reducing prompt size.",
|
|
2012
|
+
task_id, len(stdout),
|
|
2013
|
+
)
|
|
2014
|
+
return TaskResult(
|
|
2015
|
+
status="failed",
|
|
2016
|
+
exit_code=returncode,
|
|
2017
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
2018
|
+
stderr=stderr[-10000:],
|
|
2019
|
+
error="context_overflow: Claude's context window was exceeded during execution. "
|
|
2020
|
+
"The accumulated conversation history (initial prompt + tool call results) "
|
|
2021
|
+
"exceeded the model's limit. Reduce prompt size or shorten file reads.",
|
|
2022
|
+
metrics=metrics,
|
|
2023
|
+
)
|
|
1807
2024
|
return TaskResult(
|
|
1808
2025
|
status="failed",
|
|
1809
2026
|
exit_code=returncode,
|
|
@@ -1849,7 +2066,13 @@ class ProcessManager:
|
|
|
1849
2066
|
on_chunk: Any = None,
|
|
1850
2067
|
) -> TaskResult:
|
|
1851
2068
|
"""Run OpenCode CLI in non-interactive mode."""
|
|
1852
|
-
cmd = [
|
|
2069
|
+
cmd = [
|
|
2070
|
+
agent.command, "run",
|
|
2071
|
+
"--format", "json",
|
|
2072
|
+
"--dangerously-skip-permissions",
|
|
2073
|
+
"--cwd", str(cwd),
|
|
2074
|
+
prompt,
|
|
2075
|
+
]
|
|
1853
2076
|
result = await self._run_cli(cmd, cwd, timeout, task_id, on_chunk=on_chunk)
|
|
1854
2077
|
parsed_metrics = self._parse_agent_jsonl_output(result.stdout)
|
|
1855
2078
|
result.metrics.update(parsed_metrics)
|
|
@@ -3605,9 +3828,13 @@ class RuntimeDaemon:
|
|
|
3605
3828
|
required_files = _get_analysis_outputs_for_type(req_type)
|
|
3606
3829
|
|
|
3607
3830
|
# Analysis deliverables live in analysis_output_dir (docs/requirements/...)
|
|
3831
|
+
_input = task.input_data or {}
|
|
3608
3832
|
doc_dir = (
|
|
3609
|
-
|
|
3610
|
-
or (
|
|
3833
|
+
_input.get("analysis_output_dir")
|
|
3834
|
+
or _input.get("context", {}).get("analysis_output_dir")
|
|
3835
|
+
or _input.get("output_dir")
|
|
3836
|
+
or _input.get("context", {}).get("output_dir")
|
|
3837
|
+
or ""
|
|
3611
3838
|
)
|
|
3612
3839
|
if doc_dir:
|
|
3613
3840
|
base = workspace_path / doc_dir
|
|
@@ -3691,7 +3918,12 @@ class RuntimeDaemon:
|
|
|
3691
3918
|
pass
|
|
3692
3919
|
|
|
3693
3920
|
if not _skip_test_artifacts:
|
|
3694
|
-
|
|
3921
|
+
_input = task.input_data or {}
|
|
3922
|
+
doc_dir = (
|
|
3923
|
+
_input.get("output_dir")
|
|
3924
|
+
or _input.get("context", {}).get("output_dir")
|
|
3925
|
+
or ""
|
|
3926
|
+
)
|
|
3695
3927
|
if doc_dir:
|
|
3696
3928
|
base = workspace_path / doc_dir
|
|
3697
3929
|
else:
|
|
@@ -3782,11 +4014,25 @@ class RuntimeDaemon:
|
|
|
3782
4014
|
],
|
|
3783
4015
|
)
|
|
3784
4016
|
|
|
3785
|
-
# Build a targeted fix prompt
|
|
4017
|
+
# Build a targeted fix prompt with output directory context
|
|
4018
|
+
_input = task.input_data or {}
|
|
4019
|
+
_fix_doc_dir = (
|
|
4020
|
+
_input.get("output_dir")
|
|
4021
|
+
or _input.get("context", {}).get("output_dir")
|
|
4022
|
+
or ""
|
|
4023
|
+
)
|
|
3786
4024
|
fix_prompt = (
|
|
3787
4025
|
"The previous execution produced output with validation errors.\n"
|
|
3788
4026
|
"Please fix ALL of the following issues:\n\n"
|
|
3789
4027
|
f"{issues_text}\n\n"
|
|
4028
|
+
)
|
|
4029
|
+
if _fix_doc_dir:
|
|
4030
|
+
fix_prompt += (
|
|
4031
|
+
f"IMPORTANT: All deliverable files (test-cases.json, coverage-matrix.json, "
|
|
4032
|
+
f"test-report.md, design.md, etc.) MUST be written to the `{_fix_doc_dir}/` "
|
|
4033
|
+
f"directory, NOT the workspace root.\n\n"
|
|
4034
|
+
)
|
|
4035
|
+
fix_prompt += (
|
|
3790
4036
|
"Fix the issues in-place. Do NOT recreate files that are already correct.\n"
|
|
3791
4037
|
"Only fix the specific problems listed above."
|
|
3792
4038
|
)
|
|
@@ -3997,7 +4243,13 @@ class RuntimeDaemon:
|
|
|
3997
4243
|
# ── Pre-push rebase onto latest default branch ──
|
|
3998
4244
|
# This ensures the AI's branch incorporates the latest remote
|
|
3999
4245
|
# changes, dramatically reducing PR merge conflicts.
|
|
4000
|
-
|
|
4246
|
+
# SKIP for analysis nodes: analysis produces documentation files
|
|
4247
|
+
# (PRD.md, SDD.md, etc.) that are independent of codebase changes.
|
|
4248
|
+
# Rebasing analysis commits onto default_branch is unnecessary
|
|
4249
|
+
# overhead and rewrites commit SHAs, which complicates pushes
|
|
4250
|
+
# on subsequent iterations of the same requirement.
|
|
4251
|
+
if task.node_type != "analysis":
|
|
4252
|
+
await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
|
|
4001
4253
|
|
|
4002
4254
|
# ── Verify we're on the correct branch before pushing ──
|
|
4003
4255
|
current_branch = ""
|
|
@@ -4523,10 +4775,79 @@ class RuntimeDaemon:
|
|
|
4523
4775
|
unpushed = "first-push"
|
|
4524
4776
|
|
|
4525
4777
|
if unpushed:
|
|
4778
|
+
# Check if remote has commits not in local history (diverged).
|
|
4779
|
+
try:
|
|
4780
|
+
remote_ahead = (await git(
|
|
4781
|
+
"log", f"HEAD..origin/{branch}", "--oneline", cwd=workspace_path,
|
|
4782
|
+
)).strip()
|
|
4783
|
+
except RuntimeError:
|
|
4784
|
+
remote_ahead = "" # Remote branch doesn't exist yet
|
|
4785
|
+
|
|
4786
|
+
if remote_ahead:
|
|
4787
|
+
remote_count = len(remote_ahead.splitlines())
|
|
4788
|
+
# Divergence detected — but this is expected after rebase.
|
|
4789
|
+
# Check if the remote commits are already incorporated
|
|
4790
|
+
# (i.e., their patches are empty against our branch, meaning
|
|
4791
|
+
# the rebase already applied them with new SHAs).
|
|
4792
|
+
# `git cherry HEAD origin/branch` lists commits from
|
|
4793
|
+
# origin/branch not in HEAD; lines starting with "-" are
|
|
4794
|
+
# already incorporated (equivalent patch exists).
|
|
4795
|
+
rebase_divergence = False
|
|
4796
|
+
try:
|
|
4797
|
+
cherry_out = (await git(
|
|
4798
|
+
"cherry", "HEAD", f"origin/{branch}",
|
|
4799
|
+
cwd=workspace_path,
|
|
4800
|
+
)).strip()
|
|
4801
|
+
if cherry_out:
|
|
4802
|
+
# All lines starting with "-" means all remote
|
|
4803
|
+
# commits are already in our branch (rebased).
|
|
4804
|
+
# Lines starting with "+" are truly missing.
|
|
4805
|
+
truly_missing = [
|
|
4806
|
+
line for line in cherry_out.splitlines()
|
|
4807
|
+
if line.startswith("+ ")
|
|
4808
|
+
]
|
|
4809
|
+
rebase_divergence = len(truly_missing) == 0
|
|
4810
|
+
else:
|
|
4811
|
+
# No cherry output — remote commits are empty or
|
|
4812
|
+
# fully equivalent
|
|
4813
|
+
rebase_divergence = True
|
|
4814
|
+
except RuntimeError:
|
|
4815
|
+
pass
|
|
4816
|
+
|
|
4817
|
+
if rebase_divergence:
|
|
4818
|
+
logger.info(
|
|
4819
|
+
"Branch %s diverged from origin (%d remote commit(s)) "
|
|
4820
|
+
"but all are already incorporated (rebase). "
|
|
4821
|
+
"Using --force-with-lease to push safely.",
|
|
4822
|
+
branch, remote_count,
|
|
4823
|
+
)
|
|
4824
|
+
try:
|
|
4825
|
+
await git(
|
|
4826
|
+
"push", "--force-with-lease", "-u", "origin", branch,
|
|
4827
|
+
cwd=workspace_path, project_key=project_key,
|
|
4828
|
+
)
|
|
4829
|
+
logger.info("Force-pushed (with lease) branch %s to origin", branch)
|
|
4830
|
+
return None
|
|
4831
|
+
except RuntimeError as exc:
|
|
4832
|
+
logger.error("Force-push (with lease) failed for %s: %s", branch, exc)
|
|
4833
|
+
return f"Push failed: {exc}"
|
|
4834
|
+
else:
|
|
4835
|
+
logger.error(
|
|
4836
|
+
"SAFETY: Refusing to push %s — remote has %d commit(s) "
|
|
4837
|
+
"not in local branch. This would destroy prior work. "
|
|
4838
|
+
"Remote-only commits:\n%s",
|
|
4839
|
+
branch, remote_count, remote_ahead,
|
|
4840
|
+
)
|
|
4841
|
+
return (
|
|
4842
|
+
f"Push refused: remote branch '{branch}' has {remote_count} "
|
|
4843
|
+
f"commit(s) not in local history. Force-pushing would "
|
|
4844
|
+
f"destroy prior implementation work."
|
|
4845
|
+
)
|
|
4846
|
+
|
|
4526
4847
|
logger.info("Found unpushed commits on %s, pushing...", branch)
|
|
4527
4848
|
try:
|
|
4528
4849
|
await git(
|
|
4529
|
-
"push", "
|
|
4850
|
+
"push", "-u", "origin", branch,
|
|
4530
4851
|
cwd=workspace_path, project_key=project_key,
|
|
4531
4852
|
)
|
|
4532
4853
|
logger.info("Pushed branch %s to origin", branch)
|
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
# ──
|
|
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
|
|
41
|
+
from forgexa_cli._build_config import BUILD_SERVER_URL as _BUILD_DEFAULT
|
|
41
42
|
except ImportError:
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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"
|
|
476
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|