forgexa-cli 1.3.4__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.3.4
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.3.4"
2
+ __version__ = "1.5.2"
@@ -37,16 +37,198 @@ try:
37
37
  except ImportError:
38
38
  fcntl = None # type: ignore[assignment]
39
39
 
40
- try:
41
- import httpx
42
- except ImportError:
43
- # Auto-install httpx when running standalone (e.g., bundled with desktop app)
44
- subprocess.check_call(
45
- [sys.executable, "-m", "pip", "install", "--quiet", "httpx>=0.24"],
46
- stdout=subprocess.DEVNULL,
47
- stderr=subprocess.PIPE,
40
+ # ── httpx dependency — robust auto-install for standalone environments ──
41
+ # When running inside the backend package, httpx is a declared dependency and
42
+ # always available. In standalone contexts (desktop AppImage/DMG/MSI, CLI
43
+ # without [daemon] extra), httpx may be missing. We try multiple strategies:
44
+ #
45
+ # 1. Direct import (works for backend & CLI[daemon])
46
+ # 2. Import from cached deps dir (~/.forgexa/daemon/deps)
47
+ # 3. Auto-install via pip --target to the cached deps dir
48
+ # (bypasses PEP 668 / externally-managed-environment on modern distros)
49
+ # 4. Friendly error with OS-specific instructions if all else fails
50
+ _HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
51
+
52
+
53
+ def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
54
+ """Try to install httpx to a user-writable directory.
55
+
56
+ Uses pip --target which works on:
57
+ - AppImage (read-only squashfs, system Python)
58
+ - PEP 668 systems (Ubuntu 23.04+, Fedora 38+) — bypasses externally-managed check
59
+ - macOS .app bundles (sandboxed Python)
60
+ - Windows portable installs
61
+ - Docker containers with read-only system dirs
62
+
63
+ Returns (success, error_detail).
64
+ """
65
+ os.makedirs(deps_dir, exist_ok=True)
66
+ python = sys.executable or "python3"
67
+
68
+ # Try pip --target first (most universally compatible).
69
+ # Falls back to --user, then --break-system-packages as last resort.
70
+ # We explicitly list httpcore alongside httpx because pip --target may
71
+ # skip transitive deps it finds in system site-packages, even though
72
+ # they won't be importable from the isolated deps directory.
73
+ strategies: list[tuple[str, list[str]]] = [
74
+ (
75
+ "pip install --target (isolated deps)",
76
+ [python, "-m", "pip", "install", "--target", deps_dir,
77
+ "--quiet", "--upgrade", "httpx>=0.24", "httpcore"],
78
+ ),
79
+ (
80
+ "pip install --user",
81
+ [python, "-m", "pip", "install", "--user", "--quiet",
82
+ "httpx>=0.24", "httpcore"],
83
+ ),
84
+ (
85
+ "pip install --break-system-packages",
86
+ [python, "-m", "pip", "install", "--quiet",
87
+ "--break-system-packages", "httpx>=0.24", "httpcore"],
88
+ ),
89
+ ]
90
+
91
+ last_error = ""
92
+ for label, cmd in strategies:
93
+ try:
94
+ result = subprocess.run(
95
+ cmd,
96
+ stdout=subprocess.DEVNULL,
97
+ stderr=subprocess.PIPE,
98
+ text=True,
99
+ timeout=120,
100
+ )
101
+ if result.returncode == 0:
102
+ return True, ""
103
+ last_error = f"[{label}] exit code {result.returncode}"
104
+ stderr_text = (result.stderr or "").strip()
105
+ if stderr_text:
106
+ # Keep last 5 lines of stderr for diagnostics
107
+ stderr_lines = stderr_text.splitlines()[-5:]
108
+ last_error += ": " + " | ".join(stderr_lines)
109
+ except FileNotFoundError:
110
+ last_error = f"[{label}] Python not found: {cmd[0]}"
111
+ except subprocess.TimeoutExpired:
112
+ last_error = f"[{label}] timed out after 120s"
113
+ except Exception as exc:
114
+ last_error = f"[{label}] {type(exc).__name__}: {exc}"
115
+
116
+ return False, last_error
117
+
118
+
119
+ def _die_missing_httpx(detail: str) -> None:
120
+ """Print a clear, actionable error and exit when httpx cannot be loaded."""
121
+ os_name = platform.system()
122
+ python_path = sys.executable or "(unknown)"
123
+
124
+ if os_name == "Linux":
125
+ hints = [
126
+ "pip3 install --user httpx",
127
+ "sudo apt install python3-httpx # Debian/Ubuntu",
128
+ "sudo dnf install python3-httpx # Fedora/RHEL",
129
+ "pip3 install forgexa-cli[daemon]",
130
+ ]
131
+ elif os_name == "Darwin":
132
+ hints = [
133
+ "pip3 install httpx",
134
+ "brew install python3 && pip3 install httpx",
135
+ "pip3 install forgexa-cli[daemon]",
136
+ ]
137
+ elif os_name == "Windows":
138
+ hints = [
139
+ "pip install httpx",
140
+ "pip install forgexa-cli[daemon]",
141
+ ]
142
+ else:
143
+ hints = [
144
+ "pip3 install httpx",
145
+ "pip3 install forgexa-cli[daemon]",
146
+ ]
147
+
148
+ hint_lines = "\n".join(f" {h}" for h in hints)
149
+ msg = (
150
+ "\n"
151
+ "┌─────────────────────────────────────────────────────────────────────┐\n"
152
+ "│ Forgexa Daemon: missing required dependency 'httpx' │\n"
153
+ "└─────────────────────────────────────────────────────────────────────┘\n"
154
+ "\n"
155
+ " The daemon requires the 'httpx' HTTP client library but it could\n"
156
+ " not be imported, and automatic installation failed.\n"
157
+ "\n"
158
+ f" Python: {python_path}\n"
159
+ f" Platform: {os_name} ({platform.machine()})\n"
160
+ f" Detail: {detail}\n"
161
+ "\n"
162
+ " Please install it manually with one of these commands:\n"
163
+ "\n"
164
+ f"{hint_lines}\n"
165
+ "\n"
166
+ " Then restart the daemon.\n"
167
+ "─────────────────────────────────────────────────────────────────────\n"
48
168
  )
49
- import httpx
169
+ print(msg, file=sys.stderr)
170
+ # Machine-readable summary for the desktop app to parse and show as a toast.
171
+ print(f"DAEMON_ERROR: Missing required Python package 'httpx'. {detail}", file=sys.stderr)
172
+ sys.exit(1)
173
+
174
+
175
+ def _validate_httpx_imports() -> tuple[bool, str]:
176
+ """Validate that httpx and its critical transitive deps are importable.
177
+
178
+ A bare ``import httpx`` can succeed even when httpcore is missing,
179
+ because httpx lazily imports its transport layer. We eagerly check
180
+ the full chain so the daemon fails fast with a clear message instead
181
+ of crashing mid-operation when ``httpx.AsyncClient()`` tries to load
182
+ the transport.
183
+
184
+ Returns (ok, missing_module_name).
185
+ """
186
+ for mod_name in ("httpx", "httpcore"):
187
+ try:
188
+ __import__(mod_name)
189
+ except ImportError:
190
+ return False, mod_name
191
+ return True, ""
192
+
193
+
194
+ # Actual import sequence
195
+ _httpx_ok, _httpx_missing = _validate_httpx_imports()
196
+
197
+ if not _httpx_ok:
198
+ # Check cached deps directory (previous auto-install)
199
+ if _HTTPX_DEPS_DIR not in sys.path:
200
+ sys.path.insert(0, _HTTPX_DEPS_DIR)
201
+ _httpx_ok, _httpx_missing = _validate_httpx_imports()
202
+
203
+ if not _httpx_ok:
204
+ # If httpx is present but a sub-dependency (httpcore) is missing,
205
+ # the deps directory has a partial/stale installation. Clear it and
206
+ # purge cached modules so pip does a clean install with all transitive
207
+ # dependencies.
208
+ if _httpx_missing != "httpx":
209
+ shutil.rmtree(_HTTPX_DEPS_DIR, ignore_errors=True)
210
+ for _mod_key in list(sys.modules):
211
+ if _mod_key in ("httpx", "httpcore") or \
212
+ _mod_key.startswith(("httpx.", "httpcore.")):
213
+ del sys.modules[_mod_key]
214
+
215
+ # Attempt auto-install to user-writable deps directory
216
+ _ok, _err = _try_install_httpx(_HTTPX_DEPS_DIR)
217
+ if _ok:
218
+ if _HTTPX_DEPS_DIR not in sys.path:
219
+ sys.path.insert(0, _HTTPX_DEPS_DIR)
220
+ _httpx_ok, _httpx_missing = _validate_httpx_imports()
221
+ if not _httpx_ok:
222
+ _die_missing_httpx(
223
+ f"pip install succeeded but '{_httpx_missing}' still cannot "
224
+ "be imported — check Python version compatibility"
225
+ )
226
+ else:
227
+ _die_missing_httpx(_err)
228
+
229
+ import httpx # noqa: E402 — guaranteed available after validation above
230
+
231
+ del _httpx_ok, _httpx_missing
50
232
 
51
233
  # ── Settings: graceful fallback when running standalone (outside backend package) ──
52
234
  try:
@@ -97,6 +279,10 @@ except (ImportError, ModuleNotFoundError):
97
279
  def AGENT_TIMEOUT(self) -> int:
98
280
  return int(os.environ.get("AGENT_TIMEOUT", "3600"))
99
281
 
282
+ @property
283
+ def GIT_CLONE_TIMEOUT(self) -> int:
284
+ return int(os.environ.get("GIT_CLONE_TIMEOUT", "600"))
285
+
100
286
  @property
101
287
  def AGENT_MAX_OUTPUT_SIZE(self) -> int:
102
288
  return int(os.environ.get("AGENT_MAX_OUTPUT_SIZE", "100000"))
@@ -117,6 +303,36 @@ except (ImportError, ModuleNotFoundError):
117
303
 
118
304
  settings = _StandaloneSettings() # type: ignore[assignment]
119
305
 
306
+ # ── Daemon version and client type ────────────────────────────────────────
307
+ # DAEMON_VERSION is the protocol/logic version of the daemon code.
308
+ # Kept in sync with pyproject.toml version via bump-version.sh.
309
+ # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
310
+ DAEMON_VERSION = "1.5.2"
311
+
312
+
313
+ def _detect_client_type() -> str:
314
+ """Auto-detect client type from runtime context.
315
+
316
+ Priority:
317
+ 1. FORGEXA_CLIENT_TYPE env var (set by desktop Tauri launcher)
318
+ 2. Import context: app.config importable → "server"
319
+ 3. Default: "cli" (standalone pip-installed daemon)
320
+
321
+ This allows a single daemon.py source to work correctly regardless
322
+ of deployment context, making the bundle-daemon.sh copy safe.
323
+ """
324
+ env_type = os.environ.get("FORGEXA_CLIENT_TYPE", "").strip().lower()
325
+ if env_type in ("server", "cli", "desktop"):
326
+ return env_type
327
+ # Server: app.config was successfully imported at module level above
328
+ if "app.config" in sys.modules:
329
+ return "server"
330
+ # Default: standalone execution = CLI
331
+ return "cli"
332
+
333
+
334
+ _CLIENT_TYPE = _detect_client_type()
335
+
120
336
  # ── Logging — self-managed file handler ────────────────────────────────
121
337
  # The daemon configures its own FileHandler so logs are written to
122
338
  # ~/.forgexa/daemon/daemon.log regardless of how the daemon was launched
@@ -331,6 +547,31 @@ class TaskResult:
331
547
  git: dict = field(default_factory=dict)
332
548
 
333
549
 
550
+ # ── Type-aware analysis outputs (inline fallback for standalone daemons) ──
551
+ # Mirrors type_workflow_profiles.py — used when import is unavailable (CLI/Desktop).
552
+ _ANALYSIS_OUTPUTS_BY_TYPE: dict[str, list[str]] = {
553
+ "feature": ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"],
554
+ "bugfix": ["diagnosis.md", "TASKS.md", "analysis.json", "test-intent.json"],
555
+ "refactor": ["refactor-plan.md", "TASKS.md", "analysis.json"],
556
+ "documentation": ["outline.md", "analysis.json"],
557
+ "improvement": ["improvement-spec.md", "TASKS.md", "analysis.json", "test-intent.json"],
558
+ "task": ["task-plan.md", "analysis.json"],
559
+ }
560
+
561
+
562
+ def _get_analysis_outputs_for_type(req_type: str) -> list[str]:
563
+ """Get expected analysis output files for a requirement type.
564
+
565
+ Tries to use type_workflow_profiles (available in backend context),
566
+ falls back to inline mapping for standalone daemon execution.
567
+ """
568
+ try:
569
+ from app.services.type_workflow_profiles import get_profile
570
+ return list(get_profile(req_type).analysis_outputs)
571
+ except Exception:
572
+ return _ANALYSIS_OUTPUTS_BY_TYPE.get(req_type, _ANALYSIS_OUTPUTS_BY_TYPE["feature"])
573
+
574
+
334
575
  # ── Agent Discovery ──
335
576
 
336
577
 
@@ -848,7 +1089,10 @@ class WorkspaceManager:
848
1089
 
849
1090
  # Ensure _main repo is present and up-to-date
850
1091
  if not main_repo.exists():
851
- 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
+ )
852
1096
  else:
853
1097
  await self._git("fetch", "--all", cwd=main_repo, timeout=300, project_key=project_key)
854
1098
 
@@ -936,7 +1180,10 @@ class WorkspaceManager:
936
1180
  )
937
1181
  except Exception:
938
1182
  ws_path.mkdir(parents=True, exist_ok=True)
939
- 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
+ )
940
1187
  # Ensure we're on the correct branch after clone
941
1188
  try:
942
1189
  await self._git("checkout", "-B", branch_name, cwd=ws_path)
@@ -958,7 +1205,10 @@ class WorkspaceManager:
958
1205
  except Exception:
959
1206
  # Fallback to simple clone
960
1207
  ws_path.mkdir(parents=True, exist_ok=True)
961
- 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
+ )
962
1212
  # Ensure we're on the correct branch after clone
963
1213
  try:
964
1214
  await self._git("checkout", "-B", branch_name, cwd=ws_path)
@@ -1066,15 +1316,11 @@ class ProcessManager:
1066
1316
  "usage limit",
1067
1317
  "rate limit",
1068
1318
  "rate_limit",
1069
- "429",
1070
1319
  "quota exceeded",
1071
1320
  "too many requests",
1072
1321
  "overloaded",
1073
- "capacity",
1074
- "try again",
1075
- "credit",
1076
1322
  "insufficient_quota",
