forgexa-cli 1.11.3__tar.gz → 1.11.4__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.11.3
3
+ Version: 1.11.4
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
@@ -100,9 +100,9 @@ forgexa board --project <project-id>
100
100
  | `forgexa budget --workspace <id>` | Budget overview |
101
101
  | `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
102
102
  | `forgexa daemon start -d` | Start daemon in background |
103
- | `forgexa daemon status` | Show daemon statuses |
103
+ | `forgexa daemon status` | Show your daemon statuses |
104
104
  | `forgexa daemon stop` | Stop local daemon |
105
- | `forgexa runtimes list` | List runtimes |
105
+ | `forgexa runtimes list` | List your runtimes |
106
106
  | `forgexa version` | Show CLI version |
107
107
 
108
108
  ## Configuration
@@ -144,11 +144,20 @@ forgexa-daemon
144
144
  ### Other Daemon Commands
145
145
 
146
146
  ```bash
147
- # Check daemon status (from server)
147
+ # Check your daemon status (from server)
148
148
  forgexa daemon status
149
149
 
150
+ # Platform admin: list all runtimes
151
+ forgexa daemon status --all
152
+
150
153
  # Stop background daemon
151
154
  forgexa daemon stop
155
+
156
+ # List your runtimes
157
+ forgexa runtimes list
158
+
159
+ # Platform admin: list all runtimes
160
+ forgexa runtimes list --all
152
161
  ```
153
162
 
154
163
  ### Supported AI Agents
@@ -70,9 +70,9 @@ forgexa board --project <project-id>
70
70
  | `forgexa budget --workspace <id>` | Budget overview |
71
71
  | `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
72
72
  | `forgexa daemon start -d` | Start daemon in background |
73
- | `forgexa daemon status` | Show daemon statuses |
73
+ | `forgexa daemon status` | Show your daemon statuses |
74
74
  | `forgexa daemon stop` | Stop local daemon |
75
- | `forgexa runtimes list` | List runtimes |
75
+ | `forgexa runtimes list` | List your runtimes |
76
76
  | `forgexa version` | Show CLI version |
77
77
 
78
78
  ## Configuration
@@ -114,11 +114,20 @@ forgexa-daemon
114
114
  ### Other Daemon Commands
115
115
 
116
116
  ```bash
117
- # Check daemon status (from server)
117
+ # Check your daemon status (from server)
118
118
  forgexa daemon status
119
119
 
120
+ # Platform admin: list all runtimes
121
+ forgexa daemon status --all
122
+
120
123
  # Stop background daemon
121
124
  forgexa daemon stop
125
+
126
+ # List your runtimes
127
+ forgexa runtimes list
128
+
129
+ # Platform admin: list all runtimes
130
+ forgexa runtimes list --all
122
131
  ```
123
132
 
124
133
  ### Supported AI Agents
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.11.3"
2
+ __version__ = "1.11.4"
@@ -68,6 +68,42 @@ except ImportError:
68
68
  _HTTPX_DEPS_DIR = os.path.join(str(Path.home()), ".forgexa", "daemon", "deps")
69
69
 
70
70
 
71
+ def _cli_config_path() -> Path:
72
+ return Path.home() / ".forgexa" / "config"
73
+
74
+
75
+ def _load_cli_config() -> dict:
76
+ path = _cli_config_path()
77
+ if not path.exists():
78
+ return {}
79
+ try:
80
+ return json.loads(path.read_text())
81
+ except Exception:
82
+ return {}
83
+
84
+
85
+ def _save_cli_config(data: dict) -> None:
86
+ path = _cli_config_path()
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ path.write_text(json.dumps(data, indent=2))
89
+ path.chmod(0o600)
90
+
91
+
92
+ def _save_cli_tokens(access_token: str, refresh_token: str | None = None) -> None:
93
+ cfg = _load_cli_config()
94
+ cfg["token"] = access_token
95
+ if refresh_token:
96
+ cfg["refresh_token"] = refresh_token
97
+ else:
98
+ cfg.pop("refresh_token", None)
99
+ _save_cli_config(cfg)
100
+
101
+ token_path = Path.home() / ".forgexa" / "token"
102
+ token_path.parent.mkdir(parents=True, exist_ok=True)
103
+ token_path.write_text(access_token)
104
+ token_path.chmod(0o600)
105
+
106
+
71
107
  def _try_install_httpx(deps_dir: str) -> tuple[bool, str]:
72
108
  """Try to install httpx to a user-writable directory.
73
109
 
@@ -438,7 +474,7 @@ except (ImportError, ModuleNotFoundError):
438
474
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
439
475
  # Kept in sync with pyproject.toml version via bump-version.sh.
440
476
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
441
- DAEMON_VERSION = "1.11.3"
477
+ DAEMON_VERSION = "1.11.4"
442
478
 
443
479
 
444
480
  def _detect_client_type() -> str:
@@ -2844,6 +2880,10 @@ class ProcessManager:
2844
2880
  or (task.input_data or {}).get("output_dir", "")
2845
2881
  or ""
2846
2882
  )
2883
+ elif task.node_type == "fix":
2884
+ bugfix_doc_path = str((task.input_data or {}).get("bugfix_doc_path", "") or "")
2885
+ bugfix_doc_path = bugfix_doc_path.replace("\\", "/").lstrip("./")
2886
+ return {bugfix_doc_path} if bugfix_doc_path else set()
2847
2887
  else:
2848
2888
  output_dir = str((task.input_data or {}).get("output_dir", "") or "")
2849
2889
  output_dir = output_dir.replace("\\", "/").lstrip("./").rstrip("/")
@@ -4646,23 +4686,76 @@ class ServerConnection:
4646
4686
  parsed = urlparse(server_url)
4647
4687
  self.label = parsed.hostname or server_url
4648
4688
 
4649
- def refresh_token(self) -> bool:
4650
- """Re-read the user JWT from ~/.forgexa/token and update client headers.
4689
+ def _apply_api_token(self, token: str) -> bool:
4690
+ token = str(token or "").strip()
4691
+ if not token or token == self.api_token:
4692
+ return False
4693
+ self.api_token = token
4694
+ self.client.headers["Authorization"] = f"Bearer {token}"
4695
+ return True
4651
4696
 
4652
- Returns True if a new token was loaded (different from current).
4653
- """
4697
+ def _load_token_from_disk(self) -> str:
4654
4698
  token_path = Path.home() / ".forgexa" / "token"
4655
4699
  try:
4656
- new_token = token_path.read_text().strip() if token_path.exists() else ""
4700
+ return token_path.read_text().strip() if token_path.exists() else ""
4657
4701
  except OSError:
4658
- new_token = ""
4702
+ return ""
4659
4703
 
4660
- if new_token and new_token != self.api_token:
4661
- self.api_token = new_token
4662
- self.client.headers["Authorization"] = f"Bearer {new_token}"
4704
+ async def refresh_access_token(self) -> bool:
4705
+ """Refresh the daemon's user session token.
4706
+
4707
+ First re-read ~/.forgexa/token in case an interactive CLI login already
4708
+ rotated the access token. If that did not change anything, fall back to
4709
+ ~/.forgexa/config refresh_token and call /api/v1/auth/refresh.
4710
+ """
4711
+ if self._apply_api_token(self._load_token_from_disk()):
4663
4712
  logger.info("[%s] Refreshed daemon token from ~/.forgexa/token", self.label)
4664
4713
  return True
4665
- return False
4714
+
4715
+ if self.api_token.startswith("pat_"):
4716
+ return False
4717
+
4718
+ cfg = _load_cli_config()
4719
+ refresh_token = str(cfg.get("refresh_token") or "").strip()
4720
+ if not refresh_token:
4721
+ return False
4722
+
4723
+ try:
4724
+ resp = await self.client.post(
4725
+ f"{self.server_url}/api/v1/auth/refresh",
4726
+ json={"refresh_token": refresh_token},
4727
+ headers={"Content-Type": "application/json"},
4728
+ timeout=15,
4729
+ )
4730
+ except Exception as exc:
4731
+ logger.warning("[%s] Token refresh request failed: %s", self.label, exc)
4732
+ return False
4733
+
4734
+ if resp.status_code != 200:
4735
+ logger.warning(
4736
+ "[%s] Token refresh rejected: HTTP %s",
4737
+ self.label,
4738
+ resp.status_code,
4739
+ )
4740
+ return False
4741
+
4742
+ try:
4743
+ data = resp.json()
4744
+ except Exception as exc:
4745
+ logger.warning("[%s] Token refresh payload invalid: %s", self.label, exc)
4746
+ return False
4747
+
4748
+ access_token = str(data.get("access_token") or "").strip()
4749
+ next_refresh_token = str(data.get("refresh_token") or refresh_token).strip()
4750
+ if not access_token:
4751
+ logger.warning("[%s] Token refresh payload missing access_token", self.label)
4752
+ return False
4753
+
4754
+ self.api_token = access_token
4755
+ self.client.headers["Authorization"] = f"Bearer {access_token}"
4756
+ _save_cli_tokens(access_token, next_refresh_token or None)
4757
+ logger.info("[%s] Refreshed daemon token via /auth/refresh", self.label)
4758
+ return True
4666
4759
 
4667
4760
  async def re_register(self, agents: list[DiscoveredAgent], max_concurrent: int):
4668
4761
  """Refresh token and re-register with the server.