1077
- "billing",
1323
+ "billing hard limit",
1078
1324
  ]
1079
1325
 
1080
1326
  # Patterns indicating the agent's API is unreachable/misconfigured —
@@ -1086,9 +1332,11 @@ class ProcessManager:
1086
1332
  "connection refused",
1087
1333
  "connection reset",
1088
1334
  "connection timed out",
1335
+ "connection error",
1089
1336
  "name or service not known",
1090
1337
  "no such host",
1091
1338
  "network is unreachable",
1339
+ "api error",
1092
1340
  ]
1093
1341
 
1094
1342
  def __init__(self):
@@ -1139,8 +1387,12 @@ class ProcessManager:
1139
1387
  elif isinstance(err, str):
1140
1388
  error_messages.append(err)
1141
1389
  elif ev_type == "result":
1142
- has_result = True
1143
- has_meaningful_content = True
1390
+ if data.get("is_error"):
1391
+ err_text = str(data.get("result", "") or data.get("error", "") or "result marked as error")
1392
+ error_messages.append(err_text)
1393
+ else:
1394
+ has_result = True
1395
+ has_meaningful_content = True
1144
1396
  elif ev_type == "error":
1145
1397
  msg = data.get("message", "")
1146
1398
  if msg:
@@ -1182,13 +1434,33 @@ class ProcessManager:
1182
1434
 
1183
1435
  Returns True for rate/quota limits AND API unavailability errors,
1184
1436
  since a different agent (using a different API backend) may succeed.
1437
+
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.
1185
1445
  """
1186
1446
  if result.status == "success":
1187
1447
  return False
1188
- combined = (result.stdout + result.stderr + result.error).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()
1189
1461
  return (
1190
- any(p in combined for p in ProcessManager.RATE_LIMIT_PATTERNS)
1191
- or any(p in combined for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
1462
+ any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS)
1463
+ or any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS)
1192
1464
  )
1193
1465
 
1194
1466
  @staticmethod
@@ -1205,8 +1477,16 @@ class ProcessManager:
1205
1477
  if result.status != "success":
1206
1478
  return None
1207
1479
 
1208
- combined = "\n".join(part for part in (result.stdout, result.stderr, result.error) if part)
1209
- pattern_failure = ProcessManager._has_failure_pattern(combined)
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)
1210
1490
  if pattern_failure:
1211
1491
  return pattern_failure
1212
1492
 
@@ -1320,18 +1600,23 @@ class ProcessManager:
1320
1600
  return normalized
1321
1601
 
1322
1602
  def _required_deliverable_paths(self, task: TaskInfo) -> set[str]:
1323
- output_dir = str((task.input_data or {}).get("output_dir", "") or "")
1603
+ # For analysis nodes, deliverables live in analysis_output_dir (docs/requirements/...)
1604
+ # For other nodes, use output_dir (docs/implements/...)
1605
+ if task.node_type == "analysis":
1606
+ output_dir = str(
1607
+ (task.input_data or {}).get("analysis_output_dir", "")
1608
+ or (task.input_data or {}).get("output_dir", "")
1609
+ or ""
1610
+ )
1611
+ else:
1612
+ output_dir = str((task.input_data or {}).get("output_dir", "") or "")
1324
1613
  output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
1325
1614
  if not output_dir:
1326
1615
  return set()
1327
1616
 
1328
1617
  if task.node_type == "analysis":
1329
1618
  req_type = (task.input_data or {}).get("requirement_type", "feature")
1330
- try:
1331
- from app.services.type_workflow_profiles import get_profile
1332
- required_files = list(get_profile(req_type).analysis_outputs)
1333
- except Exception:
1334
- required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
1619
+ required_files = _get_analysis_outputs_for_type(req_type)
1335
1620
  elif task.node_type == "design":
1336
1621
  required_files = ["design.md"]
1337
1622
  else:
@@ -1342,7 +1627,8 @@ class ProcessManager:
1342
1627
  def _has_required_deliverable_updates(self, task: TaskInfo, *path_lists: list[str] | None) -> bool:
1343
1628
  required_paths = self._required_deliverable_paths(task)
1344
1629
  if not required_paths:
1345
- return False
1630
+ # Cannot determine required deliverables — skip check (don't fail)
1631
+ return True
1346
1632
 
1347
1633
  changed_paths: set[str] = set()
1348
1634
  for paths in path_lists:
@@ -1502,7 +1788,7 @@ class ProcessManager:
1502
1788
  cmd = [
1503
1789
  agent.command,
1504
1790
  "-p",
1505
- "--output-format", "json",
1791
+ "--output-format", "stream-json",
1506
1792
  "--verbose",
1507
1793
  "--dangerously-skip-permissions",
1508
1794
  ]
@@ -1584,7 +1870,13 @@ class ProcessManager:
1584
1870
  on_chunk: Any = None,
1585
1871
  ) -> TaskResult:
1586
1872
  """Run OpenCode CLI in non-interactive mode."""
1587
- 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
+ ]
1588
1880
  result = await self._run_cli(cmd, cwd, timeout, task_id, on_chunk=on_chunk)
1589
1881
  parsed_metrics = self._parse_agent_jsonl_output(result.stdout)
1590
1882
  result.metrics.update(parsed_metrics)
@@ -1706,6 +1998,9 @@ class ProcessManager:
1706
1998
  data = json.loads(stdout.strip())
1707
1999
  if isinstance(data, dict):
1708
2000
  parsed.append(data)
2001
+ elif isinstance(data, list):
2002
+ # Handle JSON array (from --output-format json)
2003
+ parsed.extend(d for d in data if isinstance(d, dict))
1709
2004
  except (json.JSONDecodeError, ValueError):
1710
2005
  pass
1711
2006
 
@@ -2182,6 +2477,8 @@ class HeartbeatService:
2182
2477
  "available_agents": self._agents,
2183
2478
  "system_metrics": self._collect_system_metrics(),
2184
2479
  "os_info": get_os_info(),
2480
+ "daemon_version": DAEMON_VERSION,
2481
+ "client_type": _CLIENT_TYPE,
2185
2482
  },
2186
2483
  timeout=10,
2187
2484
  )
@@ -2210,6 +2507,76 @@ class HeartbeatService:
2210
2507
  return {}
2211
2508
 
2212
2509
 
2510
+ # ── Log Uploader ──
2511
+
2512
+
2513
+ class LogUploader:
2514
+ """Periodically uploads daemon log tail to the server for remote viewing."""
2515
+
2516
+ LOG_UPLOAD_INTERVAL = 300 # Upload every 5 minutes
2517
+ LOG_TAIL_LINES = 500 # Last N lines to upload
2518
+
2519
+ def __init__(
2520
+ self,
2521
+ client: httpx.AsyncClient,
2522
+ server_url: str,
2523
+ runtime_id: str,
2524
+ ):
2525
+ self.client = client
2526
+ self.server_url = server_url.rstrip("/")
2527
+ self.runtime_id = runtime_id
2528
+ self._task: asyncio.Task | None = None
2529
+
2530
+ async def start(self):
2531
+ self._task = asyncio.create_task(self._loop())
2532
+
2533
+ async def stop(self):
2534
+ if self._task:
2535
+ self._task.cancel()
2536
+ try:
2537
+ await self._task
2538
+ except asyncio.CancelledError:
2539
+ pass
2540
+
2541
+ async def _loop(self):
2542
+ # Initial upload after 30s delay (let daemon stabilize first)
2543
+ await asyncio.sleep(30)
2544
+ while True:
2545
+ try:
2546
+ await self._upload()
2547
+ except asyncio.CancelledError:
2548
+ raise
2549
+ except Exception as e:
2550
+ logger.warning("Log upload error: %s", e)
2551
+ await asyncio.sleep(self.LOG_UPLOAD_INTERVAL)
2552
+
2553
+ async def _upload(self):
2554
+ """Read daemon log tail and upload to server."""
2555
+ try:
2556
+ if not DAEMON_LOG_PATH.exists():
2557
+ return
2558
+ # Read last N lines efficiently
2559
+ with open(DAEMON_LOG_PATH, "rb") as f:
2560
+ # Seek from end to find last N lines
2561
+ f.seek(0, 2)
2562
+ file_size = f.tell()
2563
+ # Read at most 100KB from end
2564
+ read_size = min(file_size, 100 * 1024)
2565
+ f.seek(file_size - read_size)
2566
+ content = f.read().decode("utf-8", errors="replace")
2567
+
2568
+ # Take last N lines
2569
+ lines = content.split("\n")
2570
+ tail = "\n".join(lines[-self.LOG_TAIL_LINES:])
2571
+
2572
+ await self.client.post(
2573
+ f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/logs",
2574
+ json={"log_tail": tail, "log_lines": self.LOG_TAIL_LINES},
2575
+ timeout=15,
2576
+ )
2577
+ except Exception as e:
2578
+ logger.warning("Failed to upload daemon log: %s", e)
2579
+
2213
2580
  # ── Task Poller ──
2214
2581
 
2215
2582
 
@@ -2296,6 +2663,7 @@ class ServerConnection:
2296
2663
  self.heartbeat: HeartbeatService | None = None
2297
2664
  self.poller: TaskPoller | None = None
2298
2665
  self.reporter: ProgressReporter | None = None
2666
+ self.log_uploader: LogUploader | None = None
2299
2667
  self._auth_failures = 0 # Consecutive auth failure count
2300
2668
  self._max_auth_failures = 3 # Trigger re-registration after this many
2301
2669
  # Short label for logging
@@ -2338,6 +2706,8 @@ class ServerConnection:
2338
2706
  self.poller.runtime_id = self.runtime_id
2339
2707
  if self.reporter and self.runtime_id:
2340
2708
  self.reporter.runtime_id = self.runtime_id
2709
+ if self.log_uploader and self.runtime_id:
2710
+ self.log_uploader.runtime_id = self.runtime_id
2341
2711
  self._auth_failures = 0
2342
2712
  logger.info("[%s] Re-registered successfully after token refresh", self.label)
2343
2713
  except Exception as e:
@@ -2364,6 +2734,8 @@ class ServerConnection:
2364
2734
  "hardware_id": self.hardware_id,
2365
2735
  "device_name": platform.node(),
2366
2736
  "os_info": get_os_info(),
2737
+ "daemon_version": DAEMON_VERSION,
2738
+ "client_type": _CLIENT_TYPE,
2367
2739
  "available_agents": agent_dicts,
2368
2740
  "max_concurrent_tasks": max_concurrent,
2369
2741
  "capabilities": {
@@ -2434,15 +2806,22 @@ class ServerConnection:
2434
2806
  self.reporter = ProgressReporter(
2435
2807
  self.client, self.server_url, self.runtime_id,
2436
2808
  )
2809
+ self.log_uploader = LogUploader(
2810
+ self.client, self.server_url, self.runtime_id,
2811
+ )
2437
2812
 
2438
2813
  async def start_heartbeat(self):
2439
2814
  if self.heartbeat:
2440
2815
  await self.heartbeat.start()
2816
+ if self.log_uploader:
2817
+ await self.log_uploader.start()
2441
2818
 
2442
2819
  async def stop(self):
2443
2820
  """Stop heartbeat and unregister."""
2444
2821
  if self.heartbeat:
2445
2822
  await self.heartbeat.stop()
2823
+ if self.log_uploader:
2824
+ await self.log_uploader.stop()
2446
2825
  if self.runtime_id:
2447
2826
  try:
2448
2827
  # Use deregister endpoint (no admin required) instead of DELETE
@@ -2874,15 +3253,15 @@ class RuntimeDaemon:
2874
3253
  _line_buffer.extend(lines)
2875
3254
 
2876
3255
  async def _progress_ticker():
2877
- """Flush buffered output lines + update progress % every 10 s."""
3256
+ """Flush buffered output lines + update progress % every 5 s."""
2878
3257
  import math as _math
2879
3258
  tick = 0
2880
3259
  while not progress_stop.is_set():
2881
- await asyncio.sleep(10)
3260
+ await asyncio.sleep(5)
2882
3261
  if progress_stop.is_set():
2883
3262
  break
2884
3263
  tick += 1
2885
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 8))), 90)
3264
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
2886
3265
  pid = self.process_manager.active_processes.get(task.task_id)
2887
3266
  step = "running_agent"
2888
3267
  if pid:
@@ -2919,7 +3298,26 @@ class RuntimeDaemon:
2919
3298
  tried_agents.add(agent.agent_id)
2920
3299
 
2921
3300
  # ── Agent fallback: if agent hit rate limit or API is unavailable, try next agent ──
3301
+ # Guard: if the agent already produced file changes in the workspace, it DID
3302
+ # meaningful work — don't trigger fallback even if it crashed after completing.
3303
+ # Let the recovery logic (step 4.1) handle non-zero exit with committed work.
3304
+ _skip_fallback = False
2922
3305
  if self.process_manager.is_rate_limited(result):
3306
+ _pre_fallback_git = await self.process_manager._collect_git_info(workspace_path)
3307
+ _pre_fallback_committed = await self.process_manager._collect_git_info_vs_parent(workspace_path)
3308
+ has_workspace_changes = (
3309
+ bool(_pre_fallback_git.get("files_changed"))
3310
+ or bool(_pre_fallback_committed.get("files_changed"))
3311
+ )
3312
+ if has_workspace_changes:
3313
+ logger.info(
3314
+ "Agent '%s' exited non-zero for task %s but workspace has changes — "
3315
+ "skipping fallback, proceeding to recovery",
3316
+ agent.agent_id, task.task_id,
3317
+ )
3318
+ _skip_fallback = True
3319
+
3320
+ if self.process_manager.is_rate_limited(result) and not _skip_fallback:
2923
3321
  logger.warning(
2924
3322
  "Agent '%s' unavailable/rate-limited for task %s, attempting fallback",
2925
3323
  agent.agent_id, task.task_id,
@@ -2949,11 +3347,11 @@ class RuntimeDaemon:
2949
3347
  async def _progress_ticker2():
2950
3348
  tick = 0
2951
3349
  while not progress_stop2.is_set():
2952
- await asyncio.sleep(10)
3350
+ await asyncio.sleep(5)
2953
3351
  if progress_stop2.is_set():
2954
3352
  break
2955
3353
  tick += 1
2956
- pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 8))), 90)
3354
+ pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 16))), 90)
2957
3355
  pid = self.process_manager.active_processes.get(task.task_id)
2958
3356
  step = f"running_agent:{agent.agent_id}"
2959
3357
  if pid:
@@ -3066,13 +3464,33 @@ class RuntimeDaemon:
3066
3464
  # Existing files from a prior iteration are not sufficient evidence.
3067
3465
  if result.status == "success" and task.node_type in ("analysis", "design"):
3068
3466
  committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
3069
- if not self._has_required_deliverable_updates(
3467
+ git_check_passed = self.process_manager._has_required_deliverable_updates(
3070
3468
  task,
3071
3469
  pre_commit_git.get("files_changed"),
3072
3470
  committed_git.get("files_changed"),
3073
3471
  result.files_changed,
3074
3472
  (result.git or {}).get("files_changed"),
3075
- ):
3473
+ )
3474
+ # Fallback: if git-based check fails (e.g., agent auto-committed and
3475
+ # merge-base detection failed), verify files physically exist on disk.
3476
+ # This prevents false failures when git state is unusual but files
3477
+ # are actually present.
3478
+ if not git_check_passed:
3479
+ required_paths = self.process_manager._required_deliverable_paths(task)
3480
+ if required_paths:
3481
+ files_exist = all(
3482
+ (workspace_path / p).exists() and (workspace_path / p).stat().st_size > 0
3483
+ for p in required_paths
3484
+ )
3485
+ if files_exist:
3486
+ logger.info(
3487
+ "Task %s (%s): git diff did not show deliverables but all %d "
3488
+ "files exist on disk — accepting as success",
3489
+ task.task_id, task.node_type, len(required_paths),
3490
+ )
3491
+ git_check_passed = True
3492
+
3493
+ if not git_check_passed:
3076
3494
  logger.warning(
3077
3495
  "Task %s (%s) reported success but did not update required deliverables",
3078
3496
  task.task_id, task.node_type,
@@ -3099,6 +3517,16 @@ class RuntimeDaemon:
3099
3517
  if commit_result:
3100
3518
  # Propagate push/commit errors in metrics so they're visible
3101
3519
  result.metrics.update(commit_result)
3520
+ # Push failure is a real problem for downstream nodes — mark
3521
+ # as failed so the orchestrator can retry (transient network).
3522
+ if commit_result.get("push_error"):
3523
+ push_err = commit_result["push_error"]
3524
+ logger.error(
3525
+ "Task %s: push failed, marking as failed so retry can attempt push again: %s",
3526
+ task.task_id, push_err,
3527
+ )
3528
+ result.status = "failed"
3529
+ result.error = f"Git push failed: {push_err}"
3102
3530
  # Re-collect git info after commit (compare with parent)
3103
3531
  post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
3104
3532
  # Merge: use the pre-commit file list if post-commit is empty
@@ -3201,15 +3629,13 @@ class RuntimeDaemon:
3201
3629
 
3202
3630
  if node_type == "analysis":
3203
3631
  # Use type profile to determine required analysis outputs
3204
- try:
3205
- from app.services.type_workflow_profiles import get_profile
3206
- profile = get_profile(req_type)
3207
- required_files = profile.analysis_outputs
3208
- except Exception:
3209
- # Fallback to full set if profile import fails
3210
- required_files = ["PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json"]
3632
+ required_files = _get_analysis_outputs_for_type(req_type)
3211
3633
 
3212
- doc_dir = (task.input_data or {}).get("output_dir", "")
3634
+ # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
3635
+ doc_dir = (
3636
+ (task.input_data or {}).get("analysis_output_dir", "")
3637
+ or (task.input_data or {}).get("output_dir", "")
3638
+ )
3213
3639
  if doc_dir:
3214
3640
  base = workspace_path / doc_dir
3215
3641
  else:
@@ -3444,20 +3870,25 @@ class RuntimeDaemon:
3444
3870
  always receives the file contents via the completion report and gate
3445
3871
  reviewers can see the analysis documents immediately.
3446
3872
  """