@@ -4671,7 +4764,7 @@ class ServerConnection:
4671
4764
  After re-registration, update the runtime_id on heartbeat/poller/reporter
4672
4765
  in case the server assigned a different one.
4673
4766
  """
4674
- self.refresh_token()
4767
+ await self.refresh_access_token()
4675
4768
  try:
4676
4769
  await self.register(agents, max_concurrent)
4677
4770
  # Sync runtime_id to all services (may change after re-registration)
@@ -4701,8 +4794,8 @@ class ServerConnection:
4701
4794
  }
4702
4795
  for a in agents
4703
4796
  ]
4704
- try:
4705
- resp = await self.client.post(
4797
+ async def _register_once():
4798
+ return await self.client.post(
4706
4799
  f"{self.server_url}/api/v1/runtimes/register",
4707
4800
  json={
4708
4801
  "daemon_id": self.daemon_id,
@@ -4722,6 +4815,11 @@ class ServerConnection:
4722
4815
  },
4723
4816
  timeout=15,
4724
4817
  )
4818
+
4819
+ try:
4820
+ resp = await _register_once()
4821
+ if resp.status_code == 401 and await self.refresh_access_token():
4822
+ resp = await _register_once()
4725
4823
  resp.raise_for_status()
4726
4824
  data = resp.json()
4727
4825
  self.runtime_id = data["runtime_id"]
@@ -5719,9 +5817,9 @@ class RuntimeDaemon:
5719
5817
  result.status = "failed"
5720
5818
  result.failure_code = "all_agents_rate_limited"
5721
5819
 
5722
- # 4.55 Analysis/design nodes must update their deliverables in THIS run.
5820
+ # 4.55 Analysis/design/fix nodes must update their deliverables in THIS run.
5723
5821
  # Existing files from a prior iteration are not sufficient evidence.
5724
- if result.status == "success" and task.node_type in ("analysis", "design"):
5822
+ if result.status == "success" and task.node_type in ("analysis", "design", "fix"):
5725
5823
  committed_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
5726
5824
  git_check_passed = self.process_manager._has_required_deliverable_updates(
5727
5825
  task,
@@ -5770,6 +5868,11 @@ class RuntimeDaemon:
5770
5868
  if result.status == "success" and task.node_type == "design":
5771
5869
  await self._collect_design_artifacts(workspace_path, task, result)
5772
5870
 
5871
+ # 4.8 For fix nodes: attach the bugfix report inline so knowledge
5872
+ # extraction can use the exact root-cause and verification text.
5873
+ if result.status == "success" and task.node_type == "fix":
5874
+ await self._collect_bugfix_artifacts(workspace_path, task, result)
5875
+
5773
5876
  # 5. Auto-commit and push if changes exist
5774
5877
  if result.status == "success":
5775
5878
  commit_result = await self._auto_commit(workspace_path, task)
@@ -6547,6 +6650,34 @@ class RuntimeDaemon:
6547
6650
  except Exception as e:
6548
6651
  logger.warning("Failed to read design artifact: %s", e)
6549
6652
 
6653
+ async def _collect_bugfix_artifacts(
6654
+ self, workspace_path: Path, task: TaskInfo, result: TaskResult
6655
+ ) -> None:
6656
+ """Attach the bugfix markdown report as an inline artifact."""
6657
+ bugfix_doc_path = str((task.input_data or {}).get("bugfix_doc_path", "") or "")
6658
+ bugfix_doc_path = bugfix_doc_path.replace("\\", "/").lstrip("./")
6659
+ if not bugfix_doc_path:
6660
+ return
6661
+
6662
+ artifact_paths = {a.get("path", "").replace("\\", "/") for a in result.artifacts}
6663
+ if bugfix_doc_path in artifact_paths:
6664
+ return
6665
+
6666
+ full_path = workspace_path / bugfix_doc_path
6667
+ if not full_path.exists() or full_path.stat().st_size == 0:
6668
+ return
6669
+
6670
+ try:
6671
+ content = full_path.read_text(encoding="utf-8", errors="replace")
6672
+ result.artifacts.append({
6673
+ "path": bugfix_doc_path,
6674
+ "content": content,
6675
+ "type": "text/markdown",
6676
+ })
6677
+ logger.debug("Attached bugfix artifact inline: %s (%d bytes)", bugfix_doc_path, len(content))
6678
+ except Exception as e:
6679
+ logger.warning("Failed to read bugfix artifact %s: %s", bugfix_doc_path, e)
6680
+
6550
6681
  async def _remove_root_scratch_files(
6551
6682
  self, workspace_path: Path, task: TaskInfo, git_status_output: str
6552
6683
  ) -> None:
@@ -69,6 +69,29 @@ def _save_config(data: dict) -> None:
69
69
  p.chmod(0o600)
70
70
 
71
71
 
72
+ def _save_tokens(
73
+ access_token: str,
74
+ refresh_token: str | None = None,
75
+ *,
76
+ server_url: str | None = None,
77
+ ) -> None:
78
+ cfg = _load_config()
79
+ if server_url:
80
+ cfg["server_url"] = server_url.rstrip("/")
81
+ cfg["token"] = access_token
82
+ if refresh_token:
83
+ cfg["refresh_token"] = refresh_token
84
+ else:
85
+ cfg.pop("refresh_token", None)
86
+ _save_config(cfg)
87
+
88
+ token_dir = Path.home() / ".forgexa"
89
+ token_dir.mkdir(exist_ok=True)
90
+ token_file = token_dir / "token"
91
+ token_file.write_text(access_token)
92
+ token_file.chmod(0o600)
93
+
94
+
72
95
  def _api_url() -> str:
73
96
  """Resolve the server URL using priority chain."""
74
97
  if _SERVER_URL_OVERRIDE:
@@ -93,70 +116,111 @@ def _token() -> str | None:
93
116
  return cfg.get("token") or None
94
117
 
95
118
 
96
- def _headers() -> dict[str, str]:
97
- h: dict[str, str] = {"Content-Type": "application/json"}
119
+ def _can_refresh_session() -> bool:
120
+ if os.environ.get("FORGEXA_TOKEN"):
121
+ return False
98
122
  token = _token()
99
- if token:
100
- h["Authorization"] = f"Bearer {token}"
101
- return h
123
+ if not token or token.startswith("pat_"):
124
+ return False
125
+ cfg = _load_config()
126
+ return bool(str(cfg.get("refresh_token") or "").strip())
102
127
 
103
128
 
104
- def _get(path: str) -> dict | list:
129
+ def _refresh_access_token() -> bool:
130
+ if not _can_refresh_session():
131
+ return False
132
+
133
+ cfg = _load_config()
134
+ refresh_token = str(cfg.get("refresh_token") or "").strip()
135
+ if not refresh_token:
136
+ return False
137
+
105
138
  import urllib.request
106
139
  import urllib.error
107
140
 
108
- url = f"{_api_url()}/api/v1{path}"
109
- req = urllib.request.Request(url, headers=_headers())
141
+ url = f"{_api_url()}/api/v1/auth/refresh"
142
+ body = json.dumps({"refresh_token": refresh_token}).encode()
143
+ req = urllib.request.Request(
144
+ url,
145
+ data=body,
146
+ headers={"Content-Type": "application/json"},
147
+ method="POST",
148
+ )
110
149
  try:
111
150
  with urllib.request.urlopen(req, timeout=15) as resp:
112
- return json.loads(resp.read())
113
- except urllib.error.HTTPError as e:
114
- body = e.read().decode(errors="replace")
115
- print(f"Error {e.code}: {body}", file=sys.stderr)
116
- sys.exit(1)
117
- except urllib.error.URLError as e:
118
- print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
119
- sys.exit(1)
151
+ payload = json.loads(resp.read())
152
+ except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, OSError):
153
+ return False
120
154
 
155
+ access_token = str(payload.get("access_token") or "").strip()
156
+ next_refresh_token = str(payload.get("refresh_token") or refresh_token).strip()
157
+ if not access_token:
158
+ return False
121
159
 
122
- def _post(path: str, data: dict | None = None) -> dict:
123
- import urllib.request
124
- import urllib.error
160
+ _save_tokens(access_token, next_refresh_token or None)
161
+ return True
125
162
 
126
- url = f"{_api_url()}/api/v1{path}"
127
- body = json.dumps(data or {}).encode()
128
- req = urllib.request.Request(url, data=body, headers=_headers(), method="POST")
129
- try:
130
- with urllib.request.urlopen(req, timeout=30) as resp:
131
- return json.loads(resp.read())
132
- except urllib.error.HTTPError as e:
133
- body_text = e.read().decode(errors="replace")
134
- print(f"Error {e.code}: {body_text}", file=sys.stderr)
135
- sys.exit(1)
136
- except urllib.error.URLError as e:
137
- print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
138
- sys.exit(1)
139
163
 
164
+ def _headers() -> dict[str, str]:
165
+ h: dict[str, str] = {"Content-Type": "application/json"}
166
+ token = _token()
167
+ if token:
168
+ h["Authorization"] = f"Bearer {token}"
169
+ return h
140
170
 
141
- def _delete(path: str) -> dict | None:
171
+
172
+ def _request_json(
173
+ path: str,
174
+ *,
175
+ method: str = "GET",
176
+ data: dict | None = None,
177
+ timeout: int = 15,
178
+ allow_refresh: bool = True,
179
+ ) -> dict | list | None:
142
180
  import urllib.request
143
181
  import urllib.error
144
182
 
145
183
  url = f"{_api_url()}/api/v1{path}"
146
- req = urllib.request.Request(url, headers=_headers(), method="DELETE")
184
+ body = json.dumps(data).encode() if data is not None else None
185
+ req = urllib.request.Request(url, data=body, headers=_headers(), method=method)
147
186
  try:
148
- with urllib.request.urlopen(req, timeout=15) as resp:
187
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
149
188
  content = resp.read()
150
189
  return json.loads(content) if content else None
151
190
  except urllib.error.HTTPError as e:
152
- body = e.read().decode(errors="replace")
153
- print(f"Error {e.code}: {body}", file=sys.stderr)
191
+ body_text = e.read().decode(errors="replace")
192
+ if e.code == 401 and allow_refresh and _refresh_access_token():
193
+ return _request_json(
194
+ path,
195
+ method=method,
196
+ data=data,
197
+ timeout=timeout,
198
+ allow_refresh=False,
199
+ )
200
+ print(f"Error {e.code}: {body_text}", file=sys.stderr)
154
201
  sys.exit(1)
155
202
  except urllib.error.URLError as e:
156
203
  print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
157
204
  sys.exit(1)
158
205
 
159
206
 
207
+ def _get(path: str) -> dict | list:
208
+ result = _request_json(path, method="GET", timeout=15)
209
+ if result is None:
210
+ return {}
211
+ return result
212
+
213
+
214
+ def _post(path: str, data: dict | None = None) -> dict:
215
+ result = _request_json(path, method="POST", data=data or {}, timeout=30)
216
+ return result if isinstance(result, dict) else {}
217
+
218
+
219
+ def _delete(path: str) -> dict | None:
220
+ result = _request_json(path, method="DELETE", timeout=15)
221
+ return result if isinstance(result, dict) else None
222
+
223
+
160
224
  # ── Output helpers ──
161
225
 
162
226
  _output_format = "table"
@@ -203,20 +267,9 @@ def cmd_login(args: argparse.Namespace) -> None:
203
267
  email = args.email or input("Email: ")
204
268
  password = args.password or getpass.getpass("Password: ")
205
269
  result = _post("/auth/login", {"email": email, "password": password})
206
- token = result.get("access_token", "")
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
216
- token_dir = Path.home() / ".forgexa"
217
- token_dir.mkdir(exist_ok=True)
218
- (token_dir / "token").write_text(token)
219
- (token_dir / "token").chmod(0o600)
270
+ token = str(result.get("access_token") or "").strip()
271
+ refresh_token = str(result.get("refresh_token") or "").strip()
272
+ _save_tokens(token, refresh_token or None, server_url=_SERVER_URL_OVERRIDE if server else None)
220
273
 
221
274
  active_server = _api_url()
222
275
  print(f"Login successful.")
@@ -233,10 +286,11 @@ def cmd_logout(_args: argparse.Namespace) -> None:
233
286
  cfg = _load_config()
234
287
  if "token" in cfg:
235
288
  del cfg["token"]
289
+ cfg.pop("refresh_token", None)
236
290
  _save_config(cfg)
237
291
  cleared = True
238
292
  if cleared:
239
- print("Logged out. Token removed.")
293
+ print("Logged out. Tokens removed.")
240
294
  else:
241
295
  print("No token found.")
242
296
 
@@ -255,6 +309,7 @@ def cmd_config_show(_args: argparse.Namespace) -> None:
255
309
  source = f"~/.forgexa/config"
256
310
  print(f"Server URL : {active_url} (source: {source})")
257
311
  print(f"Auth token : {'set' if token else 'not set'}")
312
+ print(f"Refresh tok: {'set' if cfg.get('refresh_token') else 'not set'}")
258
313
  print(f"Config file: {_config_path()}")
259
314
 
260
315
 
@@ -273,8 +328,14 @@ def cmd_config_set(args: argparse.Namespace) -> None:
273
328
  sys.exit(1)
274
329
 
275
330
 
276
- def cmd_daemon_status(_args: argparse.Namespace) -> None:
277
- runtimes = _get("/runtimes")
331
+ def _get_runtimes(include_all: bool = False) -> list[dict]:
332
+ path = "/runtimes" if include_all else "/runtimes/me"
333
+ runtimes = _get(path)
334
+ return runtimes if isinstance(runtimes, list) else []
335
+
336
+
337
+ def cmd_daemon_status(args: argparse.Namespace) -> None:
338
+ runtimes = _get_runtimes(include_all=getattr(args, "all", False))
278
339
  if not runtimes:
279
340
  print("No daemons registered.")
280
341
  return
@@ -363,8 +424,8 @@ def cmd_daemon_start(args: argparse.Namespace) -> None:
363
424
  main_sync()
364
425
 
365
426
 
366
- def cmd_runtimes_list(_args: argparse.Namespace) -> None:
367
- runtimes = _get("/runtimes")
427
+ def cmd_runtimes_list(args: argparse.Namespace) -> None:
428
+ runtimes = _get_runtimes(include_all=getattr(args, "all", False))
368
429
  if not runtimes:
369
430
  print("No runtimes registered.")
370
431
  return
@@ -596,11 +657,11 @@ def main() -> None:
596
657
  sub.add_parser("version", help="Show CLI version")
597
658
 
598
659
  # auth
599
- login_p = sub.add_parser("login", help="Login and save access token")
660
+ login_p = sub.add_parser("login", help="Login and save session tokens")
600
661
  login_p.add_argument("--server", metavar="URL", help="Server URL to connect to (saved to ~/.forgexa/config)")
601
662
  login_p.add_argument("--email", help="Email address")
602
663
  login_p.add_argument("--password", help="Password")
603
- sub.add_parser("logout", help="Remove saved access token")
664
+ sub.add_parser("logout", help="Remove saved session tokens")
604
665
 
605
666
  # config
606
667
  config_p = sub.add_parser("config", help="View or change CLI configuration")
@@ -616,13 +677,15 @@ def main() -> None:
616
677
  daemon_start_p = daemon_sub.add_parser("start", help="Start local daemon (discovers and registers AI agents)")
617
678
  daemon_start_p.add_argument("-d", "--detach", action="store_true", help="Run in background")
618
679
  daemon_start_p.add_argument("--server-url", default=None, help="Server URL to connect to")
619
- daemon_sub.add_parser("status", help="Show all daemon statuses (from server)")
680
+ daemon_status_p = daemon_sub.add_parser("status", help="Show your daemon statuses (from server)")
681
+ daemon_status_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
620
682
  daemon_sub.add_parser("stop", help="Stop local daemon (sends SIGTERM)")
621
683
 
622
684
  # runtimes
623
685
  rt_p = sub.add_parser("runtimes", help="Runtime management")
624
686
  rt_sub = rt_p.add_subparsers(dest="rt_cmd")
625
- rt_sub.add_parser("list", help="List all runtimes")
687
+ rt_list_p = rt_sub.add_parser("list", help="List your runtimes")
688
+ rt_list_p.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
626
689
 
627
690
  # workspace
628
691
  ws_p = sub.add_parser("workspace", help="Workspace management")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.11.3
3
+ Version: 1.11.4
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
@@ -100,9 +100,9 @@ forgexa board --project <project-id>
100
100
  | `forgexa budget --workspace <id>` | Budget overview |
101
101
  | `forgexa daemon start` | Start local daemon (discover agents, run tasks) |
102
102
  | `forgexa daemon start -d` | Start daemon in background |
103
- | `forgexa daemon status` | Show daemon statuses |
103
+ | `forgexa daemon status` | Show your daemon statuses |
104
104
  | `forgexa daemon stop` | Stop local daemon |
105
- | `forgexa runtimes list` | List runtimes |
105
+ | `forgexa runtimes list` | List your runtimes |
106
106
  | `forgexa version` | Show CLI version |
107
107
 
108
108
  ## Configuration
@@ -144,11 +144,20 @@ forgexa-daemon
144
144
  ### Other Daemon Commands
145
145
 
146
146
  ```bash
147
- # Check daemon status (from server)
147
+ # Check your daemon status (from server)
148
148
  forgexa daemon status
149
149
 
150
+ # Platform admin: list all runtimes
151
+ forgexa daemon status --all
152
+
150
153
  # Stop background daemon
151
154
  forgexa daemon stop
155
+
156
+ # List your runtimes
157
+ forgexa runtimes list
158
+
159
+ # Platform admin: list all runtimes
160
+ forgexa runtimes list --all
152
161
  ```
153
162
 
154
163
  ### Supported AI Agents
@@ -10,4 +10,5 @@ forgexa_cli.egg-info/SOURCES.txt
10
10
  forgexa_cli.egg-info/dependency_links.txt
11
11
  forgexa_cli.egg-info/entry_points.txt
12
12
  forgexa_cli.egg-info/requires.txt
13
- forgexa_cli.egg-info/top_level.txt
13
+ forgexa_cli.egg-info/top_level.txt
14
+ tests/test_auth_and_runtime_commands.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.11.3"
3
+ version = "1.11.4"
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" }
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ import urllib.error
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from forgexa_cli import daemon, main
13
+
14
+
15
+ class UrlopenResponse:
16
+ def __init__(self, payload):
17
+ self._payload = payload
18
+
19
+ def read(self) -> bytes:
20
+ return json.dumps(self._payload).encode("utf-8")
21
+
22
+ def __enter__(self):
23
+ return self
24
+
25
+ def __exit__(self, exc_type, exc, tb):
26
+ return False
27
+
28
+
29
+ class HttpxResponse:
30
+ def __init__(self, status_code: int, payload: dict):
31
+ self.status_code = status_code
32
+ self._payload = payload
33
+ self.request = httpx.Request("POST", "https://api.example.com/api/v1/runtimes/register")
34
+
35
+ def json(self) -> dict:
36
+ return self._payload
37
+
38
+ def raise_for_status(self) -> None:
39
+ if self.status_code >= 400:
40
+ raise httpx.HTTPStatusError(
41
+ f"HTTP {self.status_code}",
42
+ request=self.request,
43
+ response=self,
44
+ )
45
+
46
+
47
+ def _http_error(url: str, status_code: int, payload: dict) -> urllib.error.HTTPError:
48
+ return urllib.error.HTTPError(
49
+ url,
50
+ status_code,
51
+ payload.get("detail", "error"),
52
+ hdrs=None,
53
+ fp=io.BytesIO(json.dumps(payload).encode("utf-8")),
54
+ )
55
+
56
+
57
+ def test_login_persists_refresh_token(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
58
+ monkeypatch.setenv("HOME", str(tmp_path))
59
+ monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
60
+ monkeypatch.setattr(
61
+ main,
62
+ "_post",
63
+ lambda path, data=None: {
64
+ "access_token": "access-1",
65
+ "refresh_token": "refresh-1",
66
+ },
67
+ )
68
+
69
+ args = SimpleNamespace(
70
+ server="https://api.example.com",
71
+ email="user@example.com",
72
+ password="secret",
73
+ )
74
+ main.cmd_login(args)
75
+
76
+ cfg = json.loads((tmp_path / ".forgexa" / "config").read_text())
77
+ assert cfg["token"] == "access-1"
78
+ assert cfg["refresh_token"] == "refresh-1"
79
+ assert cfg["server_url"] == "https://api.example.com"
80
+ assert (tmp_path / ".forgexa" / "token").read_text() == "access-1"
81
+
82
+
83
+ def test_get_retries_once_after_refresh_on_401(
84
+ tmp_path: Path,
85
+ monkeypatch: pytest.MonkeyPatch,
86
+ ) -> None:
87
+ monkeypatch.setenv("HOME", str(tmp_path))
88
+ monkeypatch.setattr(main, "_SERVER_URL_OVERRIDE", None)
89
+ config_dir = tmp_path / ".forgexa"
90
+ config_dir.mkdir()
91
+ (config_dir / "config").write_text(
92
+ json.dumps(
93
+ {
94
+ "server_url": "https://api.example.com",
95
+ "token": "old-access",
96
+ "refresh_token": "refresh-1",
97
+ }
98
+ )
99
+ )
100
+ (config_dir / "token").write_text("old-access")
101
+
102
+ import urllib.request
103
+
104
+ runtime_called = {"count": 0}
105
+
106
+ def fake_urlopen(req, timeout=0):
107
+ url = req.full_url
108
+ if url.endswith("/api/v1/runtimes/me"):
109
+ runtime_called["count"] += 1
110
+ if runtime_called["count"] == 1:
111
+ raise _http_error(url, 401, {"detail": "Invalid token"})
112
+ assert req.headers.get("Authorization") == "Bearer new-access"
113
+ return UrlopenResponse([
114
+ {
115
+ "id": "12345678",
116
+ "daemon_id": "demo-daemon",
117
+ "status": "online",
118
+ }
119
+ ])
120
+ if url.endswith("/api/v1/auth/refresh"):
121
+ assert json.loads(req.data.decode("utf-8")) == {"refresh_token": "refresh-1"}
122
+ return UrlopenResponse(
123
+ {
124
+ "access_token": "new-access",
125
+ "refresh_token": "refresh-2",
126
+ }
127
+ )
128
+ raise AssertionError(f"Unexpected URL: {url}")
129
+
130
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
131
+
132
+ result = main._get("/runtimes/me")
133
+
134
+ assert isinstance(result, list)
135
+ assert runtime_called["count"] == 2
136
+ cfg = json.loads((config_dir / "config").read_text())
137
+ assert cfg["token"] == "new-access"
138
+ assert cfg["refresh_token"] == "refresh-2"
139
+ assert (config_dir / "token").read_text() == "new-access"
140
+
141
+
142
+ def test_runtime_commands_use_user_scope_by_default(
143
+ monkeypatch: pytest.MonkeyPatch,
144
+ ) -> None:
145
+ calls: list[bool] = []
146
+ monkeypatch.setattr(
147
+ main,
148
+ "_get_runtimes",
149
+ lambda include_all=False: calls.append(include_all) or [],
150
+ )
151
+
152
+ main.cmd_daemon_status(SimpleNamespace(all=False))
153
+ main.cmd_runtimes_list(SimpleNamespace(all=False))
154
+ main.cmd_runtimes_list(SimpleNamespace(all=True))
155
+
156
+ assert calls == [False, False, True]
157
+
158
+
159
+ @pytest.mark.asyncio
160
+ async def test_daemon_refreshes_access_token_from_refresh_token(
161
+ tmp_path: Path,
162
+ monkeypatch: pytest.MonkeyPatch,
163
+ ) -> None:
164
+ monkeypatch.setenv("HOME", str(tmp_path))
165
+ config_dir = tmp_path / ".forgexa"
166
+ config_dir.mkdir()
167
+ (config_dir / "config").write_text(
168
+ json.dumps(
169
+ {
170
+ "token": "stale-access",
171
+ "refresh_token": "refresh-1",
172
+ }
173
+ )
174
+ )
175
+ (config_dir / "token").write_text("stale-access")
176
+
177
+ conn = daemon.ServerConnection("https://api.example.com", "stale-access", "daemon-1")
178
+ try:
179
+ async def fake_post(url, json=None, headers=None, timeout=None):
180
+ assert url.endswith("/api/v1/auth/refresh")
181
+ assert json == {"refresh_token": "refresh-1"}
182
+ return HttpxResponse(
183
+ 200,
184
+ {
185
+ "access_token": "fresh-access",
186
+ "refresh_token": "refresh-2",
187
+ },
188
+ )
189
+
190
+ monkeypatch.setattr(conn.client, "post", fake_post)
191
+
192
+ refreshed = await conn.refresh_access_token()
193
+
194
+ assert refreshed is True
195
+ assert conn.api_token == "fresh-access"
196
+ assert conn.client.headers["Authorization"] == "Bearer fresh-access"
197
+ cfg = json.loads((config_dir / "config").read_text())
198
+ assert cfg["token"] == "fresh-access"
199
+ assert cfg["refresh_token"] == "refresh-2"
200
+ assert (config_dir / "token").read_text() == "fresh-access"
201
+ finally:
202
+ await conn.client.aclose()
203
+
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_daemon_register_retries_once_after_refresh(
207
+ monkeypatch: pytest.MonkeyPatch,
208
+ ) -> None:
209
+ conn = daemon.ServerConnection("https://api.example.com", "stale-access", "daemon-1")
210
+ try:
211
+ async def fake_refresh_access_token() -> bool:
212
+ conn.api_token = "fresh-access"
213
+ conn.client.headers["Authorization"] = "Bearer fresh-access"
214
+ return True
215
+
216
+ responses = iter(
217
+ [
218
+ HttpxResponse(401, {"detail": "Invalid token"}),
219
+ HttpxResponse(200, {"runtime_id": "runtime-1"}),
220
+ ]
221
+ )
222
+
223
+ async def fake_post(url, json=None, headers=None, timeout=None):
224
+ response = next(responses)
225
+ if response.status_code == 200:
226
+ assert json["api_token"] == "fresh-access"
227
+ return response
228
+
229
+ monkeypatch.setattr(conn, "refresh_access_token", fake_refresh_access_token)
230
+ monkeypatch.setattr(conn.client, "post", fake_post)
231
+
232
+ await conn.register([], 2)
233
+
234
+ assert conn.runtime_id == "runtime-1"
235
+ finally:
236
+ await conn.client.aclose()
File without changes