3447
- doc_dir = (task.input_data or {}).get("output_dir", "")
3873
+ # Analysis deliverables live in analysis_output_dir (docs/requirements/...)
3874
+ doc_dir = (
3875
+ (task.input_data or {}).get("analysis_output_dir", "")
3876
+ or (task.input_data or {}).get("output_dir", "")
3877
+ )
3448
3878
  if not doc_dir:
3449
3879
  return
3450
3880
 
3451
3881
  base = workspace_path / doc_dir.lstrip("./")
3452
- _ANALYSIS_FILES = ("PRD.md", "SDD.md", "TASKS.md", "analysis.json", "test-intent.json")
3453
- existing_artifact_paths = {a.get("path", "") for a in result.artifacts}
3882
+ req_type = (task.input_data or {}).get("requirement_type", "feature")
3883
+ _ANALYSIS_FILES = _get_analysis_outputs_for_type(req_type)
3884
+ existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
3454
3885
 
3455
3886
  for fname in _ANALYSIS_FILES:
3456
3887
  fpath = base / fname
3457
3888
  if not fpath.exists() or fpath.stat().st_size == 0:
3458
3889
  continue
3459
3890
  try:
3460
- rel_path = str(fpath.relative_to(workspace_path))
3891
+ rel_path = str(fpath.relative_to(workspace_path)).replace("\\", "/")
3461
3892
  if rel_path in existing_artifact_paths:
3462
3893
  continue # already attached
3463
3894
  content = fpath.read_text(encoding="utf-8", errors="replace")
@@ -3487,13 +3918,13 @@ class RuntimeDaemon:
3487
3918
  return
3488
3919
 
3489
3920
  base = workspace_path / doc_dir.lstrip("./")
3490
- existing_artifact_paths = {a.get("path", "") for a in result.artifacts}
3921
+ existing_artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
3491
3922
 
3492
3923
  design_path = base / "design.md"
3493
3924
  if not design_path.exists() or design_path.stat().st_size == 0:
3494
3925
  return
3495
3926
  try:
3496
- rel_path = str(design_path.relative_to(workspace_path))
3927
+ rel_path = str(design_path.relative_to(workspace_path)).replace("\\", "/")
3497
3928
  if rel_path in existing_artifact_paths:
3498
3929
  return
3499
3930
  content = design_path.read_text(encoding="utf-8", errors="replace")
@@ -3555,6 +3986,8 @@ class RuntimeDaemon:
3555
3986
  or task.input_data.get("title")
3556
3987
  or ""
3557
3988
  )
3989
+ if not isinstance(wi_title, str):
3990
+ wi_title = str(wi_title)
3558
3991
  req_key = task.requirement_key or task.work_item.get("requirement_key") or ""
3559
3992
  if req_key and wi_title:
3560
3993
  display_title = f"{req_key}: {wi_title}"
@@ -3565,11 +3998,15 @@ class RuntimeDaemon:
3565
3998
  else:
3566
3999
  display_title = task.task_id
3567
4000
 
3568
- commit_msg = await self._build_auto_commit_message(
3569
- display_title, task.task_id, task.node_type,
3570
- task.agent_type, change_summary,
3571
- workspace_path=workspace_path,
3572
- )
4001
+ try:
4002
+ commit_msg = await self._build_auto_commit_message(
4003
+ display_title, task.task_id, task.node_type,
4004
+ task.agent_type, change_summary,
4005
+ workspace_path=workspace_path,
4006
+ )
4007
+ except Exception as msg_err:
4008
+ logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
4009
+ commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
3573
4010
  proc = await asyncio.create_subprocess_exec(
3574
4011
  "git", "commit", "-m", commit_msg,
3575
4012
  cwd=str(workspace_path),
@@ -3766,7 +4203,22 @@ class RuntimeDaemon:
3766
4203
  lines: list[str] = []
3767
4204
 
3768
4205
  # Summary — word-wrap at 78 chars
3769
- summary = (data.get("summary") or "").strip()
4206
+ raw_summary = data.get("summary")
4207
+ if isinstance(raw_summary, dict):
4208
+ # Some agents produce summary as a structured object; extract description
4209
+ summary = (
4210
+ raw_summary.get("description")
4211
+ or raw_summary.get("title")
4212
+ or raw_summary.get("summary")
4213
+ or ""
4214
+ )
4215
+ if not isinstance(summary, str):
4216
+ summary = str(summary) if summary else ""
4217
+ elif isinstance(raw_summary, str):
4218
+ summary = raw_summary
4219
+ else:
4220
+ summary = str(raw_summary) if raw_summary else ""
4221
+ summary = summary.strip()
3770
4222
  if summary:
3771
4223
  words = summary.split()
3772
4224
  current = ""
@@ -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.3.4
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.3.4"
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